INDUSTRY NEWS

API Versioning Is a Last Resort: Notes From an Iron Team

Milan Jovanović recently published a strong argument against premature API versioning. The core point: most teams reach for v2 too early because they lack a contract evolution strategy. Versioning is a compatibility tool, not a design strategy.

The argument resonates with our engineering team at Iron Software. We ship .NET libraries, which means the public surface of our products is an API. Every method signature, every property, every default behavior is a contract sitting inside thousands of customer codebases. A major version bump is not a release. It is a migration project for everyone downstream.

What follows is a developer's take on Milan's piece from a library author's perspective, and how the same compatibility rules apply whether you are shipping a REST API or a NuGet package.

TL;DR

  • Versioning is not a design strategy. It is the escape hatch when coexistence fails.
  • Breaking changes hide in behavior, not just in URLs or schemas.
  • The four compatibility rules: do not remove, do not change processing, do not tighten validation, keep additions optional.
  • A new operation is almost always cheaper than a new version.
  • Real deprecation requires runtime signals and telemetry, not just documentation updates.

The HTTP rules apply to library APIs too

Milan frames the discussion around a REST API for /orders, but the same rules apply when your API is a public C# class shipped in a NuGet package. The mapping is direct:

REST API changeNuGet library equivalent
Renaming a JSON fieldRenaming a public property
Removing an endpointRemoving a public method
Tightening request validationAdding a non-nullable parameter
Changing operation behaviorChanging what a method does under the hood
Adding a required fieldAdding a required constructor parameter

If you have ever pulled a major version of a popular .NET library and spent half a day fixing renamed APIs, you have been on the receiving end of a v2 decision that could likely have been handled additively.

What actually breaks consumers

Milan's list is precise:

  • Removing or renaming fields
  • Changing the meaning of existing data
  • Tightening request validation
  • Changing pagination or error formats
  • Assuming enum-like values are closed forever

The second item is the one that most often catches teams off guard: changing the meaning of existing data without changing its shape. The JSON looks the same. The C# signature looks the same. Everything compiles. Nothing throws at runtime. But the field now means something different, and every consumer who depended on the old semantics is silently wrong.

Milan's example:

// Before
{ "total": 100 }

// After
{ "total": { "amount": 100, "currency": "USD" } }

Same field name. Same endpoint. Every client that parsed total as a number is now broken.

The library equivalent is changing what a method returns or how it interprets its inputs. A Save() method that previously overwrote and now appends. A Trim parameter whose default flips from true to false. A method that used to throw on invalid input and now returns a default value silently.

The four compatibility rules

Milan summarizes the rules as: do not take anything away, do not change processing rules, do not make optional things required, and anything you add must be optional. The four principles are worth keeping in front of any team responsible for a public API:

  1. Keep existing fields and behavior in place.
  2. Do not turn optional request data into required data.
  3. Do not change what an existing operation does.
  4. Make anything new additive and optional by default.

These map directly onto library design. "Do not take anything away" means do not delete public members. "Do not change processing rules" means existing methods should behave the way they did when they shipped. "Do not make optional required" means do not add required parameters to an existing method; provide an overload instead. "Additive and optional" means new functionality belongs in new methods or optional parameters with sensible defaults.

How this plays out in practice

The clearest way to illustrate these rules is with a real API decision, so here is one of ours.

A few releases ago, IronPDF needed to support a richer set of rendering options for HTML-to-PDF conversion: custom paper sizes, custom margins, CSS media emulation, header and footer templates, and more. The straightforward approach would have been to mutate the existing rendering method to accept the new options. That decision would have broken every customer using the simple form of the API.

For context, the library installs through the standard .NET package channels:

# .NET CLI
dotnet add package IronPdf

# Package Manager Console
Install-Package IronPdf
# .NET CLI
dotnet add package IronPdf

# Package Manager Console
Install-Package IronPdf
SHELL

The IronPdf NuGet package has accumulated more than 18 million downloads, which is part of why API stability matters: every breaking change ripples across that many integrations.

The approach we shipped instead:

// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");

// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
// The original, three-year-old API. Still works. Still unchanged.
var renderer = new ChromePdfRenderer();
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");

// New rendering options live on an options object, not in the method signature.
var renderer = new ChromePdfRenderer();
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4;
renderer.RenderingOptions.MarginTop = 20;
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print;
renderer.RenderingOptions.HtmlHeader = new HtmlHeaderFooter { HtmlFragment = "..." };
PdfDocument pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>");
' The original, three-year-old API. Still works. Still unchanged.
Dim renderer As New ChromePdfRenderer()
Dim pdf As PdfDocument = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")

' New rendering options live on an options object, not in the method signature.
renderer = New ChromePdfRenderer()
renderer.RenderingOptions.PaperSize = PdfPaperSize.A4
renderer.RenderingOptions.MarginTop = 20
renderer.RenderingOptions.CssMediaType = PdfCssMediaType.Print
renderer.RenderingOptions.HtmlHeader = New HtmlHeaderFooter With {.HtmlFragment = "..."}
pdf = renderer.RenderHtmlAsPdf("<h1>Hello</h1>")
$vbLabelText   $csharpLabel

Three observations about the decision:

  1. The original RenderHtmlAsPdf(string html) signature is unchanged. Customers who upgraded did not have to modify a line of code.
  2. New capabilities live on an options object that consumers opt into. The method has no new required parameters.
  3. The defaults on RenderingOptions produce output equivalent to the previous API. Behavior is unchanged for anyone who does not configure anything.

That is rules 1, 2, and 4 from Milan's list applied at once. The product evolved. The contract did not.

The temptation to ship RenderHtmlAsPdfV2(string html, RenderingOptions options) was real. It would have looked tidier on the API reference page. It would have cost every customer a migration. We chose otherwise.

Tolerant readers

The other half of Milan's add-don't-replace argument is that consumers carry responsibility as well. A well-behaved client should ignore fields it does not understand.

In .NET, System.Text.Json ignores unknown properties by default, which is the correct default. The risk usually appears in two places:

  • Generated SDKs with strict schemas that reject unexpected fields
  • Contract tests that assert exact JSON equality

Both turn a stated "we ignore unknown fields" guarantee into a tripwire. If your CI breaks the moment the server adds a new optional property, you do not have backward compatibility. You have a regression detector dressed up as a compatibility policy.

Behavior is part of the contract

Milan's section on DELETE /orders/{id} quietly shifting from soft delete to hard delete is the clearest written treatment of this issue we have seen.

The URL is the same. The request body is the same. The response shape is the same. What the operation does on the server is different.

This is the most dangerous category of breaking change because nothing in a schema diff catches it. The OpenAPI specification is identical. The generated client compiles. The integration tests pass. And every consumer who built tooling around "deleted orders are recoverable" silently destroys data in production.

The library equivalent is changing what a method does without changing its signature. Examples we have explicitly avoided:

  • A Save() method that previously flushed synchronously and quietly becomes async-fire-and-forget
  • An OCR method that returned raw results and begins post-processing them
  • A barcode reader that threw on unreadable input and begins returning an empty string

Each of these is a contract break dressed up as an improvement. The correct response is the same as Milan's: add a new method or option, leave the old behavior unchanged, and deprecate the old path only when telemetry indicates it is safe to do so.

Validation tightening

This category eventually affects every team. There are two variants of the same mistake:

  • Taking an existing optional field and making it required
  • Adding a new field and marking it required from day one

Both break older clients. The endpoint path does not move, but requests that previously succeeded now fail at runtime.

The library version of this mistake is adding a required constructor parameter or making an existing optional parameter mandatory. Every existing caller breaks at compile time, which is preferable to runtime failure, but it still imposes a migration cost on every consumer.

Safer paths:

  • Accept missing values during a transition window and infer defaults where possible.
  • Add a new overload or builder that requires the richer input shape.
  • Introduce a new operation or constructor for the stricter workflow.

The underlying rule is consistent: anything added to the contract must be optional, and anything previously optional must remain optional. If genuinely stricter requirements are necessary, they belong in a new operation, not in a tightening of the existing one.

A new operation is almost always cheaper than a new version

This is the principle most worth internalizing.

When a use case has genuinely evolved beyond what an existing endpoint cleanly supports, the common reflex is to overload the endpoint with flags:

POST /orders?validateOnly=true&includeTaxEstimate=true&reserveInventory=true

Or, more disruptively, to declare the change a versioning problem and start work on /v2/orders. Both are usually wrong. The cleaner approach is a new operation alongside the existing one:

POST /orders
POST /orders/quote
POST /checkout-sessions

Each operation has a clean contract, distinct permissions, independent validation, and its own evolution path. The original endpoint stays simple. The rest of the API is not dragged into a major version bump.

In a library context, the equivalent is adding a new method rather than overloading an existing one with optional parameters until it becomes unreadable. ExtractText() remains the simple text extractor. ExtractTextWithLayout()becomes the richer variant. ExtractStructuredDocument() becomes the richest. Three methods with clear contracts is preferable to one method with eight optional parameters.

Deprecate deliberately

This is the half of API change management most teams skip, and it is the half that determines whether the strategy works.

Real deprecation is not a note in a changelog. It involves four steps:

  1. Mark the field or endpoint as deprecated in the OpenAPI description (or with the [Obsolete] attribute in the .NET world).
  2. Signal the deprecation at runtime so live traffic surfaces it.
  3. Link to an actual migration guide.
  4. Measure usage with telemetry to determine when removal is safe.

For HTTP APIs, runtime signaling is straightforward:

Deprecation: true
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://docs.example.com/migrations/orders-total>; rel="deprecation"

For .NET libraries, the equivalent is an [Obsolete("Use NewMethod instead. This will be removed in v2026.x", DiagnosticId = "IRON001")] attribute paired with a UrlFormat pointing to a migration page. The compiler warning surfaces in every consumer's build output, the diagnostic identifier allows deliberate suppression, and the link gives consumers a documented migration path.

The telemetry step is non-negotiable. Without knowing which customers still depend on the deprecated method, removal becomes guesswork. The result is either premature removal that breaks active integrations, or indefinite carrying cost that defeats the purpose of deprecation.

When versioning is the right call

Milan is not anti-versioning, and neither are we. Versioning is appropriate when:

  • The old and new semantics genuinely cannot coexist
  • The resource model has changed fundamentally
  • Compatibility rules would force a contract that nobody can reason about

The point is not to avoid versioning entirely. The point is to reach for it because coexistence has failed, not because it was the first idea on the table.

When versioning is necessary, it should be paired with a real deprecation process. The difficult work is not shipping v2. The difficult work is getting consumers off v1.

The decision rule

Milan's framing is the right one to apply:

  1. Can I add instead of replace?
  2. Can old and new contracts coexist during a migration window?
  3. Can I introduce a new operation instead of mutating an old one?
  4. Can I deprecate the old shape with documentation, headers, and telemetry?

If the answer is yes to all four, a new version is likely unnecessary. If the answer is no, and the two worlds genuinely cannot coexist, version deliberately.

Design contracts to evolve. Treat consumers as long-lived integrations rather than today's code. Reserve versioning for the cases where compatibility has genuinely been exhausted.

For the full piece, including longer worked examples, read Milan's original post.


When selecting a .NET library to depend on, the question worth asking is the one Milan's post is built around: will this library still look like the API I integrated against in three years?

That is the question we work to answer with every release. The simple calls from 2020 still work. New capabilities sit alongside them, optional and additive. No forced major-version migrations.

If that approach to library design matches what you need, start a free 30-day trial and review the API reference for yourself. The five-minute quickstart walks through installation, license activation, and a first rendered PDF. The package itself is one command away from any .NET project:

For environments where NuGet is not the preferred path, the direct download provides the DLL and the Windows installer.