Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Beginner7 min readUpdated
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, @PathVariable and friends have required and defaultValue — use them instead of null checks.
  • ResponseEntity is for when status or headers matter; otherwise return the object.
  • Return DTOs, never entities — the contract must not follow the schema.
  • A HandlerMethodArgumentResolver removes repeated extraction from every method signature.

Parameter binding

OrderController.java
@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:

DateParameters.java
@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:

OrderFilter.java
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:

CurrentTenantResolver.java
@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

consumes is matched against Content-Type on the way in; produces against Accept on the way out.
ContentNegotiation.java
@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:

application.yml
spring:
  mvc:
    contentnegotiation:
      favor-parameter: false

Keeping 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.

ThinController.java
// 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?
A DTO, always. Returning a JPA entity couples your API contract to your schema, leaks fields you did not mean to expose, and can trigger lazy-loading queries during serialisation. A record DTO built inside the transaction costs a few lines and removes all three problems.
When do I need ResponseEntity instead of returning the object?
When you need to control the status code or the headers — a 201 with Location, a 204, a conditional 304, a Retry-After. If the answer is always 200 with a body, returning the object directly is cleaner and reads better.
How do I get the authenticated user into a controller method?
Inject Authentication or @AuthenticationPrincipal directly as a parameter. For anything more elaborate, write a HandlerMethodArgumentResolver so every controller gets the same resolved object without repeating the extraction logic.

Related tutorials