Spring Boot: Catch Breaking REST API Changes in the PR, Not in Production

Spring Boot: Catch Breaking REST API Changes in the PR, Not in Production

2026, Aug 28    

How a committed OpenAPI spec, generated by springdoc on every build, turns “we broke a client” into a red check on the PR.

The problem

A Spring Boot service exposes a REST API. A React application consumes it. Somebody renames customerId to customer_id in a response DTO, the backend tests pass, the PR is merged, and three days later the front-end shows blank names because order.customerId is now undefined. Nobody lied, nobody was careless; the contract between the two systems simply lived nowhere. It existed only as an emergent property of two codebases that happened to agree until they didn’t.

The fix is not “be more careful”. The fix is to make the contract a file, put that file in git, and let the tools that already watch git - diffs, CI, the type checker - watch the contract too.

The scope here is deliberately narrow: the response contract that is already published - the JSON your service sends to a consumer you do not control, whether that is another team, a partner, or a public client. Requests, internal renames and your own front-end are simpler cases; the hard one is a field that somebody outside your repository is already reading.

This article walks through one concrete setup:

  1. springdoc generates the OpenAPI document from the controllers.
  2. springdoc-openapi-maven-plugin writes it to a file during mvn verify.
  3. The file is committed, so every API change shows up as a diff in the pull request.
  4. CI compares the PR’s spec against main and fails on breaking changes.
  5. The spec becomes the artefact you publish to consumers, with a changelog and deprecation dates, because you cannot run their compiler.

Everything here is code-first: the controllers stay the source of truth, and the spec is a build artefact that we happen to keep. That is the pragmatic middle between “no contract” and full contract-first design, and it can be adopted on an existing service in an afternoon.


Step 1 - Generate the spec at build time

Dependency

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.8.9</version>
</dependency>

With this on the classpath the running application serves /v3/api-docs (JSON), /v3/api-docs.yaml and Swagger UI at /swagger-ui.html. The document is derived from @RestController mappings, method signatures, Bean Validation annotations and Jackson’s view of your DTOs; @Operation, @Schema and friends add descriptions and examples.

Make the output deterministic

A spec that reorders itself on every build produces noisy diffs and defeats the purpose. Two properties fix that:

springdoc:
  writer-with-order-by-keys: true          # sort paths, schemas and properties
  writer-with-default-pretty-printer: true # one key per line, diff-friendly
  paths-to-match: /api/**                  # keep actuator and internals out

Prefer YAML over JSON for the committed file. A one-line change in a pretty-printed YAML document is a one-line diff; in JSON, the trailing commas and brace placement make small changes look larger than they are.

Dump it during mvn verify

The springdoc-openapi-maven-plugin fetches the document from a running instance, so the Spring Boot plugin has to start the application before the integration-test phase and stop it afterwards:

<build>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <executions>
        <execution>
          <id>start-for-apidocs</id>
          <goals><goal>start</goal></goals>
          <configuration>
            <arguments>
              <argument>--spring.profiles.active=apidocs</argument>
              <argument>--server.port=8099</argument>
            </arguments>
          </configuration>
        </execution>
        <execution>
          <id>stop-for-apidocs</id>
          <goals><goal>stop</goal></goals>
        </execution>
      </executions>
    </plugin>

    <plugin>
      <groupId>org.springdoc</groupId>
      <artifactId>springdoc-openapi-maven-plugin</artifactId>
      <version>1.5</version>
      <executions>
        <execution>
          <id>dump-openapi</id>
          <phase>integration-test</phase>
          <goals><goal>generate</goal></goals>
        </execution>
      </executions>
      <configuration>
        <apiDocsUrl>http://localhost:8099/v3/api-docs.yaml</apiDocsUrl>
        <outputDir>${project.basedir}/api</outputDir>
        <outputFileName>openapi.yaml</outputFileName>
      </configuration>
    </plugin>
  </plugins>
</build>

The apidocs profile exists so the app can boot without real infrastructure - an in-memory database, security relaxed for /v3/api-docs, outbound clients pointed at nowhere. The spec depends only on the controllers, so the app does not need to work, it only needs to start.

./mvnw verify now leaves api/openapi.yaml in the working tree. That file is the contract.

Alternative: write it from a test

If starting the full application in the Maven lifecycle is awkward (it often is when Testcontainers or external services are involved), a test does the same job with less ceremony:

@SpringBootTest
@AutoConfigureMockMvc
class OpenApiSnapshotTest {

    @Autowired MockMvc mvc;

    @Test
    void writeSpec() throws Exception {
        String yaml = mvc.perform(get("/v3/api-docs.yaml"))
                .andExpect(status().isOk())
                .andReturn().getResponse().getContentAsString();
        Files.writeString(Path.of("api/openapi.yaml"), yaml);
    }
}

Same file, same downstream pipeline; choose whichever fits the build.


Step 2 - Commit it, and keep it honest

Commit api/openapi.yaml. From now on, every pull request that changes an endpoint, a DTO, a validation annotation or a status code also changes that file, and the change is visible in the PR’s file list next to the Java diff.

Two things keep the file honest:

A CI check that it is up to date. Regenerate in CI and fail if the result differs from what was committed:

- run: ./mvnw -B verify
- name: Committed OpenAPI spec is current
  run: git diff --exit-code -- api/openapi.yaml

A developer who changes a controller and forgets to run verify gets a failing check with the exact diff they need to commit. The spec can never silently drift from the code.

A CODEOWNERS entry. Adding /api/openapi.yaml @team-api-owners (or the front-end team) means a change to the contract automatically requests a review from the people who depend on it. The diff does the explaining.


Step 3 - Read the diff

This is the moment the whole setup pays for itself. The customerId rename from the introduction now looks like this in the pull request:

     OrderResponse:
       type: object
       properties:
-        customerId:
+        customer_id:
           type: string
         id:
           type: integer
           format: int64
       required:
-        - customerId
+        - customer_id
         - id

A reviewer who has never opened the Java file can see that a response property was removed and another added. Compare with the diff for a genuinely additive change:

     OrderResponse:
       type: object
       properties:
         customerId:
           type: string
+        deliveryNote:
+          type: string
         id:

New optional property in a response - safe for every existing client. The YAML diff reads the same way a schema-literate person thinks about compatibility, which is the point: the review conversation moves from “what does this Java change do?” to “is this contract change acceptable?”.


Worked example - one renamed response field

Take the DTO behind GET /api/orders/{id}:

record OrderResponse(long id, String customerId, BigDecimal total) {}

Jackson uses the component names as JSON keys, so the published contract is { "id": 42, "customerId": "c-123", "total": 19.90 }, springdoc writes customerId into openapi.yaml, and an external consumer somewhere has code that reads customerId. There are three ways this field can be “renamed”, and only one of them touches the contract.

The accidental break: renaming the Java component.

record OrderResponse(long id, String customerCode, BigDecimal total) {}

Jackson follows the Java name, so the wire key silently becomes customerCode. Every backend test passes - they deserialise with the same DTO. The committed spec is what catches it:

-        customerId:
+        customerCode:
           type: string

and the check fails with response-required-property-removed. Without the pipeline, the consumer reads undefined and nothing anywhere raises an error.

The harmless rename: change the Java name, keep the key.

record OrderResponse(long id,
                     @JsonProperty("customerId") String customerCode,
                     BigDecimal total) {}

@JsonProperty decouples the Java name from the wire name. JSON unchanged, spec unchanged, the PR shows only a Java diff. Once a contract is published, this is the correct form for the vast majority of renames - the key is not yours to churn any more.

The deliberate rename: expand, deprecate, contract.

When the key itself must change (say to customer_id), the published contract cannot be renamed in place. It can only be extended:

record OrderResponse(long id,
                     @JsonProperty("customer_id") String customerId,
                     BigDecimal total) {

    @Deprecated
    @Schema(deprecated = true, description = "Use customer_id; removed after 2026-12-31")
    @JsonProperty("customerId")
    public String legacyCustomerId() { return customerId; }
}

The response now carries both keys. The spec diff is additive plus deprecated: true, so the check stays green; consumers see the deprecation in the published document and migrate on their own schedule. In a later release the getter is deleted - that diff is breaking, the check fails, and it is overridden deliberately with the deprecation notice as the justification.

The rule that falls out: a published response field is never renamed, only added next to and later removed. Everything else in this article exists to make sure that rule is enforced by tools rather than by memory.


Step 4 - Let a tool decide what is breaking

Human review scales badly and reviewers get tired. A breaking-change detector reads two versions of the spec and applies the rules mechanically. oasdiff is the most complete option and has a ready-made GitHub Action:

name: api-contract
on: pull_request

jobs:
  breaking-changes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'

      - run: ./mvnw -B verify

      - name: Committed spec is current
        run: git diff --exit-code -- api/openapi.yaml

      - name: Spec from the target branch
        run: git show origin/${{ github.base_ref }}:api/openapi.yaml > /tmp/base.yaml

      - name: Breaking changes
        uses: oasdiff/oasdiff-action/breaking@v0
        with:
          base: /tmp/base.yaml
          revision: api/openapi.yaml
          fail-on: ERR

For the rename above, the job fails with something like:

GET /api/orders/{id}
  removed the required property 'customerId' from the response with the '200' status
  (response-required-property-removed)

Locally, the same check is oasdiff breaking /tmp/base.yaml api/openapi.yaml, and oasdiff changelog prints every change with a severity - useful for release notes.

If you prefer to stay inside the JVM, openapi-diff from OpenAPITools ships as a Maven plugin (org.openapitools.openapidiff:openapi-diff-maven, goal diff, failOnIncompatible=true) and can run in the same verify that produced the spec, comparing against a copy of the previous version.

Whichever tool you pick, wire it as a required status check on the target branch. A breaking change then requires a conscious decision - override the check with a justification, or version the API - instead of a lucky reviewer.


What counts as breaking

The detector applies rules like these; knowing them makes the diffs faster to read.

Change Request side Response side
Add an optional property / query parameter safe safe
Add a required property / parameter breaking safe (clients get more than they need)
Remove a property safe if optional, breaking if clients relied on it being accepted breaking
Rename a property breaking (remove + add) breaking
Change a type or format (string -> integer, int32 -> int64) breaking breaking
Make an optional property required breaking safe
Make a required property optional safe breaking (clients may now receive null)
Add an enum value safe breaking for strict clients (exhaustive switch, generated union types)
Remove an enum value breaking safe
Remove an endpoint or an HTTP method breaking -
Add an endpoint safe -
Change a success status code (200 -> 201) - breaking for clients that check the exact code
Add a new error status code - usually safe, sometimes surprising
Change nullable from false to true - breaking

Two of these surprise people. Adding an enum value to a response is breaking for a TypeScript client whose generated type is "PENDING" | "PAID" | "SHIPPED" and whose switch is exhaustive; the compiler was happy, production is not. And loosening a request (making a property optional) is safe while loosening a response (making a property optional) is breaking, because the direction of the data decides who has to cope.


Step 5 - The consumer you cannot compile

When the consumer is your own React app, the same openapi.yaml feeds openapi-typescript or orval, and a removed field becomes a compile error in the front-end’s CI - the type checker acts as a second detector. With an external consumer you do not have that. You cannot see their code, run their build or know which fields they actually read. Three things stand in for it.

Treat the spec as the deliverable. Publish openapi.yaml where consumers can fetch it - a versioned URL, a developer portal, a release attachment - and keep every version. A consumer who can diff v1.4 against v1.5 themselves is a consumer who does not have to trust your release notes.

Generate the changelog from the diff. oasdiff changelog base.yaml revision.yaml lists every change with a severity; run it at release time and paste the result into the release notes. Deprecations carry a date (description: "removed after 2026-12-31" in the spec, and a Sunset header on the response if you want to be formal). The consumer’s obligation becomes “read the changelog”, which is fair; “guess what we changed” is not.

Consumer-driven contracts, when the consumer will cooperate. With Pact, the consumer records the fields it really uses (customerId, total, nothing else) as a contract; your CI replays those expectations against the provider on every build. A rename of customerId fails your pipeline with the consumer’s name attached, while a rename of a field nobody reads passes - which the spec diff alone cannot tell you. It needs buy-in from the other side, so it works for partners and internal teams, not for anonymous public clients.

What you can and cannot detect

Consumer Detection you own
Your own front-end in the same repo spec diff + breaking check + tsc on the generated client
Your own front-end in another repo spec diff + breaking check; the client build fails when it bumps the spec version
Partner / internal team spec diff + breaking check + Pact contract in your CI
Anonymous external clients spec diff + breaking check + published changelog and deprecation dates

The first two rows of that list are the tool’s job. The last two are why the expand-deprecate-contract discipline from the worked example is not optional: for a consumer you cannot see, the only safe response change is an additive one.


When the change really is breaking

Sometimes the rename is right and the old shape has to go. The pipeline does not forbid that; it makes it deliberate. The usual playbook:

  1. Expand first. Add customer_id next to customerId, keep both populated. The spec diff is additive and the check stays green.
  2. Mark the old one deprecated. @Deprecated on the getter, or @Schema(deprecated = true), becomes deprecated: true in the spec; generated clients surface it as a strike-through in the editor. Announce a removal date; for public APIs add a Sunset header.
  3. Migrate consumers. Their compile errors, if any, are now warnings they can schedule.
  4. Contract. Remove customerId in a later release. This diff is breaking and the check will fail - override it with the deprecation ticket as the justification. That override is a recorded decision, which is exactly what you want a breaking change to be.

For changes too large for expand/contract, version the path (/api/v2/orders) and use springdoc.group-configs to publish v1 and v2 as separate documents, each diffed on its own.


Pitfalls

  • Nondeterministic output. Forgetting writer-with-order-by-keys produces a spec that changes when unrelated code moves, and reviewers learn to ignore the file. Sort it from day one.
  • Entities in controllers. Return a JPA entity and springdoc will faithfully describe its whole graph, lazy collections included. The spec becomes a description of the database, every schema change becomes an “API change”, and the breaking-change check cries wolf. Return DTOs (records) from controllers; this pipeline is one more reason for the rule.
  • Security in the apidocs profile. If Spring Security guards /v3/api-docs, the plugin gets a 401 and the build fails. Permit that path in the profile used for generation only.
  • Polymorphism and generics. Page<T>, sealed hierarchies and @JsonTypeInfo need @Schema(oneOf = ...) hints or a springdoc ModelConverter to come out right; check the generated schema.d.ts once before trusting it.
  • Diffing against the wrong base. Compare the PR’s spec with the target branch’s spec, not with the previous commit on the feature branch, or a breaking change split across two commits slips through.
  • Warnings that are not errors. Tools classify some changes (new enum value in a response, new error status) as warnings. Decide as a team whether fail-on: WARN is worth the noise; most teams start with ERR and tighten later.

Summary

The contract between a Spring Boot API and its React client is real whether or not it is written down. Writing it down - one openapi.yaml generated by springdoc on every mvn verify and committed to the repository - costs a Maven plugin and two properties, and it buys three things at once:

  • every API change becomes a readable diff in the pull request,
  • a CI check fails on changes that would break existing clients, and
  • the published document and its changelog give consumers you cannot see a contract they can diff themselves.

Breaking changes still happen. They just stop being surprises.