Most teams think they’ve solved API versioning in production once they add /v1/ to their URL paths. Eighteen months later they have five versions still running, three consumers on a version that was “deprecated” eight months ago, and a deployment they can’t make without calling someone at 11pm.
I’ve managed .NET APIs through this exact situation more than once. The versioning mistakes are always the same. And they’re almost never about which strategy you chose.
The Real Problem Is Not Routing — It’s Relationships
API versioning is a communication problem dressed up as a technical one.
The routing part — serving v1 and v2 from the same codebase, connecting it to your Swagger UI, wiring it through the gateway — takes an afternoon. In ASP.NET Core with Asp.Versioning.Mvc, it’s a NuGet package and a few annotations.
The hard part is managing the relationship with every consumer that depends on your contract, across time, as that contract changes. Get that wrong and the technical setup doesn’t matter.
Every versioning failure I’ve seen started with a team that treated API versioning in production as an infrastructure problem and ignored the process around it.
Breaking vs. Non-Breaking Changes: Where Most Teams Draw the Line Wrong
A breaking change is anything a consumer could write correct code against today that would fail — silently or loudly — after your deploy.
That definition is broader than most teams think.

Obvious breaking changes: removing a response field, changing a field’s type (string to int, nullable to required), renaming an endpoint, changing an HTTP method, adding a required request field with no default.
The non-obvious ones are what actually bite teams.
Changing validation rules is a breaking change. A field that accepted empty strings previously now rejects them. Consumers sending empty strings start getting 400s they’ve never seen before. You didn’t change the contract — you just enforced it differently. Doesn’t matter.
Adding a new enum value is a breaking change for any consumer using a strictly-typed deserializer. In .NET, JsonStringEnumConverter will throw on an unknown value. You add a new OrderStatus entry in your response, deploy, and a consumer’s background job starts throwing JsonException on every order with that status. I’ve shipped this. It’s an uncomfortable support call.
Changing sort order of a list can break pagination assumptions consumers built on top of your API. If a consumer built a “fetch all records by iterating pages” flow assuming stable ordering, a sort change silently corrupts their state.
These three show up constantly. They’re shipped as patches because they “don’t change any fields” — and they break production.
Safe changes: adding optional response fields, adding optional request fields with defaults, adding new endpoints, relaxing validation rules, improving error messages (text only — never change structure or status codes).
Document your definition of “breaking” explicitly. Make it part of your team’s definition of done. If it’s not written down, you’ll have twelve different opinions at 2am when something is on fire.
The Three Versioning Strategies and When Each One Breaks Down
API versioning in production relies on three main strategies. Each is technically valid. Each fails in a predictable way if you apply it in the wrong context.

URL Path Versioning
GET /api/v1/orders/{id}
GET /api/v2/orders/{id}
This is what most teams default to. It’s explicit, cacheable, and visible everywhere — logs, gateway configs, error reports. A new developer can read a request in a log and know exactly which version was called without checking anything.
The downside is it pollutes your URL space and tempts early versioning. I’ve seen codebases with /v1/, /v2/, /v3/ routes where the only difference between v1 and v2 was a single optional field. That’s not versioning — it’s indecision with a routing layer on top.
In .NET, Asp.Versioning.Mvc handles this cleanly. The real gotcha: if you’re using Swashbuckle, you need to configure a Swagger document per version or you’ll generate a merged spec that’s wrong for every version.
Header Versioning
GET /api/orders/{id}
API-Version: 2
Cleaner URLs, harder to debug. Header versioning makes sense for internal service-to-service APIs where the consuming teams own their HTTP clients and can be trusted to set headers correctly. It’s also easier to default: if no version header is present, serve the configured default.
The problem is visibility. When something breaks in production and you’re scanning logs, /api/orders/123 tells you nothing about which version was called. You need to dig into request headers, which aren’t always captured at every layer. In systems with multiple consumers on different versions, this slows debugging down significantly.
Query String Versioning
GET /api/orders/{id}?api-version=2
Easy to test from a browser, ugly in production. Query parameters get stripped by some caches and proxies. I’ve used this once — for a publicly accessible API that needed to be directly testable without tooling. It worked well enough for that specific case. In production systems with real consumers, I wouldn’t reach for it.
My default: URL path for public and partner APIs, header for internal service contracts. Visibility matters more than clean URLs at 2am.
Deprecation Is a Process, Not an Event
Most teams “deprecate” a version by posting a note in a changelog nobody reads. That’s not deprecation. That’s making yourself feel better.
Real deprecation is a five-phase process with dates, communication, and enforcement.

Phase 1 — Announce with a timeline, not just a date. “v1 will be deprecated on March 1” tells consumers nothing actionable. “v1 will be deprecated on March 1. All v1 endpoints will return 410 on June 1. Migration guide is at [link].” — that’s a contract they can plan against.
Phase 2 — Add deprecation headers immediately. As soon as you announce, start returning Deprecation and Sunset headers on all v1 responses:
Deprecation: true
Sunset: Sat, 01 Jun 2024 00:00:00 GMT
Link: <https://api.example.com/v2/orders>; rel="successor-version"
This follows RFC 8594. Most consumers won’t notice — but teams running automated header inspection will catch it before your emails reach them.
Phase 3 — Track who’s still calling the old version. This is non-negotiable. Tag your logs by API version. Aggregate in whatever you’re running — Application Insights, Seq, ELK. Know your consumer distribution. If you don’t know who’s on v1, you can’t deprecate v1 safely. This is the phase that most teams skip, and it’s why most deprecation timelines slip.
Phase 4 — Three reminders. No more. At 60 days, 30 days, and 7 days before sunset. If you’ve communicated clearly, more emails just train consumers to ignore you.
Phase 5 — Enforce, then remove. Before pulling the endpoint entirely, return 410 Gone for one week. This surfaces any consumers you missed. Let them escalate. Then remove it.
The most expensive versioning mistake I’ve made in production happened at phase 3. We deprecated a version without fully knowing who was consuming it. The sunset date arrived, we checked the logs, and found an integration from a department that had been quietly using the API for fourteen months without ever being in our consumer list. The deadline slipped eight weeks.
Consumer Communication That Actually Works
Internal consumers and external ones are different problems.
For internal teams, the most effective deprecation notice is a PR comment directly on the lines in their codebase that call your deprecated endpoint. Not an email. A PR. Developers see it when they merge, they fix it in context, and it doesn’t get buried in an inbox. I’ve watched internal migrations that dragged for four months via email get resolved in ten days after switching to code-level comments.
For external consumers, the key is making migration easier than ignoring your emails. A guide that lists every breaking change with before/after examples, specific to your API, gets read. A changelog entry pointing to a GitHub diff does not.
Include an explicit “what to do if you use X” section. If your consumers are .NET developers, include the package version they should update to. If there’s a new nullable field they need to handle, show the C# code. Specificity drives adoption.
One pattern that worked: ship an HTTP file alongside the migration guide with every v2 endpoint pre-filled with example requests. Developers can test their migration without writing a single line of code. The migration velocity on the projects where we did this was noticeably better than the ones where we sent a PDF.
Three Mistakes I’d Undo From Real .NET API Projects
Versioning too early. On a small internal API with two consuming services, I added URL versioning from day one because it felt like the right practice. We ran for sixteen months without ever needing a v2. The prefix added zero value and confused every new developer who assumed v1 meant there was a meaningful alternative. Version when the problem is real. An API with one team and two consumers doesn’t need a versioning scheme — it needs clean internal contracts and a conversation.
Diverging business logic between versions. We maintained v1 and v2 in parallel for seven months. By month five, the implementations had drifted. A bug fix was applied to the shared service layer but one of the v1 DTOs was transforming the response in a way that masked it. v1 consumers were running on subtly incorrect data that nobody caught until a consumer compared outputs across versions. The fix: share domain logic, version only the API surface. Controllers and DTOs get versioned. The service layer does not. If you’re duplicating business logic per version, something has gone wrong in your architecture — and if you want to think through that trade-off carefully, the simplicity over engineering framework applies directly.
No default version. We launched a public API without configuring a default version. Requests with no API-Version header got a 400. I assumed this would force consumers to be explicit. What it produced was a support queue and three weeks of developer complaints from people who hadn’t read the docs. Set a default. Log when it’s used. Phase it out deliberately. But set one.
The Rule That Fixes Most of This
Treat your API contract like a public interface in production code. You don’t change a method signature without updating callers first. You don’t remove a method while callers still exist. You deprecate, migrate, then remove — in that order, with dates attached to each step.
The technical strategies are secondary to the discipline around them. The routing is the easy part. The process is where API versioning in production either works or doesn’t.
Got a versioning story that cost you time? Find me on LinkedIn.

Leave a Reply