Choosing Your OpenAPI Strategy with Quarkus vs. Spring
Hands-on guide comparing two approaches for modern Java APIs.
In modern application development, particularly within microservices architectures, clearly defined API contracts are crucial. The OpenAPI Specification (OAS) has become the de facto standard for describing RESTful APIs. How you generate and maintain this specification, however, significantly impacts your development workflow.
Two dominant approaches exist:
Code-First: You write your application code (controllers, DTOs) first, adding annotations. Tooling then introspects this code at runtime or build time to generate the OpenAPI specification.
Spec-First: You define the API contract in an OpenAPI document (
openapi.yaml
oropenapi.json
) before writing implementation code. Tooling then generates server-side skeletons (interfaces, models) and potentially client-side code based on this specification.
Both frameworks I am using in this example support both ways. And I’ve deliberately picked a controversial topic presenting the two approaches with two different frameworks. Both of them support both obviously.
Quarkus, with its quarkus-openapi-generator
extension, can strongly facilitate the spec-first approach, integrating it deeply into its build process. Spring Boot, commonly paired with the springdoc-openapi
library, would be the example for the code-first approach.
This article provides a hands-on comparison for Java developers and architects, exploring the mechanics, pros, and cons of each approach within their two different frameworks.
The Code-First Approach: Spring Boot and Springdoc
This is often the path of least resistance for developers starting a new project or adding APIs to an existing one, especially if they are already familiar with Spring Web annotations.
How it Works:
Write Code: You create standard Spring Boot components:
@RestController
classes, request/response DTOs (Plain Old Java Objects - POJOs), and use annotations like@GetMapping
,@PostMapping
,@PathVariable
,@RequestBody
,@RequestParam
, etc., to define endpoints and parameters.Add Annotations (Springdoc/Swagger): To enrich the generated specification, you add annotations from
springdoc-openapi
(or the olderswagger-annotations
). Key annotations include:@Operation
: Describes a single API operation (endpoint method).@Parameter
: Describes a single parameter.@RequestBody
: Describes the request body.@ApiResponse
: Describes a possible response for an operation.@Schema
: Describes a data model (DTO).@Tag
: Groups operations together.
Include Dependency: Add the
springdoc-openapi-starter-webmvc-ui
(for Spring MVC) orspringdoc-openapi-starter-webflux-ui
(for WebFlux) dependency to yourpom.xml
orbuild.gradle
.Runtime Generation: By default, when the Spring Boot application starts,
springdoc-openapi
scans the application context for relevant controllers and annotations. It generates the OpenAPI specification (/v3/api-docs
by default) and hosts a Swagger UI instance (/swagger-ui.html
) for interactive documentation. (Note: A Maven/Gradle plugin exists for build-time generation, but runtime is the most common default).
Hands-on Example (Spring Boot + Springdoc):
1. Dependency (pom.xml
):
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
2. DTO (Product.java
):
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Represents a product in the catalog")
public class Product {
@Schema(description = "Unique identifier for the product", example = "PROD-123", requiredMode = Schema.RequiredMode.REQUIRED)
private String id;
@Schema(description = "Name of the product", example = "Super Widget")
private String name;
@Schema(description = "Price of the product", example = "99.99")
private double price;
// Getters and Setters omitted for brevity
}
3. Controller (ProductController.java
):
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Product API", description = "API for managing products")
public class ProductController {
@Operation(summary = "Get a product by its ID",
description = "Retrieves detailed information about a specific product.",
responses = {
@ApiResponse(responseCode = "200", description = "Product found",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = Product.class))),
@ApiResponse(responseCode = "404", description = "Product not found")
})
@GetMapping("/{productId}")
public ResponseEntity<Product> getProductById(
@Parameter(description = "ID of the product to retrieve", required = true, example = "PROD-123")
@PathVariable String productId) {
// --- Dummy Implementation ---
if ("PROD-123".equals(productId)) {
Product p = new Product();
// Set properties...
return ResponseEntity.ok(p);
} else {
return ResponseEntity.notFound().build();
}
// --- End Dummy Implementation ---
}
@Operation(summary = "Search for products", description = "Finds products matching criteria.")
@GetMapping("/search")
public List<Product> searchProducts(
@Parameter(description = "Optional search term for product name")
@RequestParam(required = false) String name) {
// --- Dummy Implementation ---
return Collections.emptyList();
// --- End Dummy Implementation ---
}
}
Accessing the Spec/UI: Run the Spring Boot application. Navigate to /v3/api-docs
to see the raw OpenAPI JSON and /swagger-ui.html
for the interactive UI.
Pros of Code-First (Springdoc):
Familiar Workflow: Fits naturally into the typical "code-compile-run" cycle for many Java developers.
Low Initial Friction: Easy to add to existing projects or start new ones quickly.
Refactoring Support: IDE refactoring tools often handle annotation updates when renaming methods or classes (though not always perfectly for spec semantics).
Single Source: The code is the source of truth (theoretically), reducing duplication if annotations are diligently maintained.
Cons of Code-First (Springdoc):
Spec as an Afterthought: Documentation quality depends entirely on developer discipline in adding and maintaining annotations. It's easy for the spec to become outdated or incomplete.
Potential for Drift: The actual runtime behavior might subtly differ from what the annotations imply, especially regarding validation or error handling specifics.
Annotation Clutter: Controller code can become heavily cluttered with detailed OpenAPI annotations, potentially obscuring business logic.
Runtime Overhead (Default): Default runtime generation adds startup time and memory overhead, although build-time plugins mitigate this.
Contract Enforcement: It doesn't strictly enforce the contract before implementation. A developer could change the code without updating annotations, breaking consumers relying on the (now inaccurate) spec.
The Spec-First Approach: Quarkus and quarkus-openapi-generator
Quarkus actively encourages a spec-first workflow through its build-time integration with the OpenAPI Generator project.
How it Works:
Define the Contract: Create an
openapi.yaml
oropenapi.json
file, typically placed insrc/main/openapi
orsrc/main/resources/META-INF/openapi
. This file rigorously defines paths, operations, parameters, request/response bodies, and schemas (models).Add Extension: Include the
quarkus-openapi-generator
extension in yourpom.xml
orbuild.gradle
.Configure Generation (Optional but common): In
application.properties
, configure how the generator should run. You typically point it to your OpenAPI spec file and specify the base package where generated Java code (interfaces, models) should be placed.Build Time Generation: During the Quarkus build process (
mvn quarkus:dev
,mvn package
, etc.), the extension invokes the OpenAPI Generator. It parses your spec file and generates:Java Interfaces: Corresponding to your API paths/operations (e.g.,
ProductsApi.java
). These interfaces use JAX-RS annotations.Model Classes (POJOs): Corresponding to the schemas defined in your spec. These often include basic validation annotations (like
@NotNull
,@Size
) derived from the spec.
Implement the Interface: Write a standard Quarkus resource class (e.g.,
ProductResource.java
) thatimplements
the generated Java interface. You only need to provide the business logic; the JAX-RS annotations defining the endpoints are already on the interface methods.Spec Serving: Quarkus still serves the original
openapi.yaml
/json
file (often at/q/openapi
) for documentation tools like Swagger UI (enabled via thequarkus-smallrye-openapi
extension).
Hands-on Example (Quarkus + quarkus-openapi-generator):
1. OpenAPI Specification (src/main/openapi/product-api.yaml
):
openapi: 3.0.3
info:
title: Product API
version: 1.0.0
description: API for managing products
tags:
- name: Product API
description: API for managing products
paths:
/api/v1/products/{productId}:
get:
tags:
- Product API
summary: Get a product by its ID
description: Retrieves detailed information about a specific product.
operationId: getProductById # Used for generated method name
parameters:
- name: productId
in: path
required: true
description: ID of the product to retrieve
schema:
type: string
example: PROD-123
responses:
'200':
description: Product found
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
'404':
description: Product not found
/api/v1/products/search:
get:
tags:
- Product API
summary: Search for products
description: Finds products matching criteria.
operationId: searchProducts
parameters:
- name: name
in: query
required: false
description: Optional search term for product name
schema:
type: string
responses:
'200':
description: List of matching products
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Product'
components:
schemas:
Product:
type: object
description: Represents a product in the catalog
required:
- id
properties:
id:
type: string
description: Unique identifier for the product
example: PROD-123
name:
type: string
description: Name of the product
example: Super Widget
price:
type: number
format: double
description: Price of the product
example: 99.99
2. Dependency (pom.xml
):
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-openapi-generator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
3. Configuration (application.properties
):
# Point the generator to our spec file
quarkus.openapi-generator.spec.product-api.yaml=src/main/openapi/product-api.yaml
# Define base package for generated code (adjust group/artifact id)
# Example assumes your project is com.example:my-product-service
quarkus.openapi-generator.codegen.spec.product-api_yaml.base-package=com.example.myproductservice.api.generated
# Optional: Skip validation during generation if needed (use with caution)
# quarkus.openapi-generator.codegen.validate-spec=false
4. Build the Project: Run mvn compile quarkus:dev (or just mvn compile). Check the target/generated-sources/quarkus-openapi-generator directory. You'll find generated files like:
* com/example/myproductservice/api/generated/ProductApi.java (Interface)
* com/example/myproductservice/api/generated/model/Product.java (Model class)
Generated ProductApi.java
(Snippet):
package com.example.myproductservice.api.generated;
// imports...
import com.example.myproductservice.api.generated.model.Product;
@Path("/api/v1/products")
@javax.annotation.Generated(value = "org.openapitools.codegen.languages.JavaJAXRSSpecServerCodegen") // Generator metadata
public interface ProductApi {
@GET
@Path("/{productId}")
@Produces({ "application/json" })
@ApiOperation(value = "Get a product by its ID", /* ... more annotations ... */)
Response getProductById(@PathParam("productId") @NotNull String productId);
@GET
@Path("/search")
@Produces({ "application/json" })
@ApiOperation(value = "Search for products", /* ... more annotations ... */)
Response searchProducts(@QueryParam("name") String name);
}
5. Implement the Interface (ProductResource.java
):
package com.example.myproductservice.api;
import com.example.myproductservice.api.generated.ProductApi;
import com.example.myproductservice.api.generated.model.Product;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.core.Response;
import java.util.Collections;
@ApplicationScoped // Standard Quarkus bean scope
public class ProductResource implements ProductApi { // Implement the generated interface
@Override
public Response getProductById(String productId) {
// --- Dummy Implementation ---
if ("PROD-123".equals(productId)) {
Product p = new Product();
// Set properties using generated setters...
p.setId(productId);
p.setName("Super Widget");
p.setPrice(99.99);
return Response.ok(p).build();
} else {
return Response.status(Response.Status.NOT_FOUND).build();
}
// --- End Dummy Implementation ---
}
@Override
public Response searchProducts(String name) {
// --- Dummy Implementation ---
return Response.ok(Collections.emptyList()).build();
// --- End Dummy Implementation ---
}
}
Accessing the Spec/UI: Run the Quarkus application (mvn quarkus:dev
). Navigate to /q/openapi
(or the path configured by quarkus-smallrye-openapi
) to see the original spec, and /q/swagger-ui
for the interactive UI.
Pros of Spec-First (Quarkus OpenAPI Generator):
Contract as Source of Truth: The OpenAPI spec is the definitive contract. This enforces discipline and clarity.
Clear Separation: Separates API contract definition from implementation logic, leading to cleaner code.
Build-Time Efficiency: Code generation happens during the build, adding no runtime overhead for spec generation itself. Quarkus's fast build times make this seamless.
Parallel Development: Frontend teams, backend teams, and QA can work concurrently based on the agreed-upon spec.
Tooling Ecosystem: Leverages the mature OpenAPI Generator tool, allowing generation of clients, server stubs in various languages, documentation snippets, etc.
Reduced Boilerplate: Generates interfaces and models, saving repetitive coding effort.
Cons of Spec-First (Quarkus OpenAPI Generator):
Upfront Design Effort: Requires defining the API contract before implementation, which can feel slower initially.
Learning Curve: Understanding OpenAPI syntax and the generator's configuration options takes some effort. Customizing generation might require deeper knowledge.
Rigidity: Can feel more rigid than code-first. Changes require updating the spec and regenerating code, adding an extra step.
Generated Code: Developers need to work with (and understand the boundaries of) generated code. Debugging might involve stepping into generated layers.
Comparison: Spec-First vs. Code-First
Which Approach Should You Choose?
The "best" approach depends heavily on your context:
Choose Code-First (Springdoc) if:
You are rapidly prototyping or working on smaller projects/teams.
Your team is already deeply invested in and comfortable with annotation-based definitions.
The API is relatively simple or evolves organically alongside the implementation.
You prioritize immediate coding speed over strict contract enforcement initially.
Choose Spec-First (Quarkus) if:
You are building complex APIs or microservices where contracts are critical.
You have separate frontend/backend teams or external consumers relying on a stable contract.
You value rigorous API design and documentation from the outset.
You want to leverage the broader OpenAPI Generator ecosystem for client/server generation.
You are embracing Quarkus's build-time philosophy for performance and efficiency.
It's also worth noting that you can use the OpenAPI Generator Maven/Gradle plugin directly in a Spring Boot project for a spec-first approach, and Quarkus can work with a code first approach. Conversely, Quarkus can generate an OpenAPI spec from JAX-RS annotations using quarkus-smallrye-openapi
(similar to Springdoc), but its unique strength lies in the integrated spec-first generator workflow.
Remember that ..
... both Quarkus and Spring Boot offer robust solutions for working with OpenAPI, but they champion different philosophies. Spring Boot with Springdoc excels at the familiar code-first approach, generating specifications from annotated code, which is often quick to start but requires discipline to maintain accurately. Quarkus, through its quarkus-openapi-generator
extension, provides a powerful, integrated spec-first workflow. By defining the contract upfront and generating code skeletons at build time, it promotes design rigor, clear contracts, and leverages build-time optimizations.
Understanding the trade-offs between these approaches allows development teams and architects to choose the workflow that best aligns with their project goals, team structure, and technical philosophy, ultimately leading to more productive development and well-defined APIs. For teams adopting Quarkus, embracing the spec-first approach offered by quarkus-openapi-generator
is often a highly effective strategy.