Building Microservices with Spring Boot

Designing Service APIs & DTOs

18 min Lesson 2 of 12

Designing Service APIs & DTOs

A microservice lives or dies by the quality of its public contract. Every endpoint you expose is a promise to every other service that depends on you — a promise that will cost real work to break. This lesson teaches you how to design that contract deliberately: which HTTP semantics to use, how to shape request and response bodies with Data Transfer Objects (DTOs), how to validate inputs early, and how to version your API so evolution does not become a disaster.

Why DTOs Are Not Optional

The temptation is to expose your JPA entity directly. It is already there; you just annotate it with Jackson annotations and return it from the controller. Do not do this. Your entity is tied to your database schema, your lazy-loaded relationships, your persistence annotations. Leaking it to the outside world creates a two-way coupling: a caller's code breaks when you rename a database column, and your schema is now constrained by what external callers expect.

A DTO is a plain Java object (a record or a class) whose only job is to carry data across a boundary. It contains exactly the fields the caller needs — no more. It has no JPA annotations, no business logic, no circular references that cause infinite JSON serialization.

The rule of thumb: Every field you put in a response DTO is part of your public API contract. Only include what callers actually need. You can always add fields later; removing one is a breaking change.

Java Records as DTOs

Java 16+ records are the cleanest way to write DTOs in modern Spring Boot 3 projects. They are immutable, auto-generate equals/hashCode/toString, and are natively supported by Jackson without any extra configuration.

// Response DTO — only exposes what callers need public record ProductResponse( Long id, String name, String description, java.math.BigDecimal price, String category, boolean available ) {}
// Request DTO — what the caller must send to create a product public record CreateProductRequest( String name, String description, java.math.BigDecimal price, String category ) {}

Notice that CreateProductRequest has no id field. The server assigns the ID; the caller must not supply one. This is a deliberate contract decision.

Validating Requests with Bean Validation

Never trust the caller. Every piece of data that arrives over the network must be validated before it touches your domain logic or your database. Spring Boot 3 integrates Jakarta Bean Validation (formerly javax) out of the box via the spring-boot-starter-validation dependency.

<!-- pom.xml --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>

Annotate your request DTO with constraints. Because records are supported, use compact constructor validation:

import jakarta.validation.constraints.*; public record CreateProductRequest( @NotBlank(message = "name is required") @Size(max = 120, message = "name must be 120 characters or fewer") String name, @Size(max = 1000) String description, @NotNull(message = "price is required") @DecimalMin(value = "0.01", message = "price must be positive") java.math.BigDecimal price, @NotBlank String category ) {}

Then add @Valid to the controller method parameter. Spring will reject the request with HTTP 400 automatically if any constraint fails:

import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @PostMapping @ResponseStatus(HttpStatus.CREATED) public ProductResponse create(@Valid @RequestBody CreateProductRequest request) { return productService.create(request); } @GetMapping("/{id}") public ProductResponse findById(@PathVariable Long id) { return productService.findById(id); } }
Return a structured error body on validation failure. By default, Spring returns a verbose 400 payload. Override MethodArgumentNotValidException in a @RestControllerAdvice to return a consistent, safe error shape — one that does not leak internal field names to external callers.

Mapping Between Entity and DTO

Someone has to translate between your JPA entity and your DTOs. You have three choices: manual mapping methods in a service class, a static factory method on the DTO itself, or a mapping library such as MapStruct. For most services, a private static helper method inside the service is clear and requires zero extra dependencies:

@Service public class ProductService { private final ProductRepository repository; public ProductService(ProductRepository repository) { this.repository = repository; } public ProductResponse create(CreateProductRequest request) { Product entity = new Product(); entity.setName(request.name()); entity.setDescription(request.description()); entity.setPrice(request.price()); entity.setCategory(request.category()); entity.setAvailable(true); Product saved = repository.save(entity); return toResponse(saved); } public ProductResponse findById(Long id) { Product entity = repository.findById(id) .orElseThrow(() -> new ProductNotFoundException(id)); return toResponse(entity); } // Pure mapping — no side effects, easy to test private static ProductResponse toResponse(Product p) { return new ProductResponse( p.getId(), p.getName(), p.getDescription(), p.getPrice(), p.getCategory(), p.isAvailable() ); } }
Never expose internal exception messages to API callers. A EntityNotFoundException stack trace or a SQL error message tells an attacker about your schema. Catch domain exceptions in your @RestControllerAdvice and return only a generic, safe message with the appropriate HTTP status code.

HTTP Semantics Matter

The HTTP method you choose is part of your contract. Misusing them creates confusion and breaks caching, proxies, and security tools. Follow these conventions strictly:

  • GET — Read only. Must be safe (no side effects) and idempotent. Never use GET to trigger a mutation.
  • POST — Create a new resource. Not idempotent; duplicate requests may create duplicates. Return 201 Created with a Location header pointing to the new resource.
  • PUT — Full replacement of an existing resource. Idempotent. The caller sends the complete new state.
  • PATCH — Partial update. Use when callers should only be able to change specific fields.
  • DELETE — Remove a resource. Idempotent. Return 204 No Content on success.

API Versioning Strategy

Microservices are evolved independently, and breaking changes happen. The most pragmatic versioning strategy for service-to-service communication is URL path versioning — put the version in the base path (e.g., /api/v1/products). It is visible, easy to route at the gateway level, and unambiguous in logs.

The key rule: do not break existing versions. Add a new endpoint at /api/v2/products when you need to change the contract. Run both versions in parallel until all callers have migrated, then decommission v1 with a deprecation warning period.

Additive changes are non-breaking. Adding a new optional field to a response DTO, or a new optional query parameter, does not require a version bump — provided callers are written to ignore unknown fields. Jackson does this by default (unknown properties are silently ignored). Removing a field, renaming one, or changing a type always requires a new version.

Summary

Good service API design is about creating a stable, minimal, honest contract. Use dedicated DTO records for request and response shapes; never leak JPA entities. Validate all inbound data at the controller boundary with @Valid. Map between entities and DTOs in a thin service layer. Use the correct HTTP verbs and status codes. Version your API from day one with a /api/v1 prefix so evolution never becomes an emergency. In the next lesson you will see how one service calls another using WebClient.