Writing 400 lines of YAML, who actually enjoys that? Certainly not me! It's hell to write and a chore to review. And like every time I end up in that kind of situation, I have to find a solution.
Code first
Most of the time, when we set up REST endpoints, it's for internal use. So there's rarely any documentation for these endpoints. When we provide these endpoints to another team, we sometimes agree on an interface contract, but that's rare, and often we'll simply expose an endpoint that does nothing yet. For public endpoints, we eventually end up publishing technical documentation for integrators. Often this is done after the endpoints have already been implemented. What I've seen a lot is using a library like Swagger. You annotate the endpoints and the documentation is generated automatically. This is what's called code first.
Here's an example of what code first looks like:
@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Products", description = "Product catalog API")
public class ProductController {
private final ConcurrentHashMap<Long, ProductResponse> products = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong(0);
public ProductController() {
long id = sequence.incrementAndGet();
products.put(id, new ProductResponse(id, "Dell XPS 15 Laptop", new BigDecimal("1299.99")));
}
@Operation(summary = "Get a product by id", description = "Returns the details of a single product")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Product found",
content = @Content(schema = @Schema(implementation = ProductResponse.class))
),
@ApiResponse(
responseCode = "404",
description = "Product not found",
content = @Content
)
})
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProductById(
@Parameter(description = "Product id", required = true, example = "42")
@PathVariable Long id) {
return Optional.ofNullable(products.get(id))
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@Schema(description = "A product")
public record ProductResponse(
@Schema(description = "Product id", example = "42")
Long id,
@Schema(description = "Product name", example = "Dell XPS 15 Laptop")
String name,
@Schema(description = "Unit price", example = "1299.99")
BigDecimal price
) {}
}
And here's what we get by generating the documentation:
openapi: 3.0.1
info:
title: OpenAPI definition
version: v0
servers:
- url: http://localhost:8099
description: Generated server url
tags:
- name: Products
description: Product catalog API
paths:
/api/v1/products/{id}:
get:
tags:
- Products
summary: Get a product by id
description: Returns the details of a single product
operationId: getProductById
parameters:
- name: id
in: path
description: Product id
required: true
schema:
type: integer
format: int64
example: 42
responses:
"404":
description: Product not found
"200":
description: Product found
content:
'*/*':
schema:
$ref: '#/components/schemas/ProductResponse'
components:
schemas:
ProductResponse:
type: object
properties:
id:
type: integer
description: Product id
format: int64
example: 42
name:
type: string
description: Product name
example: Dell XPS 15 Laptop
price:
type: number
description: Unit price
example: 1299.99
description: A product
It works well and is quite convenient. Now I can use this OpenAPI document wherever I want. Either in a third-party tool (almost all of them support this format), or in a Swagger UI. The documentation stays in sync with the code. I can send this document to the people who will integrate my endpoints. They could then start integrating them before my endpoints even go to production. On the other hand, if I make changes, I'd have to send them the new version of this document.
Code first is really the most practical and comfortable method. Most of the time it's probably the simplest and most widely used approach. However, it requires implementing the endpoint in order to generate the documentation. Sure, you can write empty endpoints that do nothing just to generate the documentation and finish the implementation afterwards. But where it becomes impractical is when you want to discuss interface contracts with multiple stakeholders. Whether that's another team or external integrators. And that's the reason the other approach exists: contract first.
Contract first
This is the reverse method from code first: you start from the specification (the OpenAPI document) and generate the corresponding code. The benefit of doing this, as I mentioned earlier, is that it lets you discuss the interface contract BEFORE committing time and effort to implementing the endpoint. YAML, even though I don't particularly like the format, is "readable" by non-devs. A Product Owner can review it and spot errors or inconsistencies. Another benefit is that you can parallelize building the endpoints and their consumption. You create the documentation, and once it's validated, you send it to the teams in charge of integrating it. Each side can then work independently. Yet another benefit is that you can set up automated tests that verify the implementation actually conforms to the spec, unlike code first where changing the code changes the spec.
So for this method, you just need to write and validate the OpenAPI document, then generate the code. I won't go into detail on how to do that here — I'll let you check out this article, which explains it very well: API First Development with Spring Boot and OpenAPI 3.0.
Still not fully satisfying
The main problem with contract first is that it requires writing YAML. For an endpoint like the one in my example, that's fine. But once you start having a lot of them, with references scattered everywhere, you have to maintain it. I find that the time saved by parallelizing work across teams gets completely eaten up by writing and reviewing that infamous OpenAPI document. Especially since everything has to be included in it: request examples, response examples, errors, and so on.
Approach and POC
For the reasons I've mentioned, each approach has its pros and cons. Each meets different needs. I think that outside of dependencies with other teams or endpoints intended for external integrators, code first should be favored.
Now, I know some POs use online tools to generate YAML from a graphical interface. However, these don't solve all the issues mentioned and add a new dependency on a third-party tool.
What I told myself is that the contract first approach is, in cases where it's relevant, the right one, but the friction needs to be removed. Particularly when it comes to generating the OpenAPI document. So why not have the documentation generated by code, but only for the contract part? We wouldn't write a real implementation, just enough to generate the document and hand it over to the relevant people. The idea would then be, instead of writing an OpenAPI document in YAML, to write Java code that generates it. We could write only the DTOs (request/response objects and everything needed to describe them) and the interfaces describing the endpoints.
The catch is that generating the docs requires actual REST controllers, not just interfaces... I couldn't find anything online to solve this problem. So I told myself that instead of implementing the interfaces with controllers, I'd build something that implements them on the fly. After all, these pseudo-controllers that only implement an interface and return nothing shouldn't be too hard to build.
So I built a POC that you can find here: Yagni/spec-from-skeleton
So how does it work? I won't go over the whole project in detail — feel free to check it out if you're interested. For the "magic" part, everything lives in the ApiStubAutoConfiguration class, which you can find here. It's a BeanDefinitionRegistryPostProcessor which, as its name suggests, kicks in quite early in the Spring startup, before the context needs the controllers. Here's what it does:
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
log.info("🔍 Scanning '{}' for @RequestMapping API interfaces...", CONTRACT_PACKAGE);
Set<Class<?>> apiInterfaces = findApiInterfaces();
log.info("✅ Found {} API interface(s) to register as stub controllers", apiInterfaces.size());
for (Class<?> apiInterface : apiInterfaces) {
registerStubController(registry, apiInterface);
}
}
It finds all the declared interfaces, and for each one, it creates and registers the corresponding bean. This way, from the Spring Boot application's point of view, the controller really exists.
For generating the bean at startup, I use ByteBuddy, building it directly from the interface:
private Class<?> createStubControllerClass(Class<?> apiInterface) {
String className = apiInterface.getSimpleName() + "StubImpl";
try (DynamicType.Unloaded<?> unloaded = new ByteBuddy()
.subclass(Object.class)
.implement(apiInterface)
.name(apiInterface.getPackage().getName() + ".generated." + className)
.annotateType(AnnotationDescription.Builder.ofType(RestController.class).build())
.annotateType(Arrays.stream(apiInterface.getAnnotations())
.map(AnnotationDescription.ForLoadedAnnotation::of)
.toArray(AnnotationDescription[]::new))
.method(ElementMatchers.any())
.intercept(InvocationHandlerAdapter.of(new StubMethodHandler()))
.make()) {
return unloaded.load(apiInterface.getClassLoader()).getLoaded();
}
}
All we do is create a class that implements the interface. We add the @RestController annotation so Spring can detect it properly, and we make it inherit all the annotations from the interface.
I also wrote the generate-spec.sh script to generate the documentation without having to run everything by hand.
The new workflow
Ok, so the POC shows that we can generate documentation from just interfaces and DTOs. This looks a lot like code first, except we never wrote the business logic. If we set aside the spec-generator module (that's the tool itself, only built once), the code used to generate the documentation lives in the contract module. It contains only interfaces or DTOs (mostly request and response objects). This gives us a module that serves as the interface contract between teams. We could even decide to make this module public to external integrators, but that's a separate discussion.
This module can then be used directly in other projects, both by the clients and by whoever actually implements the interfaces. This is a real difference from the code first approach described earlier: where I previously had to manually resend a new version of the OpenAPI document on every change, here the contract is a versioned dependency like any other. A mvn dependency:update (or equivalent) is enough for a consuming team to pull in the latest version of the contract.
It's now also the medium for discussion when building the contract between devs and the Product Owner. Taking the example from my POC, here's an excerpt:
@Tag(name = "Products", description = "Product catalog API")
@RequestMapping("/api/v1/products")
public interface ProductApi {
@Operation(
summary = "List products",
description = "Returns a paginated list of all products"
)
@ApiResponse(
responseCode = "200",
description = "Product list",
content = @Content(schema = @Schema(implementation = ProductListResponse.class))
)
@GetMapping
ResponseEntity<ProductListResponse> listProducts(
@Parameter(description = "Page number (zero-based)", example = "0")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "Page size", example = "10")
@RequestParam(defaultValue = "10") int size,
@Parameter(description = "Sort criterion", example = "name")
@RequestParam(defaultValue = "name") String sort
);
}
And its implementation in the example provided in the POC:
@RestController
public class ProductController implements ProductApi {
// class fields && constructor here...
@Override
public ResponseEntity<ProductListResponse> listProducts(int page, int size, String sort) {
return ResponseEntity.ok(paginate(List.copyOf(products.values()), page, size));
}
}
This has a benefit that pure contract-first doesn't have: here the implementation genuinely depends on the contract. If tomorrow we change the ProductApi interface (one more parameter, a different return type), ProductController no longer compiles until it's updated accordingly. So without writing anything extra, we get the equivalent of the contract/implementation conformance tests I mentioned earlier as one of classic contract-first's strengths, except here it's the compiler that takes care of it.
You can also see in the UserApi interface that endpoints can be split into categories, as shown here with UserReadOperations and UserWriteOperations. This is just one way of splitting things up, but you can do it however you like. The benefit of splitting is that you can then implement all the endpoints, or only some of them if needed. You're not required to implement these interfaces directly as long as the endpoints exist in the end, but I find it cleaner and safer to do it this way.
A secondary benefit is that it separates documentation from implementation, which lightens the RestController considerably.
With the interfaces and their associated DTOs, devs and the PO can co-build the interface contract together. Once the contract is validated, the Pull Request can be merged into the main branch and the contract becomes available to everyone. As for publishing the documentation for external integrators, we can rely on CI to generate the documentation after every new commit. It can then be shared, or even published directly into the public documentation (whether automatically or not).
We can actually push this logic further: the same CI mechanism can run on a branch that's still in progress, not just after merge. This lets us deploy an ephemeral environment with an up-to-date Swagger UI, accessible via a link in the Pull Request. The PO can then review and validate the contract on a standard Swagger UI rendering, exactly as they would with pure contract-first, without a single line of business code having been written.
Limitations?
This new workflow brings quite a few benefits, but it's not without flaws or limitations. Here are the ones I've identified:
- Dependency on ByteBuddy. One more dependency to watch and keep up to date...
- Strong coupling to the Java/Spring ecosystem. I mainly work in this ecosystem, but I'd imagine a similar solution could be found for others.
- Implementing the interface is optional. So there remains a risk of divergence between the spec and the actual implementation. But can we really stop humans from doing whatever they want?
- Added complexity cost for CI. To really get the full benefit of this new workflow, you need to set up new pipelines to generate and integrate the documentation. And of course, maintain them.
- Maintenance cost of the
spec-generatormodule. And yes, we can't forget security updates! - Grouping the APIs of several teams into a single
contractmodule raises a question: a breaking change on a single API would force a major version bump for everyone, even teams that aren't affected.
But above all, contrary to my initial "promise," we don't end up writing less description than in YAML: the Java annotations remain verbose. The real gain lies elsewhere: IDE autocompletion, safe refactoring, errors caught at compile time, and a reusable artifact you depend on rather than a plain document.
Next steps
This project remains a POC, and there are several avenues worth exploring further. Splitting the contract module into sub-modules per team or bounded context would help limit the blast radius of the breaking changes mentioned above. And even though I'm staying focused on the Spring ecosystem for now, I'd be curious to see whether an equivalent mechanism could apply to other frameworks. We could also package spec-generator as a proper reusable starter, further reducing the cost of adopting the tool.