Java

Design-First APIs with Spring Boot and OpenAPI 3.1 to Eliminate Contract Drift

Learn how design-first APIs with Spring Boot and OpenAPI 3.1 prevent contract drift using code generation, validation, and CI checks.

Design-First APIs with Spring Boot and OpenAPI 3.1 to Eliminate Contract Drift

I’ve spent too many nights debugging integration failures that traced back to a single culprit: the API documentation said one thing, but the code did another. That silent drift between spec and implementation cost teams weeks of rework, and it felt like a slow leak you couldn’t patch until the whole pipeline flooded. This is why I started using a design‑first approach with Spring Boot and OpenAPI 3.1. Instead of letting the code dictate the contract, you write the contract first—then everything else flows from it. No more guessing, no more chasing mismatched endpoints, no more “oh, the schema was updated last month”. The idea is simple: define your API in a standard YAML file, generate server stubs and clients from that file, and enforce the same schema at runtime. When the spec is the single source of truth, the rest becomes orderly.

You might be wondering: “But I already use Springdoc or Swagger annotations—why change?” Good question. Annotations are code‑first. You sprinkle @Schema and @Operation on your controllers, and the UI serves a generated spec. But that spec is derived from the code, so any hidden bugs in your implementation become part of the contract. More importantly, if you change the spec in YAML first, the code generator will scream at you when you try to compile and the generated interface doesn’t match. It’s a force field instead of a safety net.

Let me show you what I mean. First, you define your OpenAPI spec. Here’s a minimal order‑service contract written in OpenAPI 3.1 YAML. Notice how I use $ref for reusable schemas and tags for grouping endpoints:

openapi: 3.1.0
info:
  title: Order Service API
  version: 1.0.0
paths:
  /orders:
    get:
      summary: Get all orders
      operationId: listOrders
      tags:
        - Orders
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [PENDING, SHIPPED, DELIVERED]
      responses:
        '200':
          description: A list of orders
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Order'
    post:
      summary: Create an order
      operationId: createOrder
      tags:
        - Orders
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewOrder'
      responses:
        '201':
          description: Order created
          headers:
            Location:
              schema:
                type: string
              description: URI of the created order
components:
  schemas:
    Order:
      type: object
      properties:
        id:
          type: string
          format: uuid
        customerName:
          type: string
        items:
          type: array
          items:
            $ref: '#/components/schemas/OrderItem'
        status:
          type: string
          enum: [PENDING, SHIPPED, DELIVERED]
        createdAt:
          type: string
          format: date-time
    OrderItem:
      type: object
      properties:
        productId:
          type: string
        quantity:
          type: integer
          minimum: 1
        price:
          type: number
          format: double
    NewOrder:
      type: object
      required: [customerName, items]
      properties:
        customerName:
          type: string
          minLength: 1
        items:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/OrderItem'

Now, the magic happens when you feed this spec to the OpenAPI Generator Maven Plugin. In your pom.xml, you configure it to produce Spring Boot interfaces and DTOs. The plugin reads the YAML and spits out Java code that you cannot cheat—if the spec says a field is required, the generated class will have @NotNull on it. If you try to delete a path from your code without updating the spec, the build fails.

<plugin>
  <groupId>org.openapitools</groupId>
  <artifactId>openapi-generator-maven-plugin</artifactId>
  <version>7.4.0</version>
  <executions>
    <execution>
      <id>generate-server-stubs</id>
      <goals><goal>generate</goal></goals>
      <configuration>
        <inputSpec>${project.basedir}/src/main/resources/openapi/order-service-api.yaml</inputSpec>
        <generatorName>spring</generatorName>
        <apiPackage>com.baeldung.openapi.api</apiPackage>
        <modelPackage>com.baeldung.openapi.model</modelPackage>
        <configOptions>
          <delegatePattern>true</delegatePattern>
          <useSpringBoot3>true</useSpringBoot3>
          <useTags>true</useTags>
          <performBeanValidation>true</performBeanValidation>
        </configOptions>
      </configuration>
    </execution>
  </executions>
</plugin>

After a mvn clean compile, you get an OrdersApi interface with method signatures that match the spec exactly. All you have to do is write a delegate implementation. No more debating whether the controller accepts a query parameter or a header—the generated code forces your hand.

But having the right stubs isn’t enough. The real risk is a rogue developer (or yourself) returning a response that violates the schema—maybe a missing field, an extra property, or a wrong type. You need runtime validation. I add the Atlassian Swagger Request Validator as a filter. It intercepts every request and response, and if the payload doesn’t match the spec, it returns a 400 with a clear error. The configuration is minimal:

@Configuration
public class OpenApiValidationConfig {

    @Bean
    public OpenApiValidationFilter validationFilter() {
        return new OpenApiValidationFilter(
                "order-service-api.yaml",
                true,   // validate requests
                true    // validate responses
        );
    }

    @Bean
    public OpenApiValidationInterceptor validationInterceptor() {
        return new OpenApiValidationInterceptor(
                "order-service-api.yaml"
        );
    }
}

Now, if a client sends a POST /orders with a missing customerName, the validator rejects it before your controller logic even runs. And if your controller accidentally returns a string where a number is expected, the response filter catches it. This saves you from those silent failures that poison downstream systems.

Have you ever pushed a change to an API and later discovered that a consuming microservice stopped working because you renamed a field? The design‑first approach helps you catch those breakages earlier. I use Spring Cloud Contract to write consumer‑driven contract tests. The trick is to validate that your current implementation still satisfies the contract defined in the OpenAPI spec. Here’s a base test class:

@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
@AutoConfigureMockMvc
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public abstract class BaseContractTest {
    // Stubs generated from the OpenAPI spec
}

Then I write a contract test that calls the stubs and verifies the responses. If the spec changes, the stubs change, and the test fails—forcing a deliberate review.

Let’s talk about versioning. A design‑first API lives in a version‑controlled YAML file. You can diff two versions using openapi-diff in CI. It tells you whether the change is backward compatible or breaking. For instance, adding a new optional field is non‑breaking; removing a required field is breaking. You can fail the pipeline if a breaking change isn’t accompanied by a version bump. I’ve set this up as a Maven exec step:

java -jar openapi-diff-cli.jar --old order-service-api-v1.yaml --new order-service-api-v2.yaml --fail-on-incompatible

This gives you a hard stop before any deployment.

One personal rule I follow: treat the OpenAPI YAML as the source of truth for everything, including Springdoc’s Swagger UI. Instead of letting Springdoc generate the spec from annotations, I serve the static YAML file directly using a custom OpenApiCustomizer. This way, the UI always shows the canonical spec, not a derived version. No annotation drift, no hidden differences.

@Bean
public OpenApiCustomizer schemaCustomizer() {
    return openApi -> {
        // Load and merge custom paths if needed, but often just serve the original
    };
}

I also configure Springdoc to serve the spec at /v3/api-docs/design-first by pointing to the YAML resource:

springdoc:
  api-docs:
    path: /v3/api-docs/design-first

Now, when someone hits the Swagger UI, they see exactly what’s in the YAML file—not an annotation‑generated approximation. It’s a small thing, but it eliminates the “but Swagger showed me that field!” arguments.

The whole pipeline feels solid. You write the YAML, the code generates itself, validation enforces the schema at runtime, contract tests verify the integration, and CI prevents accidental breaking changes. Every layer reinforces the contract.

If you’re still building APIs by annotating controllers and hoping for the best, I encourage you to try this approach. It takes a bit more setup upfront, but the reduced debugging time pays for itself in the first sprint. Start with one endpoint, generate the stubs, and see how it feels. You might never go back.

Like this article? Share it with your team. Leave a comment if you’ve dealt with contract drift or have your own tricks. And don’t forget to subscribe—I post deep dives into Spring Boot and API design every month.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

// Similar Posts

Keep Reading