Spring Boot: Catch Breaking REST API Changes in the PR, Not in Production
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:
- springdoc generates the OpenAPI document from the controllers.
springdoc-openapi-maven-pluginwrites it to a file duringmvn verify.- The file is committed, so every API change shows up as a diff in the pull request.
- CI compares the PR’s spec against
mainand fails on breaking changes. - 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:
- Expand first. Add
customer_idnext tocustomerId, keep both populated. The spec diff is additive and the check stays green. - Mark the old one deprecated.
@Deprecatedon the getter, or@Schema(deprecated = true), becomesdeprecated: truein the spec; generated clients surface it as a strike-through in the editor. Announce a removal date; for public APIs add aSunsetheader. - Migrate consumers. Their compile errors, if any, are now warnings they can schedule.
- Contract. Remove
customerIdin 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-keysproduces 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
apidocsprofile. 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@JsonTypeInfoneed@Schema(oneOf = ...)hints or a springdocModelConverterto come out right; check the generatedschema.d.tsonce 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: WARNis worth the noise; most teams start withERRand 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.