MVP Factory
ai startup development

.github/workflows/api-compat.yml

KW
Krystian Wiewiór · · 5 min read

The versioning problem nobody solves well

Most teams pick an API versioning strategy in week one and regret it by month six. The initial choice — URL segments, headers, or query params — matters far less than the infrastructure around it: how you route requests, transform payloads, and deprecate old versions without surprise outages.

Most teams treat versioning as a URL design problem when it’s actually an architecture problem. That distinction makes all the difference.

Comparing the three approaches

StrategyExampleDiscoverabilityCache-friendlinessTooling support
URL path/v2/usersHigh, visible in URLExcellent, unique cache keysBest, works everywhere
Custom headerAccept-Version: 2Low, hidden in headersPoor, requires Vary headerModerate, needs docs
Query param/users?version=2MediumPoor, cache key pollutionGood, easy to test

URL-path versioning wins on simplicity and tooling, which is why roughly 70% of public APIs (Stripe, GitHub, Twilio) use it. But it creates a real problem: every version multiplies your route definitions, and your controllers accumulate conditional logic that becomes untestable.

A gateway pattern worth using

Instead of embedding version logic in your handlers, introduce a thin gateway layer that does a few things. It extracts the version from URL, header, or query param (so you’re not locked to one strategy). It routes to a versioned handler chain with request/response transformers. And it injects sunset headers for deprecated versions.

A minimal implementation in Express:

// gateway/versionRouter.ts
const versionExtractors = {
  path: (req) => req.params.version,
  header: (req) => req.headers['accept-version'],
  query: (req) => req.query.version,
};

function versionGateway(strategy: string, handlers: Record<string, Handler>) {
  return (req, res, next) => {
    const version = versionExtractors[strategy](req) || 'v3';
    const handler = handlers[version];
    if (!handler) return res.status(410).json({ error: 'Version sunset' });

    if (handler.sunset) {
      res.set('Sunset', handler.sunset);
      res.set('Deprecation', 'true');
      res.set('Link', `</v3/users>; rel="successor-version"`);
    }
    return handler.fn(req, res, next);
  };
}

This keeps your actual business logic version-free. Each handler chain includes an adapter layer that transforms between the client’s expected schema and your internal domain model.

The adapter layer: supporting 3+ versions cleanly

Schema evolution is where versioning gets painful. Consider a real migration: renaming userName to displayName while restructuring address from a flat string to a nested object.

// adapters/userTransformers.ts
const transformers = {
  v1: (internal) => ({
    userName: internal.displayName,       // field rename
    address: internal.address.formatted,  // flatten nested object
  }),
  v2: (internal) => ({
    displayName: internal.displayName,
    address: internal.address.formatted,  // still flat in v2
  }),
  v3: (internal) => ({
    displayName: internal.displayName,
    address: {                            // full nested structure
      street: internal.address.street,
      city: internal.address.city,
      country: internal.address.country,
    },
  }),
};

Your internal domain model evolves freely. The transformers are the only place where version-specific logic lives — small, testable, and isolated. When you sunset v1, you delete one transformer and one route entry. That’s it.

Catching breaking changes in CI with OpenAPI diffing

The most underused technique in API versioning is automated spec diffing. Tools like oasdiff compare OpenAPI specs between commits and flag breaking changes:

# .github/workflows/api-compat.yml
- name: Check API compatibility
  run: |
    oasdiff breaking openapi-prev.yaml openapi-current.yaml \
      --fail-on ERR

What this catches:

Change typeExampleSeverity
Field removeduserName deleted without deprecationError
Type changedage: string -> age: integerError
Required field addedNew required email on request bodyError
Enum value removedStatus PENDING droppedWarning

This is your safety net. Teams that add spec diffing to CI catch breaking changes before merge, not after a client integration breaks in production. I’ve watched this single check prevent more versioning incidents than any amount of code review.

Sunset headers: deprecation clients actually see

RFC 8594 defines the Sunset header, a machine-readable deprecation date. Pair it with the Deprecation header and a Link to the successor version. Client SDKs and monitoring tools can parse these and alert teams before a version goes dark.

This turns deprecation from a “check the changelog” problem into an automated one. And honestly, if you’re not doing this, you’re relying on people reading emails. Good luck with that.

What I’d do on a new project

Start with URL-path versioning. It’s boring, it works, and every HTTP tool on the planet understands it. But don’t stop there.

Put a gateway layer between your routes and your business logic from day one. When you inevitably need to support a second version, you’ll add a transformer instead of forking your controller. The effort to set this up is maybe an afternoon; the effort to retrofit it later is a week of careful refactoring.

Add oasdiff to your CI pipeline. It takes minutes to configure and it will save you from the 2 AM “why did our partner’s integration break” call.

Use Sunset headers from the start. They cost nothing and they let your consumers’ tooling do the work of tracking deprecation timelines.

The versioning strategy you pick matters less than the infrastructure you build around it. Invest in the gateway, the adapters, and the CI checks. That’s where the real reliability comes from.


Tags: api rest architecture backend nodejs


Share: Twitter LinkedIn