惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Martin Fowler
Martin Fowler
大猫的无限游戏
大猫的无限游戏
J
Java Code Geeks
罗磊的独立博客
雷峰网
雷峰网
G
Google Developers Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
爱范儿
爱范儿
B
Blog RSS Feed
腾讯CDC
Apple Machine Learning Research
Apple Machine Learning Research
D
Docker
Recent Announcements
Recent Announcements
T
Tailwind CSS Blog
博客园 - 聂微东
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Vercel News
Vercel News
小众软件
小众软件
人人都是产品经理
人人都是产品经理
云风的 BLOG
云风的 BLOG
IT之家
IT之家
Blog — PlanetScale
Blog — PlanetScale
I
InfoQ
S
SegmentFault 最新的问题

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Why API Breaking Changes Still Reach Production Even With...
Deepak Satyam · 2026-06-25 · via DEV Community

Why API Breaking Changes Still Reach Production Even With CI/CD

A few years ago I watched a "tiny" API change take down checkout for about forty minutes. The change was a one-liner. The pull request had two approvals. CI was green across the board. And it still broke production, because the thing that actually mattered was never tested.

If you run microservices at any real scale, you have lived some version of this. Let's talk about why it keeps happening even with a mature pipeline, and what the teams who don't keep getting paged do differently.

The Problem

Here's the change that caused the outage. A payments service had a response that looked like this:

{
  "status": "ok",
  "transaction_id": "txn_8842",
  "amount_cents": 4200
}

Someone renamed amount_cents to amount and switched it to a decimal, because "cents is confusing." Cleaner field, better docs. The producing service's tests were updated to match, everything passed, it shipped.

The problem: three downstream services still read amount_cents. One of them was the order service, which now received undefined, multiplied it by a quantity, and wrote NaN into the database. The failures didn't even surface in the payments service. They surfaced two hops away, in a service the original author had never opened.

This is the core issue. A breaking change is not defined by the service that makes it. It's defined by the consumers who depend on it. And the producer's CI pipeline has no idea those consumers exist.

Why Existing Approaches Fail

The natural reaction is "we need more tests." But look at what each layer actually checks.

Unit tests verify the code does what the author intended. The author intended to rename the field. The unit tests were updated to expect amount. They passed because they were testing the new, broken behavior. Green unit tests told us nothing.

Integration tests verify the service works with its own dependencies — its database, its cache, the APIs it calls. They almost never spin up the services that call it. The payments service had no reason to boot the order service in its pipeline, so the incompatibility was invisible.

End-to-end tests can catch this in theory. In practice they're slow, flaky, and incomplete. Nobody has an E2E test for every consumer's every field access. The order service's amount_cents read wasn't in any E2E path that ran on the payments PR. E2E suites also tend to test happy paths through the UI, not the specific data contracts between internal services.

Schema validation in CI feels like the answer, and it's closer. But most teams validate that their OpenAPI spec is well-formed, not that it's compatible with the previous version. A spec that renames a field is still a perfectly valid spec. Linting passes. The document is correct. It's just incompatible.

The gap is structural, not a matter of test coverage. Every one of these layers checks a service against its own expectations. None of them check a service against what its consumers actually depend on. That's the missing layer.

A Better Approach

You need two things the pipeline above doesn't have: a machine-readable contract, and a check that compares the new contract to what consumers rely on — running before the change merges.

There are two ways to get there, and they're complementary.

1. Diff the contract against its own previous version. If you publish an OpenAPI spec, you can compare the PR's spec to the one currently in production and classify the differences. Removing a field, renaming it, tightening a type, adding a required request parameter — these are breaking. Adding an optional field is not. This catches the obvious regressions cheaply and needs zero coordination with other teams.

2. Diff the contract against what consumers actually use. This is consumer-driven contract testing. Each consumer publishes the subset of the API it depends on. The producer's pipeline checks every change against the union of those expectations. If something still reads amount_cents, removing it fails the build — on the producer's PR, before merge.

Here's how that reshapes the flow:

flowchart TD
    A[Producer opens PR] --> B[Generate OpenAPI spec from code]
    B --> C{Diff vs production spec}
    C -->|Backward compatible| D{Check consumer contracts}
    C -->|Breaking change| F[Fail build + report removed fields]
    D -->|All satisfied| E[Merge allowed]
    D -->|Consumer dependency broken| F
    F --> G[Producer notified pre-merge]
    G --> H[Coordinate version or fix]

The tradeoff worth naming: approach 1 is nearly free but only catches self-inconsistency. Approach 2 catches real-world breakage but requires consumers to publish and maintain their contracts, which is organizational work, not just technical. Most teams I've worked with start with the diff (immediate value, no buy-in needed) and layer in consumer contracts for the high-blast-radius services first.

Example

Start with the spec diff, because it pays off on day one. Given the production spec and the PR's spec, a tool like oasdiff classifies every change. The output for our rename looks roughly like this:

$ oasdiff breaking production.yaml pr.yaml

1 breaking changes:

error, in components/schemas/Transaction
  property 'amount_cents' removed from response of
  GET /transactions/{id} (200)

That's the whole outage, surfaced in one command. The interesting part is the OpenAPI definition driving it — note that removing amount_cents and adding amount are two separate changes, and only one of them is dangerous:

# production.yaml
components:
  schemas:
    Transaction:
      type: object
      required: [status, transaction_id, amount_cents]
      properties:
        status: { type: string }
        transaction_id: { type: string }
        amount_cents: { type: integer }

# pr.yaml  — amount_cents gone, amount added
components:
  schemas:
    Transaction:
      type: object
      required: [status, transaction_id, amount]
      properties:
        status: { type: string }
        transaction_id: { type: string }
        amount: { type: number }

Now wire it into CI so it gates the merge instead of living in someone's terminal:

# .github/workflows/api-compatibility.yml
name: API Compatibility
on: pull_request

jobs:
  contract-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Fetch the spec currently in production
        run: |
          curl -sf https://api.internal/openapi.yaml -o production.yaml

      - name: Generate the spec from this PR
        run: ./gradlew generateOpenApiSpec   # or your generator

      - name: Fail on breaking changes
        run: |
          docker run --rm -v "$PWD:/specs" tufin/oasdiff \
            breaking /specs/production.yaml /specs/build/openapi.yaml \
            --fail-on ERROR

--fail-on ERROR is the line that matters. Without it the diff is a report nobody reads; with it, the rename never merges. Run it on every PR, not nightly — the entire point is to catch the change while the author still has context, not eight hours later.

For the consumer-driven side, the mechanics differ by tool (Pact is the common one), but the principle is identical: the producer's pipeline downloads the consumers' recorded expectations and verifies the new build still satisfies them. Same gate, broader truth.

Lessons Learned

A few things I only learned by getting them wrong, mostly while building internal governance tooling for a service fleet that had outgrown anyone's ability to reason about it by hand.

Generate the spec from code, never the reverse. Hand-maintained OpenAPI files drift from the implementation within weeks, and then your compatibility check is comparing two fictions. If the spec comes out of the running code (annotations, reflection, whatever your stack offers), the diff is comparing reality to reality.

"Breaking" needs a precise, boring definition. Our first version flagged every change and developers learned to ignore it inside a week. Alert fatigue kills these tools faster than bugs do. Sit down and write the actual rules: removing a response field is breaking, adding an optional request field is not, changing a type is breaking, adding an enum value is breaking for responses but not requests. Encode that, and trust drops back.

The org problem is harder than the code problem. The diff was a weekend. Getting teams to treat a red compatibility check as a real blocker — that took quarters. The check only works if "the consumer contract failed" carries the same weight as "the tests failed." Until then it's advisory, and advisory checks get clicked through at 6 PM on a Friday.

Version numbers are a promise, not a mechanism. Bumping to /v2 doesn't stop a v1 consumer from breaking; it just gives you somewhere to put the new shape. Something still has to enforce that v1 keeps its contract. The version is the label on the box, not the lock.

Conclusion

Breaking changes reach production because every standard testing layer validates a service against its own expectations, and a breaking change is by definition about someone else's expectations. CI being green means "this service is internally consistent" — which is exactly what you'd see right before the field you renamed takes down three downstream consumers.

Closing that gap doesn't take a rewrite. It takes one new gate: a contract, generated from code, diffed on every PR, configured to actually fail the build. Start with the cheap spec diff today; add consumer-driven contracts for your highest-blast-radius services as you go.

So here's what I'm curious about: how does your team draw the line on what counts as a "breaking" change — and is that definition written down anywhere, or does it live in the head of whoever reviews the PR?