Spring REST Controllers Deep Dive
Everything a Spring controller can bind, how ResponseEntity builds responses properly, content negotiation, custom argument resolvers, and keeping controllers thin.
On this page
Spring's controller model resolves method arguments from the request and turns return values into responses. Knowing exactly what it can bind — and what belongs elsewhere — keeps controllers to a dozen readable lines.
Key Takeaways
- A controller's job is HTTP translation. Business logic belongs in a service.
@RequestParam,@PathVariableand friends haverequiredanddefaultValue— use them instead of null checks.ResponseEntityis for when status or headers matter; otherwise return the object.- Return DTOs, never entities — the contract must not follow the schema.
- A
HandlerMethodArgumentResolverremoves repeated extraction from every method signature.
Parameter binding
@RestController
@RequestMapping("/api/v1/orders")
@Validated
public class OrderController {
private final OrderService orders;
public OrderController(OrderService orders) {
this.orders = orders;
}
@GetMapping("/{id}")
public OrderResponse get(@PathVariable String id) {
return orders.find(id);
}
@GetMapping
public Page<OrderResponse> list(
@RequestParam(required = false) OrderStatus status,
@RequestParam(defaultValue = "false") boolean includeArchived,
@RequestHeader(value = "X-Tenant-Id", required = true) String tenant,
@CookieValue(value = "preferredCurrency", defaultValue = "EUR") String currency,
@PageableDefault(size = 20, sort = "createdAt",
direction = Sort.Direction.DESC) Pageable pageable) {
return orders.search(tenant, status, includeArchived, currency, pageable);
}
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request,
UriComponentsBuilder uriBuilder) {
OrderResponse created = orders.place(request);
// Build the Location from the current request rather than hard-coding
// a path that breaks the moment the context path changes.
URI location = uriBuilder.path("/api/v1/orders/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> cancel(@PathVariable String id) {
orders.cancel(id);
return ResponseEntity.noContent().build();
}
}Spring converts path and query values to the declared type automatically — enums, UUID,
LocalDate, numbers. A value that will not convert produces a MethodArgumentTypeMismatchException,
which your @ControllerAdvice should map to a 400 with a message naming the parameter, because
the default message is not something you want a client to read.
For date and time parameters, be explicit about the format:
@GetMapping("/report")
public Report report(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant until) {
return service.build(from, until);
}Grouping parameters
Once a method has more than four or five query parameters, bind them into an object. Spring populates it by property name, and you get validation for free:
public record OrderFilter(
OrderStatus status,
@PastOrPresent LocalDate createdAfter,
@Size(max = 100) String customerReference,
boolean includeArchived) { }
@GetMapping
public Page<OrderResponse> list(@Valid OrderFilter filter, Pageable pageable) {
return orders.search(filter, pageable);
}Note there is no annotation on filter. Spring treats a non-annotated complex parameter as a
command object and binds request parameters onto it.
Custom argument resolvers
Repeating the same extraction in twenty methods is a smell. Resolve it once:
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentTenant { }
@Component
public class CurrentTenantResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentTenant.class)
&& Tenant.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mav,
NativeWebRequest request, WebDataBinderFactory binder) {
String header = request.getHeader("X-Tenant-Id");
if (!StringUtils.hasText(header)) throw new MissingTenantException();
return tenantService.resolve(header);
}
}
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(currentTenantResolver);
}
}Controllers now declare @CurrentTenant Tenant tenant and the header handling lives in one place
that can be tested on its own.
Content negotiation
@PostMapping(
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = { MediaType.APPLICATION_JSON_VALUE, "application/vnd.acme.v2+json" })
public OrderResponse create(@RequestBody CreateOrderRequest request) { }Turn off the legacy path-extension strategy so /orders/42.json does not become a second URL for the
same resource — that duplication is a real SEO and caching problem for public APIs:
spring:
mvc:
contentnegotiation:
favor-parameter: falseKeeping controllers thin
A controller should translate HTTP into a service call and back. Everything else — orchestration, rules, transactions — belongs behind it. The test is whether the same operation could be triggered by a message consumer or a scheduled job without moving code.
// Good: HTTP concerns only.
@PostMapping("/{id}/shipments")
public ResponseEntity<ShipmentResponse> ship(@PathVariable String id,
@Valid @RequestBody ShipRequest request,
UriComponentsBuilder uri) {
ShipmentResponse shipment = shipping.dispatch(id, request);
return ResponseEntity
.created(uri.path("/api/v1/shipments/{sid}").buildAndExpand(shipment.id()).toUri())
.body(shipment);
}The mapping between domain objects and DTOs should happen inside the transactional service, not in
the controller. Doing it in the controller means either the transaction is still open during
serialisation — which is what open-in-view quietly does — or you get a LazyInitializationException
in production the first time somebody adds a lazy association.
Designing the DTO layer
The objection to DTOs is always the same: they are duplication. In practice they are the opposite of duplication — they are the thing that stops your database schema and your published API from being the same artefact, which is what lets either change without breaking the other.
Separate the request and response shapes rather than reusing one class for both. They differ more
than you expect: a create request has no id, no createdAt and no computed totals, while the
response has all three and no password field. A single shared class ends up with half its fields
nullable and a comment explaining when each one applies.
Prefer records. They are immutable, they generate equals and hashCode, they read as a
declaration of shape rather than a bag of setters, and Jackson supports them natively. For fields
that must be genuinely optional in a PATCH — where "absent" and "explicitly null" mean different
things — use JsonNullable from the OpenAPI Jackson module, or accept a Map and apply changes
key by key. Trying to express three-state optionality with plain nulls does not work.
Be deliberate about what never leaves the building. Internal identifiers, soft-delete flags, audit
columns, and anything derived from another customer's data should have no representation in the
response type at all. Relying on @JsonIgnore to hide fields on an entity works until somebody adds
a field and forgets the annotation; a DTO that simply does not declare the field cannot leak it.
Where mapping should happen
Map inside the transactional service method, where the persistence context is open and lazy
associations can still be resolved. Mapping in the controller either forces open-in-view to stay
enabled — which quietly issues database queries during JSON serialisation, outside any transaction —
or fails with a lazy-initialisation error the first time somebody adds an association.
For small objects, write the mapping by hand as a static factory on the DTO. It is a few lines, it is explicit, and the compiler tells you when a field is missing. For large object graphs, MapStruct generates the same code at build time with no reflection cost. Avoid reflection-based mappers that match fields by name at runtime: they turn a rename into a silent null instead of a compile error.
Response envelopes
Some teams wrap every response in a { "data": ..., "meta": ... } envelope. It has one real benefit
— a consistent place for metadata — and two real costs: every client must unwrap, and HTTP already
provides status, headers and links for exactly that purpose.
If you do adopt one, apply it uniformly with a ResponseBodyAdvice rather than by hand in each
method, and make sure errors use the same envelope. A half-applied envelope is worse than none.
What to take away
Bind what the framework can bind, group parameters into objects once there are several, and resolve
recurring context with an argument resolver. Return DTOs mapped inside the transaction, use
ResponseEntity when status or headers matter, and keep everything that is not HTTP out of the
controller.
Frequently Asked Questions
Should a controller return the entity or a DTO?
When do I need ResponseEntity instead of returning the object?
How do I get the authenticated user into a controller method?
Related tutorials
- REST API Design PrinciplesThe decisions that make an API pleasant to consume: resource naming, method semantics and idempotency, choosing the right status code, HATEOAS, and the Richardson maturity model.
- API Documentation with OpenAPIGenerating documentation people actually use: springdoc-openapi setup, annotations worth adding, grouping large APIs, documenting errors and auth, and the API-first workflow.
- API Versioning StrategiesComparing URI, header, query and media-type versioning honestly, deciding what counts as breaking, running two versions at once, and retiring one without breaking clients.
- Pagination, Filtering & SortingPagination that stays fast at depth: why OFFSET degrades, keyset and cursor pagination, dynamic filtering with Specifications, safe sorting, and the RFC 8288 Link header.