Beer & Servers Don't Mix

The Art of Not Breaking Things: How We Test Backwards Compatibility Without the Drama

There’s a moment in every engineer’s career when you realize you’ve just shipped a breaking change to production. It’s that special blend of horror and enlightenment that the Japanese call “satori” — except instead of achieving Buddhist enlightenment, you’re achieving an inbox full of angry Slack messages at 2 AM.

As the novelist F. Scott Fitzgerald once wrote, “The test of a first-rate intelligence is the ability to hold two opposing ideas in mind at the same time and still retain the ability to function.” In our world, those two opposing ideas are: “We need to evolve our APIs” and “We cannot break existing consumers.” Most teams choose one or the other. Today, we’re going to talk about choosing both.

The Versioning Paradox

Here in Bangkok, there’s a saying among the street food vendors: “Same same but different.” It’s usually said with a knowing smile when you ask if today’s pad thai is the same as yesterday’s. In software versioning, we face the inverse challenge: “Different different but same same” — our code needs to be different (new features) but behave the same (backwards compatible).

You’ve seen the carnage. A team updates their library, publishes v2.0, and suddenly half the company’s services are failing their builds. The post-mortem is always the same: “We didn’t realize that interface was being used that way.” Of course you didn’t. Nobody ever does.

At Agoda, we’ve developed a pattern that catches these issues before they escape into the wild. It’s deceptively simple, surprisingly effective, and — here’s the kicker — it works by making your build system do the heavy lifting.

The Pattern: Schrödinger’s Dependencies

Here’s what we do: we make our projects simultaneously depend on both the old and new versions of our interfaces. Not in production, mind you — that would be madness — but during our build and test cycle.

<ItemGroup> <ProjectReference Include="..\Agoda.Graphql.Client.Abstractions\Agoda.Graphql.Client.Abstractions.csproj" Condition="&#x27;$(Configuration)&#x27;==&#x27;Debug&#x27;" /> <PackageReference Include="Agoda.Graphql.Client.Abstractions" Version="1.0.395" Condition="&#x27;$(Configuration)&#x27;==&#x27;Release&#x27;" /> </ItemGroup>Look at that configuration. In Debug mode, we reference the local project — the new code you’re working on. In Release mode, we reference the last published package from NuGet. It’s like having your cake and eating it too, except the cake is backwards compatibility and eating it is… well, actually breaking production.

How This Actually Works

When you run dotnet build --configuration Debug, you're testing against your new interfaces. Everything compiles? Great, your new code works with your new contracts.

When you run dotnet build --configuration Release, you're testing against the published package — the one your consumers are actually using. If this fails, congratulations, you've just discovered a breaking change before it broke anything.

As the chef Julia Child once said, “The only real stumbling block is fear of failure. In cooking, you’ve got to have a what-the-hell attitude.” In API design, we need the opposite: a healthy paranoia about failure, implemented through systematic testing.

The Moment of Truth

Here’s where it gets interesting. Your CI/CD pipeline runs both configurations. No manual intervention, no “we’ll remember to test backwards compatibility” promises that evaporate faster than water on Bangkok pavement in April. The build either passes both configurations, or it doesn’t ship. Simple as that.

This pattern is particularly crucial for our .Abstractions projects — for us those are interface definitions that form the contracts between our code. Break one of those, and you're not just breaking code; you're breaking trust.

Beyond .NET: The Universal Principle

Now, you might be thinking, “That’s great for you .NET folks with your fancy MSBuild conditions, but what about the rest of us?” Fair point. Let’s talk about how this pattern translates to other ecosystems.

JavaScript/TypeScript with npm

In the Node.js world, you can achieve something similar with npm aliases and build scripts:

{ "devDependencies": { "my-interfaces-old": "npm:[email protected]", "my-interfaces": "file:../my-interfaces" }, "scripts": { "test:compatibility": "npm run test:current && npm run test:legacy", "test:current": "jest", "test:legacy": "MODULE_ALIAS=my-interfaces-old jest" } }Your test suite runs twice: once against local development, once against the published version.

Kotlin with Gradle

Gradle’s build variants and configurations make this pattern quite elegant:

kotlin

// build.gradle.kts dependencies { if (project.hasProperty("compatibilityTest")) { implementation("com.agoda:interfaces:1.0.0") // Last published version } else { implementation(project(":interfaces")) // Local development version } }tasks.register("testCompatibility") { doLast { exec { commandLine("./gradlew", "test", "-PcompatibilityTest") } } } tasks.register("testAll") { dependsOn("test", "testCompatibility") }Run ./gradlew test for development version, ./gradlew testCompatibility for published version, or ./gradlew testAll for both. Both should pass.

The Hidden Benefits

Beyond catching breaking changes, this pattern delivers some unexpected wins:

Documentation by Example: Your code becomes a living example of how to migrate from old to new interfaces. When you need to support both, you write more thoughtful abstractions.Gradual Migration Paths: You’re forced to think about how consumers will transition. Can they upgrade incrementally, or are you forcing a big-bang migration?Interface Design Discipline: When you know every change will be tested against existing contracts, you think twice before casually refactoring that parameter name.Intentional Friction for Breaking Changes: Here’s the beautiful part — if you absolutely must make a breaking change, this pattern forces you to do it in two deliberate steps. First, you push the breaking change to your abstractions and publish the package via master. Then, in a separate PR, you update the consuming code. This two-step dance isn’t a bug; it’s a feature. It’s like a speed bump before a school zone — it forces you to slow down and think, “Do I really need to break this interface, or am I just being lazy?” Most of the time, that pause is enough to realize there’s a backwards-compatible way to achieve the same goal.As the architect Frank Lloyd Wright observed, “Form follows function — that has been misunderstood. Form and function should be one, joined in a spiritual union.” In our case, the form (our API surface) and function (backwards compatibility) become one through systematic testing.

The Gotchas

Let’s be honest about the trade-offs:

Build Time: Yes, you’re essentially building twice. But would you rather find out about breaking changes in CI or in production? Also you can and should run in parrallel.Version Management: You need to religiously update those package versions in your references. Automation helps here.False Positives: Sometimes you genuinely need to make a breaking change. This pattern doesn’t prevent that; it just makes it deliberate rather than accidental.

The Bottom Line

We spend enormous effort on sophisticated testing strategies — unit tests, integration tests, contract tests, chaos engineering. Yet we often miss the most basic test: “Does our new code work with what’s already deployed?”

This pattern isn’t revolutionary. It’s not going to win any architecture awards. But it will save you from that 2 AM wake-up call when half your services stop talking to each other because someone thought renaming that method would make the code “cleaner.”

As the Roman philosopher Seneca wrote, “Every new thing excites the mind, but a mind that seeks truth turns from the new and seeks the old.” In our relentless pursuit of the new — new features, new frameworks, new paradigms — we sometimes forget that our old code is still out there, running in production, expecting interfaces that haven’t changed.

The next time someone on your team says, “It’s just a small interface change,” remember: there’s no such thing. Every interface is a promise, and this pattern helps you keep it.