The API Diff Tool That Escaped the Monolith
A major API rewrite meant someone had to tell the mobile team exactly what had changed. I'd need to document every renamed field, every new endpoint, every removed response property. Doing this manually was going to be a herculean, error-prone task. Surely, there had to be a way to diff API contracts between deployments.
Swagger UI is great for exploring your current API but it tells you nothing about what changed. When you have multiple-mobile apps, third-party integrations, internal frontends; you need to answer questions like:
- What endpoints changed between Tuesday and today?
- Did we introduce any breaking changes in this release?
- When exactly did the
GET /usersresponse schema change?
I built a tool to solve this: an in-app Swagger diff viewer that snapshots your OpenAPI spec on every merge and lets you visually compare any two versions. It started as a React SPA inside a monolith. When I broke the monolith into micro services, the React approach didn't scale. So I rewrote the frontend as a single static HTML file, embedded it as a resource in a shared .NET library and every micro service got the diff tool for free.
Then I had an epiphany: what if this didn't need to be coupled to my codebase at all and it could just be reusable for newer projects?
This post walks through three generations of the tool: from React SPA, to embedded resource, to a standalone Nuget Package project and the patterns that finally made it reusable across any ASP.NET project. No code build steps. No duplicated code. No curl-and-sleep CI workflows.
What you'll take away from this post:
- How to embed static UIs in .NET assemblies (and why you'd want to)
- The minimal API pattern that eliminated MVC dependencies
- A reusable CLI pattern for extracting OpenAPI specs without running the server
- Auto-downloading native binaries from Nuget packages
- The
dotnet exec --depsfile pattern for loading foreign assemblies
V1: The React SPA (Monolith Era)
Lesson: Proving a concept is the first step. But tight coupling to host project limits reuse.
The first version was a React + TypeScript SPA living inside ClientApp/swagger-diff/ which was a Create React App project with react-router-dom, html-react-parser, react-toastify and all the usual suspects. It worked well in the monolith: one API project, one SPA, one build pipeline.
What it did:
- Fetched available OpenAPI snapshots from a custom controller
- Let users select two versions and a comparison mode (diff, changelog, breaking)
- Rendered the oasdiff output as formatted HTML with coloured HTTP verbs
- Generated shareable URLs via query parameters.
It was useful. Developers actually used it. But when I split the system into microservices (ServiceA.Api, ServiceB.Api, ServiceC.Api), the React app became a problem.
Each service needed the same diff tool. My options were:
1. Duplicate the entire ClientApp/ directory into every service (and maintain N copies)
2. Publish it as an npm package and add a Node build step to every .NET project
3. Deploy it as a standalone app with CORS configuration to talk to each service
None of these were acceptable. The diff viewer is a developer tool, not a user-facing feature. It doesn't warrant its own deployment, and it shouldn't force a Node dependency onto services that otherwise have none.
Lesson: Embedded resources + extension methods can distribute tooling without adding build steps.
The solution was to eliminate the build step entirely. I rewrote the React component as a single index.html file; this consisted of vanilla HTML, CSS and JavaScript. No JSX, no bundler and no more node_modules. The entire thing is under 450 lines and does exactly what the React version did.
Then I embedded it as a resource in the shared .NET library that all microservices already referenced.
┌──────────────────────────────────────────────────┐
│ Jaiye.Api.Shared │
│ (shared library referenced by all services) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ wwwroot/ (embedded resources) │ │
│ │ ├── swagger-diff-tool.html ← UI button │ │
│ │ └── swagger-diff/index.html ← Diff viewer │ │
│ ├─────────────────────────────────────────────┤ │
│ │ Extensions/ │ │
│ │ └── SwaggerDiffExtensions.cs │ │
│ ├─────────────────────────────────────────────┤ │
│ │ Controllers/ │ │
│ │ └── ApiDocsController.cs │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Services/ (in Jaiye.Bll) │
│ ├── IApiDiffClient.cs │
│ ├── OasDiffClient.cs ← shells to oasdiff │
│ └── DocumentationService.cs │
└──────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
ServiceA.Api ServiceB.Api ServiceC.Api
(just calls (just calls (just calls
ConfigurePipeline) ConfigurePipeline) ConfigurePipeline)Each microservice's Program.cs became this simp;e:
var app = builder.Build();
StartupExtensions.ConfigurePipeline(app);
await app.RunAsync();public static void ConfigurePipeline(WebApplication app, bool enableSwaggerDiff = true)
{
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
if (enableSwaggerDiff)
{
options.AddSwaggerDiffButton();
}
});
}
if (enableSwaggerDiff)
{
app.UseSwaggerDiff();
}
// ... rest of middleware pipeline
}The embedded resource pattern is what made this work. Thecsproj embeds all files under the wwwroot/ into the assembly:
<ItemGroup>
<EmbeddedResource Include="wwwroot\**\*" />
</ItemGroup>Two extension methods serve them. AddSwaggerDiffButton injects a button into Swagger UI's topbar. UseSwaggerDiff maps the swagger-diff route and serves index.html from an EmbeddedFileProvider.
public static class SwaggerDiffExtensions
{
private const string SwaggerDiffToolResource = "Service.Api.Shared.wwwroot.swagger-diff-tool.html";
private const string SwaggerDiffIndexResource = "Service.Api.Shared.wwwroot.swagger_diff.index.html";
private const string SwaggerDiffFolderResource = "Service.Api.Shared.wwwroot.swagger_diff";
public static void AddSwaggerDiffButton(this SwaggerUIOptions options)
{
var assembly = typeof(SwaggerDiffExtensions).Assembly;
using var stream = assembly.GetManifestResourceStream(SwaggerDiffToolResource);
if (stream == null) return;
using var reader = new StreamReader(stream);
options.HeadContent = reader.ReadToEnd();
}
public static IApplicationBuilder UseSwaggerDiff(this IApplicationBuilder app)
{
var assembly = typeof(SwaggerDiffExtensions).Assembly;
app.Map("/swagger-diff", swaggerDiffApp =>
{
swaggerDiffApp.UseStaticFiles(new StaticFileOptions
{
FileProvider = new EmbeddedFileProvider(assembly, SwaggerDiffFolderResource),
RequestPath = ""
});
swaggerDiffApp.Run(async context =>
{
if (context.Request.Path == "/" || context.Request.Path == "")
{
await using var stream = assembly.GetManifestResourceStream(SwaggerDiffIndexResource);
if (stream != null)
{
context.Response.ContentType = "text/html";
await stream.CopyToAsync(context.Response.Body);
return;
}
}
context.Response.StatusCode = 404;
});
});
return app;
}
}swagger-diff/index.html becomes swagger_diff.index.html. I lost an hour to this. The constant declaration makes this explicit.Result: Every microservice got the diff tool automatically, with zero configuration and zero additional dependencies. No Node or build steps. Everything is achieved with just a shared library reference.
The Static HTML Diff Viewer
The viewer itself is a single HTML file with inline CSS and JavaScript. It utilizes no frameworks and no dependencies.
UI: Three drop-down (old version, new version and comparison mode), a share button and a diff output container. A custom toast notification system replaces react-toastify which is just a positioned div with CSS transitions.
Version Loading: On init, it fetches api-docs/versions and populates both dropdown. URL query parameters (old=...&new=...&mode=...) are parsed on load so comparisons are shareable.
Diff fetching: When both versions are selected, a POST to /api-docs/compare returns the HTML diff. The response gets post-processed before rendering:
function cleanHtml(html) {
return html
.replace(/<\/?(html|head|body)[^>]*>/gi, '')
.trim()
.replace(/\b(GET|POST|PUT|DELETE|PATCH)\b/g, (verb) =>
`<span class="httpVerb ${verb.toLowerCase()}">${verb}</span>`
);
}
function filterContent(html) {
const temp = document.createElement('div');
temp.innerHTML = cleanHtml(html);
temp.querySelectorAll('li').forEach(li => {
if (li.textContent.includes('Modified media type:') &&
!li.textContent.includes('application/json')) {
li.remove();
}
});
temp.querySelectorAll('ul.endpoint-changes').forEach(ul => {
const seen = new Set();
ul.querySelectorAll('li').forEach(li => {
const text = li.textContent.trim().toLowerCase();
if (seen.has(text)) li.remove();
else seen.add(text);
});
});
return temp.innerHTML;
}HTTP verbs get colored badges (GET = green, POST = blue, PUT = amber, DELETE = red, PATCH = purple) while duplicate list items are deduplicated.
Version validation: The old version must predate the new version. Since filenames are timestamps (doc_20241029002023), string comparison works directly.
What I'd do differently: I wouldn't start with React in V1. Vanilla JS would have sufficed and saved the rewrite, but I didn't know that until I get the pain of distributing a framework-dependent UI across multiple services.
V2.5: The Uncomfortable Realization
V2 solved reuse within my own solution. Every microservice got the diff tool by referencing the shared project, but this was still coupled to my codebase:
- The
DocumentationServicehardcodedDocs/Versionsas the snapshot path - The controller inherited from my
BaseController - The resource names were prefixed with
Service.Api.Shared - The snapshot generation booted the whole API just to curl the swagger endpoint
I showed a friend this cool thing I had built and they wanted to try it out. Another problem then struck me in the face, I'd have to put rip out project-specific pieces and put in a new repo and they'd fork it to then setup the entire thing again for their own solution.
It was then I realized: The tool had escaped the monolith, but it was still trapped in my solution. The tool needed to break free from cage that was my solution.
V3: Two Standalone Packages for Any Project
Lesson: The best developer tool is the one they don't have to install or configure.
V3 is two standalone NuGet packages that solve both halves of the problem:
- The
SwaggerDiff.AspNetCorelibrary: Embeds the diff viewer UI and wires up API endpoints as minimal API routes. Install it and add two lines toProgram.csand you're done. - The
SwaggerDiff.Tooldotnet CLI tool: Generates OpenAPI snapshots from a built assembly without running the webserver. Likedotnet efgenerations migrations from your DbContext without booting the app.
The Library Package: SwaggerDiff.AspNetCore
Key decision: Minimal APIs instead of controllers
V2 used a controller with AddApplicationPart to make it discoverable. That works within a solution (my solution), but as a NuGet package it forces AddControllers() invocation on the consumer. Plenty of modern ASP.NET APIs are minimal-API only and they shouldn't need the MVC pipeline for a developer tool.
The new registration is two lines:
builder.Services.AddSwaggerDiff(); // <- register diff services
app.UserSwaggerDiff(); // <- map endpoints + serve UIThere's no need for AddController or AddApplicationPart. We just utilize minimal API lambdas mapped directly on the WebApplication.
app.MapGet("/api-docs/versions", (SwaggerDiffService service) =>
{
var versions = service.GetAvailableVersions();
return Results.Ok(new { isSuccess = true, data = versions });
}).ExcludeFromDescription();
app.MapPost("/api-docs/compare",
async (ApiDiffRequest request, SwaggerDiffService service) =>
{
var result = await service.GetDiffAsync(request);
return result == null
? Results.Ok(new { isSuccess = false, message = "Failed to retrieve the diff." })
: Results.Ok(new { isSuccess = true, data = result });
}).ExcludeFromDescription();.ExcludeFromDescriptioin() is the minimal API equivalent of [ApiExplorerSettings(IgnoreApi = true)] which keeps these internal endpoints out of the generated Swagger spec.
Dynamic resource names: The hardcoded Service.Api.Shared prefix is gone. Now it's derived from the assembly name at runtime:
private static readonly Assembly Assembly = typeof(SwaggerDiffExtensions).Assembly;
private static readonly string AssemblyName = Assembly.GetName().Name!;
private static string SwaggerDiffIndexResource =>
$"{AssemblyName}.wwwroot.swagger_diff.index.html";The oasdiff Auto-Downloader
For V2, I didn't worry about oasdiff because I had previously downloaded it and set it up in my PATH. That wasn't going to suffice since this was an uncommon dependency and the diff viewer would silently return nothing if the binary was missing.
V3 handles it automatically. The OasDiffDownloader resolves the binary through a four-step cascade:
- Explicit path: if
SwaggerDiffOptions.OasDiffPathis set, use that directly - Path lookup: scan the system PATH for an existing
oasdiffbinary - Local cache: check
~/.swaggerdiff/bin/{version}/oasdiff - Auto-download: fetch the release tarball from Github and extract it
The download is thread-safe and platform-aware. macOS gets the universal binary while Linux and Windows get the appropriate architecture.
Result: The diff viewer works out of the box on first request. There are no install instructions or toolchains to be utilized to get it running. The first comparisons takes a few seconds longer while the binary downloads; subsequent calls are instant.
OasDiffPath to a pre-downloaded binary committed to your repository or mounted as a volume. The auto-downloader respects this override and won't attempt to fetch from GitHub.The CLI Tool: SwaggerDiff.Tool
Lesson: The two-stage subprocess pattern is essential for any CLI tool that needs to load a target app's assembly.
V2's snapshot generation was a CI workflow that booted the entire API, waited for it to start, curled the swagger endpoint and compared JSON. It worked, but it was slow, fragile and required the full runtime environment.
The naive approach is to call Assembly.LoadFrom("MyApi.dll") and resolve ISwaggerProvider. This works until MyApi.dll depends on Npgsql 8.0 and your tool ships with no Npgsql at all. FileNotFoundException. The problem is dependency isolation: your tool runs in its own context, but the target assembly needs its own.
The solution is a two-stage subprocess pattern which is the same technique Swashbuckle CLI and dotnet ef use.
Stage 1 (the snapshot command): Resolves the target assembly path, builds the project if needed, then re-invokes itself via dotnet exec with the target app's dependency graph:
dotnet exec \
--depsfile MyApi.deps.json \
--runtimeconfig MyApi.runtimeconfig.json \
SwaggerDiff.Tool.dll \
_snapshot --assembly MyApi.dll --output Docs/VersionsThe --depsfile and --runtimeconfig flags tell the .NET runtime to use the target app's dependency resolution, not the tool's.
Stage 2 (the hidden _snapshot command): Now running in the target app's runtime context, it loads the assembly, builds the DI container using HostFactoryResolver, replaces the web server with a no-op implementation, strips non-essential hosted services, resolves ISwaggerProvider and extracts the OpenAPI document.
This means we've stripped out the need for a server/port and curl. It's now just a built assembly and a fully configured DI container.
The before/after:
- dotnet run --project MyApi --urls http://localhost:5000 &
- sleep 10
- curl http://localhost:5000/swagger/v1/swagger.json -o swagger.json
- kill $(lsof -t -i:5000)
- jq --sort-keys '.info.version = "1.0"' latest.json > /tmp/last.json
+ swagger-diff snapshot --project ./src/MyApi/MyApi.csprojThe CLI tool handles build, extraction, comparison and file writing in one command. It's faster, more reliable and works anywhere dotnet runs.
Integration in a Fresh Project
Here's the full walkthrough for someone starting from scratch.
- Install the packages:
dotnet add package SwaggerDiff.AspNetCore
dotnet tool install --global SwaggerDiff.ToolThat's it. oasdiff is auto-downloaded on first use.
- Add two lines to
Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerDiff(); // ← register diff services
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.AddSwaggerDiffButton(); // ← inject button into Swagger UI
});
app.UseSwaggerDiff(); // ← serve the diff viewer at /swagger-diff
app.Run();Works with minimal APIs or controllers. No MVC dependency is pulled in.
- Generate the first snapshot:
swagger-diff snapshotRun this from your project directory. It auto-discovers the .csproj, builds it, extracts the OpenAPI spec and writes Docs/Versions/doc_20250610143022.json.
- Add snapshot to your .csproj
<ItemGroup>
<None Include="Docs\Versions\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>- Generate snapshots in CI
swagger-diff snapshot --project ./src/MyApi/MyApi.csproj -c ReleaseIf the API surface hasn't changed, the tool prints "No API changes detected" and exits without writing a file.
That's the full integration. Two NuGet packages, two lines in Program.cs, a directory for snapshots, and a CLI command.
This tool has been running in production across 2 projects (broken in 7 microservices) for over a year. It's generated hundreds of snapshots and caught breaking changes before deployment multiple times.
Real incidents it prevented:
- A developer removed a required field from a response DTO. The diff tool highlighted the breaking change in the PR. The
- A mobile engineer used the shareable URL feature to link directly to a diff between v2.3.1 and v2.4.0, confirming exactly which endpoints had changed before updating the client.
Design Decisions Worth Stealing
Why minimal APIs instead of controllers?
Forces zero MVC overhead on consumers. Works with any project shape.
Why a static HTML file instead of React?
Eliminates the build step. Embeds cleanly in an assembly. 450 lines vs 10,000+ lines of node_modules. The diff viewer doesn't need a framework.
Why embedded resources instead of serving from disk?
Files on disk require copying during build/publish. Embedded resources travel with the assembly. No CopyToOutputDirectory, publish scripts or missing files in production.
Why auto-download oasdiff instead of bundling or requiring manual install?
Bundling a Go binary would bloat the package and require per-platform builds. Manual install adds friction and a failure mode. Auto-download keeps the package small, handles platform detection, and works on first request.
Why a CLI tool instead of a CI workflow?
The V2 workflow was slow, fragile and required a running server. The CLI tool extracts the spec directly from the assembly, making it faster, more reliable and works anywhere dotnet runs.
Why the two-stage subprocess pattern?
Direct Assembly.LoadFrom fails on transitive dependencies. The dotnet exec --depsfile pattern gives you the target app's dependency graph. It's the same technique dotnet ef and Swashbuckle CLI use, and it's the only reliable way to load foreign assemblies.
Why filesystem-based snapshots instead of a database?
Snapshots are JSON files committed to git. Version history for free. Reproducible diffs from any checkout. No external dependencies. OpenAPI specs are 50-200KB, even 100 snapshots is under 20MB.
Why shell out to oasdiff instead of a .NET library?oasdiff is mature, well-maintained, and OpenAPI 3.x compliant. Writing equivalent C# would be significant effort with little benefit. The IApiDiffClient interface means you can swap the implementation later.
What I'd Do Differently
V1: I wouldn't start with React. Vanilla JS would have sufficed and saved an entire rewrite. The cost of framework tooling isn't worth it for a single-page internal tool.
V2: The hardcoded Service.Api.Shared namespace in resource paths should have been dynamic from day one. I knew better but I was just in a hurry. That coupling delayed V3 by months.
V3: I hadn't thought about how this works in an air-gapped environment until I saw a new for it.
The two-stage subprocess pattern: I spent two weeks fighting AssemblyLoadContext before discovering HostFactoryResolver. The lesson: don't fight the runtime. If Microsoft's own tools use a pattern, adopt it.
Wrapping Up
The swagger diff tool went through three generations.
V1 proved the concept that visual API diffing is useful and developers will actually use it if it's accessible from Swagger UI.
V2 proved the distribution model (embedded resources + extension methods) beat React build pipelines for developer tooling.
V3 proved the reusability of the same tool works in a fresh project with no shared codebase, no project references, and no knowledge of the original implementation. And the CLI tool replaces fragile curl-and-sleep CI workflows with a single command.
The key insight across all three: Developer tooling doesn't need a framework. A single HTML file with inline CSS and JS, served via EmbeddedFileProvider, gives you the same functionality as a React SPA—with none of the build complexity. And when that file lives inside an assembly, it travels with the package to any project that installs it.
Today, when a developer renames userName to username, the snapshot workflow catches it. The diff tool shows exactly what changed, when and where. The PR is flagged before merge. The mobile team gets notified.
No more having to manually track what has changed where!