Why I Stopped Generating Code from OpenAPI in Mirror
Design-first OpenAPI looked clean until Spring generics, pagination, and response wrappers outgrew what a YAML spec can honestly express. Here is why Mirror moved control back into the code.
I started Mirror's backend with a design-first OpenAPI workflow. Define the contract in YAML, generate Java interfaces, implement the controllers, ship.
That pipeline looked disciplined. It also hit a wall: the shapes I needed in Spring were richer than the shapes OpenAPI could describe without becoming a second, worse programming language.
This post is about that wall - mainly the gap between what you can declare in a spec and what you actually get back as DTOs and controller signatures.
Design-first vs code-first
Two common ways to keep an HTTP API honest:
- Design-first: write
openapi.yaml, generate stubs/interfaces, implement against them. - Code-first: write controllers and models, annotate them, publish the spec from what the runtime actually exposes.
Both can work. Design-first is attractive when you want mobile and backend to negotiate a contract before either side hardens implementation details. Code-first is attractive when the framework already owns pagination, validation, and response envelopes that the spec language only approximates.
Mirror started design-first. It was a premeditated decision. I wanted to see how far I could go with this given that I had absolute freedom to take engineering decisions.
What the pipeline looked like
The flow was roughly:
- Hand-edit
openapi.yamlunder resources. - OpenAPI Generator (Maven plugin) produces API interfaces already decorated with OpenAPI annotations.
@Controllerclasses implement those interfaces.- SpringDoc / Swagger UI exposes the runtime spec (
swagger.json), which is more accurate than the source YAML because it reflects what the app actually wired.
In short:
openapi.yaml
→ generated API interfaces (+ annotations)
→ controller implementations
→ runtime swagger.json (the Postman / client source of truth)
That last point mattered. Even while the YAML was the input, the endpoint that served the interpreted spec was the better artifact for collections and client generation. The YAML was a design sketch; the runtime document was the handshake.
Where it breaks: the DTO ceiling
OpenAPI is excellent at describing JSON objects, query params, status codes, and media types. It is much weaker at describing Java's type system as Spring actually uses it.
The pain is not "OpenAPI is bad." The pain is that generation forces your API surface through a model that:
- Does not have real generics the way Java does.
- Encourages flattened, named schemas for every variation of a response.
- Translates awkwardly into Spring types you already have (
Page,Pageable,ResponseEntity, HATEOAS models, custom envelopes).
Once your controllers want to return framework types - or compose DTOs inside them - you stop designing the API. You start fighting the generator so the generated signature matches the method you wanted to write by hand.
Pagination is the clearest example
Spring Data pagination is a two-sided contract:
- In:
Pageable(page, size, sort). - Out:
Page<T>(content, total elements, page metadata, etc.).
In OpenAPI you can usually model the request side well enough: page, size, sort as parameters. The response side is where honesty collapses.
What you actually want in Java:
ResponseEntity<Page<ItemDto>> listItems(Pageable pageable);
What the spec world pushes you toward:
- invent a
ItemDtoPageschema withcontent,totalElements,totalPages,number,size, ... - repeat that pattern for every entity (
OutfitDtoPage,TagDtoPage, ...) - or lie in the spec and document a bare array while the implementation returns a page object
None of those options is free. Named page wrappers duplicate knowledge Spring already has. Copy-pasted schemas drift. Documenting an array when you return a page means your "contract" is fiction the moment a client trusts it.
You can keep patching the YAML. You can keep teaching the generator special cases. At some point you are maintaining two APIs: the one Spring wants, and the one OpenAPI can express.
Generics and wrappers compound the problem
Pagination is only the loudest case. The same ceiling shows up anywhere a response is a parameterized wrapper:
Page<T>PagedModel<T>/RepresentationModel<T>if you lean on Spring HATEOASResponseEntity<T>with varying body types and headers- any shared envelope like
ApiResponse<T>withdata,errors,meta
OpenAPI schemas are nominative. Generics become either:
- Explosion of concrete schemas (
PageOfItem,PageOfOutfit, ...), or - Lossy approximations (
additionalProperties, loosely typedobject, or docs that omit the wrapper).
Generated DTOs inherit that choice. They are rarely as ergonomic as the hand-written models you would have authored once you already knew the domain.
That is the core claim: the limiting factor stopped being "how do I describe this endpoint?" and became "how much of Spring's type system am I willing to flatten into YAML?"
Options I considered
Faced with that gap, the realistic paths were:
1. Stay design-first and invent custom page (and wrapper) schemas
Keep generating from openapi.yaml. For each paginated resource, define an explicit page DTO in the spec and accept the duplication.
Pros: one source of truth in YAML; generation stays automated. Cons: schema sprawl; every new list endpoint taxes the contract; you still lose real Java generics.
2. Stop generating and own the annotations in source
Copy the generated interfaces into main source (or rewrite them), implement controllers freely, and maintain OpenAPI annotations by hand - basically doing the generator's job with full control over return types.
Pros: Page<T>, HATEOAS models, and custom envelopes become normal Java again.
Cons: more annotation surface in code; the YAML is no longer the driver.
3. Post-process generated code
Keep the generator, then run a script that rewrites signatures (for example, force Page<SomeDto> where the generator emitted something weaker).
Pros: fastest local fix. Cons: a brittle second compiler. Refactors become "remember the replace rules." This is technical debt with a bash accent.
Decision
I stopped generating controller interfaces from the OpenAPI file.
The YAML-driven flow was optimizing the wrong side of the problem. I needed control over the output types more than I needed a YAML file to be the authoritative authoring surface. Specs do not model generic response wrappers well enough for a Spring API that leans on Page, and may lean on HATEOAS-style models in places. Fighting that mismatch costs more than writing the annotations yourself.
So Mirror moved toward: code owns the shapes; the published spec is derived from what the code actually exposes.
That is not "we abandoned OpenAPI." It is "we stopped pretending the design document could express types the runtime already handles better."
What I still want from a contract
Dropping generation does not mean dropping discipline.
I still care about:
- A published OpenAPI document clients can import (Postman, codegen for mobile, future web).
- Breaking changes being visible, not accidental.
- Controllers remaining boring and explicit about status codes and media types.
The difference is authorship. The runtime-accurate swagger endpoint was already the better consumer-facing artifact. Making the controllers the source of that document removes a translation layer that was inventing DTOs I did not want.
Tradeoffs, said plainly
Moving away from generation means:
- More annotation (or equivalent metadata) living next to the Java.
- Less "edit YAML, regenerate everything" ceremony.
- Fewer fake page schemas and fewer generator workarounds.
- A clearer refactor story: change the Java type, then confirm the published spec - instead of changing YAML, regenerating, then patching what the generator got wrong.
For a small backend with a mobile client, that trade favors control. The more generated glue you accumulate, the more expensive a future redesign becomes - especially when the glue exists only to paper over missing generics.
Closing
Design-first OpenAPI is a good default when your API is mostly plain DTOs and status codes. It stops being a good default when Spring's type system - pagination, wrappers, hypermedia models - is part of the product contract.
In Mirror, the DTO ceiling was the signal. Once every honest response required either schema explosion or generator hacks, the process was no longer buying clarity. It was buying friction.
So I stopped generating from the spec and took ownership of the code. The contract still exists. It just follows the implementation instead of pretending to lead it.