DEV Community

Cover image for Build Better REST APIs in Java: Master OpenAPI Specification-First Development for Seamless Integration
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Build Better REST APIs in Java: Master OpenAPI Specification-First Development for Seamless Integration

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!

Let's talk about building APIs, specifically REST APIs in Java. If you've ever been part of a project where the frontend team and backend team seem to be speaking different languages, where documentation is always out of date, and where making a simple change breaks three different apps, then you know the problem. There's a way to fix this. It starts by writing down exactly what your API will do before you write a single line of server code. This is often called a "specification-first" approach, and OpenAPI is the tool that makes it work.

Think of OpenAPI as a blueprint. Before you build a house, you and the architect agree on a detailed plan. How many rooms? Where are the doors? What materials? Everyone signs off. That blueprint becomes the single source of truth. OpenAPI does the same for your API. It’s a formal, machine-readable document that describes every endpoint, every piece of data it expects, and every response it can give back. You write this document first. It’s your contract.

Here’s a tiny piece of what that contract looks like. It’s usually written in YAML, which is a clean, human-friendly format. This snippet says we have an API for orders, and you can POST a new order to the /orders endpoint.

openapi: 3.0.3
info:
  title: Order Service API
  version: 1.0.0

paths:
  /orders:
    post:
      summary: Create a new order
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrderRequest'
      responses:
        '201':
          description: Order created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderResponse'
Enter fullscreen mode Exit fullscreen mode

The magic is in the $ref parts. They point to definitions elsewhere in the document, keeping things organized. Let's look at those definitions. This is where we get specific about the shape of our data.

components:
  schemas:
    OrderRequest:
      type: object
      required:
        - customerId
        - items
      properties:
        customerId:
          type: string
          format: uuid
        items:
          type: array
          items:
            $ref: '#/components/schemas/OrderItem'

    OrderItem:
      type: object
      required:
        - productId
        - quantity
      properties:
        productId:
          type: string
        quantity:
          type: integer
          minimum: 1
Enter fullscreen mode Exit fullscreen mode

This isn't just a description. It's a set of rules. The OrderRequest must have a customerId that is a valid UUID, and an items array. Each item in that array must have a productId (a string) and a quantity (an integer that is at least 1). If someone sends data that breaks these rules, we know immediately it's invalid. We've defined our expectations upfront.

So, you have this blueprint. What now? The first powerful technique is to let the blueprint write your basic code for you. This is code generation. Tools can read your OpenAPI document and spit out the skeleton of your Java application. It creates the interfaces, the data models (those OrderRequest and OrderResponse classes), and all the boilerplate annotations. This is a huge time-saver and, more importantly, it guarantees that your code starts its life in perfect sync with the specification.

Here’s an example of what a generated server interface might look like. Notice the default method returns NOT_IMPLEMENTED. The tool creates the shell; you fill in the business logic.

@Controller
@RequestMapping("${openapi.orderService.base-path:/api}")
public interface OrdersApi {

    @RequestMapping(
        method = RequestMethod.POST,
        value = "/orders",
        produces = { "application/json" },
        consumes = { "application/json" }
    )
    default ResponseEntity<OrderResponse> createOrder(
         @Valid @RequestBody OrderRequest orderRequest
    ) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
    }
}
Enter fullscreen mode Exit fullscreen mode

Your job as a developer is then to implement this interface. Your implementation is clean and focused solely on the business problem, because all the API plumbing is already set up.

@RestController
public class OrderController implements OrdersApi {

    private final OrderService orderService;

    @Override
    public ResponseEntity<OrderResponse> createOrder(OrderRequest orderRequest) {
        // Convert the API model to your internal domain model
        Order domainOrder = convertToDomain(orderRequest);
        // Use your business logic service
        Order createdOrder = orderService.create(domainOrder);
        // Convert back to API model and return
        return ResponseEntity
            .created(URI.create("/orders/" + createdOrder.getId()))
            .body(convertToResponse(createdOrder));
    }
}
Enter fullscreen mode Exit fullscreen mode

The beauty here is separation. The OrderController handles web concerns, but it delegates the core "order creation" logic to the OrderService. The OpenAPI spec defined the contract, the generated code provides the structure, and you provide the valuable business behavior.

Now, how do you know your implementation actually follows the contract? You write tests, of course. But in a specification-first world, testing becomes even more powerful. The second technique is contract testing. You can automatically generate tests from your OpenAPI document that check if your running service obeys its own rules. These tests act as a safety net, catching any deviation from the promised behavior.

Imagine a test that is born directly from that YAML we wrote earlier. It knows a valid request needs a UUID and items with a quantity of at least 1. It can test both the happy path and the error cases.

@SpringBootTest
@AutoConfigureMockMvc
class OrdersApiContractTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldCreateOrder() throws Exception {
        String validOrderJson = """
            {
                "customerId": "550e8400-e29b-41d4-a716-446655440000",
                "items": [
                    {
                        "productId": "product-123",
                        "quantity": 2
                    }
                ]
            }
            """;

        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(validOrderJson))
            .andExpect(status().isCreated()) // Should return 201
            .andExpect(jsonPath("$.id").exists()) // Should have an 'id' field
            .andExpect(jsonPath("$.status").value("PENDING"));
    }
}
Enter fullscreen mode Exit fullscreen mode

And a test for failure is just as important. This ensures your validation works and returns a proper error, like a 400 Bad Request, when the contract is violated.

    @Test
    void shouldRejectInvalidOrder() throws Exception {
        String invalidOrderJson = """
            {
                "customerId": "not-a-uuid", // Invalid format!
                "items": [] // Empty array is technically okay, but the invalid ID should fail
            }
            """;

        mockMvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidOrderJson))
            .andExpect(status().isBadRequest());
    }
Enter fullscreen mode Exit fullscreen mode

These tests are not about your business logic. They are about the API contract. They verify that the "plumbing" works as advertised. If you change your implementation in a way that accidentally breaks the API—like requiring a new field that wasn't in the spec—these tests will fail. They guard the agreement you made.

The third technique solves a classic headache: documentation. In traditional projects, documentation is a separate task, a wiki page or a Google Doc that is almost always outdated the moment the code changes. With OpenAPI, your documentation is your specification. Tools can take your OpenAPI YAML file and turn it into beautiful, interactive web pages automatically.

Libraries like Springdoc OpenAPI integrate directly with your Spring Boot application. As you write your Java code and annotate it, the library inspects your running application and builds or enhances an OpenAPI model behind the scenes. You can also provide global configuration to add important details like security schemes.

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI orderServiceOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("Order Service API")
                .version("1.0.0"))
            .components(new Components()
                .addSecuritySchemes("bearerAuth",
                    new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")));
    }
}
Enter fullscreen mode Exit fullscreen mode

You can add descriptive annotations to your controller methods to make the auto-generated docs even richer. This combines the rigor of the spec-first approach with the convenience of code-level documentation.

@RestController
@Tag(name = "Orders") // Groups this controller in the docs
public class OrderController {

    @Operation(summary = "Get order by ID") // A clear summary
    @ApiResponses({
        @ApiResponse(responseCode = "200", description = "Order found"),
        @ApiResponse(responseCode = "404", description = "Order not found")
    })
    @GetMapping("/orders/{id}")
    public ResponseEntity<OrderResponse> getOrder(
            @Parameter(description = "Order ID", example = "12345") // Describes the parameter
            @PathVariable String id) {
        // implementation
    }
}
Enter fullscreen mode Exit fullscreen mode

The result is a living documentation site, often with a built-in "Try it out" feature. A frontend developer can visit this page, see every endpoint, understand the exact data format, and even execute sample calls against your real development server. The documentation can never be out of sync because it’s generated from the same code that runs the API.

The fourth technique is a game-changer for teamwork and integration: client SDK generation. You have this precise, machine-readable API contract. Why should the team building the mobile app or the frontend website have to manually write the code to call your API? They shouldn't. The same OpenAPI document that generated your server skeleton can generate a complete, type-safe client library for them.

Tools can produce clients in Java, TypeScript, Python, C#, and many other languages. This means the team using your API gets a library that feels natural in their language, with ready-made methods for all your endpoints and pre-defined data classes that match your schemas.

Here’s a simplistic view of what a generated Java client might look like. It handles the HTTP communication, serialization, and deserialization.

public class OrderApiClient {

    private ApiClient apiClient; // Handles low-level HTTP

    public OrderResponse createOrder(OrderRequest orderRequest) throws ApiException {
        // This method knows the URL, method, and data types from the OpenAPI spec
        return apiClient.invokeAPI(
            "/orders",
            "POST",
            orderRequest,
            new GenericType<OrderResponse>(){}
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Another service in your system, like a payment service, can use this generated client. It’s safe and easy. The OrderResponse class used here is the same one generated from the shared OpenAPI spec, ensuring perfect data compatibility.

public class PaymentService {
    private final OrderApiClient orderClient;

    public void processPayment(String orderId) {
        try {
            // Type-safe call. We know exactly what we're getting back.
            OrderResponse order = orderClient.getOrder(orderId);
            // Process payment for this order...
        } catch (ApiException e) {
            // Handle errors based on the HTTP status code from the spec
            if (e.getCode() == 404) {
                throw new OrderNotFoundException(orderId);
            }
            throw new ServiceException("Failed to fetch order", e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This eliminates a whole category of integration bugs. No more mismatched field names, incorrect URL paths, or wrong data types. The client code is a direct product of the contract.

Finally, the fifth technique is more of a mindset that ties everything together: treating the OpenAPI specification as the single source of truth throughout the entire development lifecycle. This isn't a one-time document you write and forget. It’s a living artifact at the center of your process.

Your build pipeline can be designed around it. Step one: Validate the OpenAPI file for syntax errors. Step two: Generate the server interfaces and client SDKs. Step three: Run the contract tests to ensure the implementation matches. Step four: Generate and publish the documentation. Step five: Package and deploy. The spec drives the process.

This approach fosters better collaboration. Backend developers can start implementing against the generated interfaces as soon as the spec is stable. Frontend developers can start building UI logic and network calls using the generated client SDK against a mock server that also comes from the spec. Both sides can work in parallel with confidence, because they are bound by the same agreed-upon contract.

When a change is needed, you discuss and update the OpenAPI specification first. Everyone reviews the change to the contract. Once merged, the code generation tools create the new method signatures or data models, highlighting exactly what needs to be implemented on the server and what needs to be adapted on the clients. The contract tests will fail until the server implementation fulfills the new terms. It’s a disciplined, traceable way to evolve an API.

In my own experience, moving to this specification-first style with OpenAPI felt like turning on the lights. The ambiguity vanished. Arguments about "how the API should work" were resolved by editing a YAML file, not through long Slack threads. Integration became predictable. Our documentation became a useful asset instead of a source of embarrassment. The initial investment in learning the OpenAPI syntax and setting up the code generation tools paid for itself many times over in reduced bugs, faster onboarding, and smoother team workflows. It transforms the API from an afterthought into a designed, reliable product interface.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


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!

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 | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS 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

Top comments (0)