Mapping & Contracts
A microservice exposes a public API through its DTOs and consumes other services through theirs. The gap between those public shapes and the internal JPA entities that back them must be bridged deliberately — that bridge is object mapping. Getting it right determines the stability of your service contract, the security of your data, and the maintainability of your codebase. This lesson covers the techniques every Spring Boot developer needs: hand-written mappers, utility helpers, and an introduction to MapStruct for teams that want compile-time safety at scale.
Why You Must Never Expose Entities Directly
The most common shortcut in early microservice code is returning JPA entities straight from a @RestController. This creates three serious problems:
- Security leaks: Entities often carry fields that must never leave the service — password hashes, internal audit columns, foreign-key IDs that expose your schema. A single added field in the entity silently becomes part of the public API.
- Tight coupling: Any consumer of your API now depends on your internal data model. Renaming a column in the database breaks every client without a compile-time warning.
- Serialisation side-effects: Lazy-loaded JPA associations trigger N+1 queries when Jackson tries to serialise them, or worse, throw a
LazyInitializationException outside a transaction boundary.
Never serialize a JPA entity in a REST response. If Jackson can see a @OneToMany collection, it will try to load it. Mark the entity class with @JsonIgnoreProperties({"hibernateLazyInitializer","handler"}) as a safety net, but always use a DTO instead.
The DTO Layer — What It Looks Like
A DTO (Data Transfer Object) is a plain Java class that carries exactly the fields needed by the consumer, named in the terms the consumer understands. Modern Spring Boot teams use Java records for immutable DTOs because they are concise and serialise cleanly with Jackson:
// Entity — internal, owned by the persistence layer
@Entity
@Table(name = "orders")
public class OrderEntity {
@Id
private Long id;
private String customerId;
private BigDecimal totalAmount;
private String internalCostCentreCode; // NEVER expose this
private Instant createdAt;
private Instant lastModifiedAt;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderLineEntity> lines;
// getters / setters omitted
}
// Response DTO — public contract, safe to serialize
public record OrderResponse(
String orderId, // hashed, not the raw Long PK
String customerId,
BigDecimal total,
List<OrderLineResponse> lines,
Instant createdAt
) {}
public record OrderLineResponse(
String productId,
int quantity,
BigDecimal unitPrice
) {}
Notice that internalCostCentreCode and lastModifiedAt are completely absent. The raw Long id is converted to a hashed string before it ever leaves the service. These decisions are made in the mapper, not in the entity.
Hand-Written Mappers — the Baseline Approach
A hand-written mapper is a plain Spring @Component with explicit conversion logic. It is the right default for simple cases and gives you total control over the transformation:
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class OrderMapper {
private final HashIdService hashIdService;
public OrderMapper(HashIdService hashIdService) {
this.hashIdService = hashIdService;
}
public OrderResponse toResponse(OrderEntity entity) {
List<OrderLineResponse> lineResponses = entity.getLines().stream()
.map(this::toLineResponse)
.toList();
return new OrderResponse(
hashIdService.encode(entity.getId()), // hide raw PK
entity.getCustomerId(),
entity.getTotalAmount(),
lineResponses,
entity.getCreatedAt()
);
}
private OrderLineResponse toLineResponse(OrderLineEntity line) {
return new OrderLineResponse(
hashIdService.encode(line.getProductId()),
line.getQuantity(),
line.getUnitPrice()
);
}
// Inbound: request DTO → new entity (no id, no audit fields)
public OrderEntity toEntity(CreateOrderRequest request) {
OrderEntity entity = new OrderEntity();
entity.setCustomerId(request.customerId());
// lines mapped separately, total calculated in service layer
return entity;
}
}
Keep business logic out of mappers. Price calculations, validation, and authorization checks belong in the service layer. A mapper should only translate field names and types. If a mapper method grows beyond ~15 lines, extract the logic into the service and pass a richer object to the mapper.
Mapping Inbound Requests Safely
Mapping from an inbound DTO to an entity demands extra care. The client must never be allowed to set fields that the server controls — like id, createdAt, or an internal status field. Ignoring unknown properties is also important to avoid breaking existing clients when you add new fields:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
// Inbound DTO — only the fields the caller is allowed to supply
@JsonIgnoreProperties(ignoreUnknown = true)
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<OrderLineRequest> lines
) {}
Using @JsonIgnoreProperties(ignoreUnknown = true) on request DTOs is a backwards-compatibility contract: if the client sends extra fields (perhaps because it talks to a newer version of your service), they are silently dropped rather than causing a 400 error.
Distributed-Systems Consideration — Schema Evolution
In a microservice mesh, services are deployed independently. Your order service may be running version 1.2 while the inventory service that consumes its events is still on version 1.1. This means your DTO fields cannot be safely renamed or deleted; they can only be added. Consumers that use @JsonIgnoreProperties(ignoreUnknown = true) will gracefully ignore new fields. This principle — be conservative in what you send, liberal in what you accept — is sometimes called Postel's Law and is the foundation of backward-compatible API design.
Design rule for event DTOs: treat any field you publish in a message or API response as a public commitment. Never remove a field from a response DTO without a deprecation cycle. Adding fields is always safe; removing or renaming is a breaking change.
Introduction to MapStruct
Hand-written mappers work well up to a point. Once a service has dozens of entities and DTOs, the boilerplate becomes repetitive and error-prone. MapStruct is an annotation processor that generates mapper implementations at compile time — no reflection, no runtime overhead, full IDE support and type safety.
Add the dependency to pom.xml:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<!-- annotation processor — required for code generation -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
Define a mapper interface — MapStruct generates the implementation:
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface ProductMapper {
// Field names match — generated automatically
ProductResponse toResponse(ProductEntity entity);
// Ignore fields the caller must not set
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
ProductEntity toEntity(CreateProductRequest request);
}
With componentModel = SPRING, MapStruct registers the generated class as a Spring bean, so you can inject it like any other @Component. When source and target field names differ, use @Mapping(source = "entityField", target = "dtoField").
Use MapStruct for any service with more than five entity/DTO pairs. The main advantage is not just less code — it is that a rename of a source field produces a compile-time error rather than a silent null in production. Treat the generated code as read-only; the mapper interface is your single source of truth.
Putting It All Together — the Service Layer
The service layer orchestrates: it calls the repository, passes the entity through the mapper, and returns the DTO. The controller stays thin:
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{hashId}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String hashId) {
return ResponseEntity.ok(orderService.findByHashId(hashId));
}
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
OrderResponse created = orderService.create(request);
URI location = URI.create("/orders/" + created.orderId());
return ResponseEntity.created(location).body(created);
}
}
@Service
@Transactional
public class OrderService {
private final OrderRepository repository;
private final OrderMapper mapper;
private final HashIdService hashIdService;
// constructor injection omitted for brevity
public OrderResponse findByHashId(String hashId) {
Long id = hashIdService.decode(hashId);
OrderEntity entity = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Order not found: " + hashId));
return mapper.toResponse(entity); // entity -> DTO inside the transaction
}
public OrderResponse create(CreateOrderRequest request) {
OrderEntity entity = mapper.toEntity(request);
OrderEntity saved = repository.save(entity);
return mapper.toResponse(saved);
}
}
The entity-to-DTO conversion happens inside the transaction boundary in the service method. This is important: if the mapper accesses a lazy collection, it will be within an active session and will not throw a LazyInitializationException.
Summary
The mapping layer is the guardian of your service contract. Keep entities strictly internal and define DTOs that represent the public API you are willing to maintain. Hand-written mappers give you full control for simple cases; MapStruct scales that approach to large codebases with compile-time safety. Never allow callers to set server-controlled fields, always annotate request DTOs with @JsonIgnoreProperties(ignoreUnknown = true), and treat every published DTO field as a versioned commitment. These habits prevent the silent, distributed breakages that are hardest to debug in a microservice system.