Spring MVC converts a request body into an argument before a controller method runs. Bean Validation on a command DTO can reject missing or malformed fields, but it does not decide whether the caller may approve a case or whether the current case revision still matches. Keep input DTOs separate from persistence entities so clients cannot set server-owned status, reviewer ID, or audit fields through binding. An API error contract should distinguish malformed input, forbidden access, missing records, stale revisions, and unexpected server faults. ProblemDetail provides a structured error body, yet the service still needs deliberate exception mapping and safe field messages. Validation annotations are only one layer: cross-field rules, length budgets, content type, and business transition checks need explicit placement.
Spring Boot MVC Validation and Problem Contracts
Working case
The approval endpoint for permit 62 accepts operation ID, expected revision 5, and reason. A browser sends an empty reason and receives a validation error. Another sends reviewerId=81 and status=approved alongside valid fields; those extra fields must not become entity state. Reviewer 47 then submits revision 5 after another reviewer already committed revision 6. The shape is valid, but the transition is stale; returning the same 400 as a blank reason makes client recovery unclear. A service maps the latter to a conflict response with a safe code and current revision hint, while a forbidden reviewer receives no private case details. An internal database failure returns a server error and a trace ID, never an exception stack in JSON.
Implementation boundary
record ApprovePermitCommand(
@NotBlank String operationId,
@Positive long expectedRevision,
@NotBlank @Size(max = 420) String reason
) { }
@RestController
class PermitApprovalController {
private final PermitApprovalService approvals;
PermitApprovalController(PermitApprovalService approvals) {
this.approvals = approvals;
}
@PostMapping("/permits/{permitId}/approval")
ApprovalView approve(@PathVariable Long permitId,
@Valid @RequestBody ApprovePermitCommand command) {
return approvals.approve(permitId, command);
}
}Define an immutable approval command with only client-owned fields. Add field constraints for nonblank bounded text and required revision, and validate the request body at the controller boundary. Configure a central exception mapper so validation errors use a predictable field-error structure with a maximum count and safe messages. Map stale revisions, missing records, forbidden operations, and unexpected failures explicitly; keep the ProblemDetail status consistent with the HTTP status. Avoid exposing rejected raw input, database table names, or security internals in the response. A content type policy should reject unsupported payloads before binding. If a browser uses cookies, keep CSRF checks independent from field validation. Check that a client can use a stable error code rather than parsing English prose.
Cost and boundaries
DTO binding and validation cost work proportional to the fields and nested elements inspected. Bound a collection's length and nesting depth before expensive service work; a small DTO cannot protect a service that later traverses an unbounded related graph. Creating a ProblemDetail object is cheap, but collecting and serializing thousands of field errors can be expensive and can expose submitted values. Cap error counts and request body size. A distinct conflict response allows a client to refresh current state instead of retrying the same stale mutation repeatedly. Central mapping reduces inconsistent controller logic, but broad catch-all handlers can hide programming defects if they convert every exception into a client error. Measure invalid request rates, validation time, and conflict frequency.
Failure trace
Send null body, wrong content type, missing operation ID, negative revision, blank reason, an overlong reason, and an extra status field. Confirm none reach the approval service with unvalidated values. Produce nested invalid collections at the body-size limit and verify the response remains bounded. Race two approvals for the same expected revision and require one committed outcome and one conflict. Submit a valid DTO as an unassigned reviewer; permission must still fail. Simulate a database outage and assert the response is a server error with no SQL string or stack trace. Check that localized human messages can change without breaking the client, which should branch on a stable machine code.
Verification
- Only client-owned command fields appear in the request type.
- A stale revision has a distinct conflict outcome from malformed input.
- Unexpected faults do not disclose internal stack or SQL details.
Practice drill
Create a Spring MVC approval command record and a controller action for permit 62. Use validation on the command and a central mapper for input, forbidden, missing, stale, and internal failures. A request test should feed malformed bodies and assert both status and structured error code. Seed an existing revision 6 and send expected revision 5 to confirm the stale case is not misclassified as malformed input. Use a separate service test for transition permission, because annotations on a DTO do not protect case state. Review the serialized JSON and confirm no submitted status, reviewer ID, raw reason, or internal exception detail leaks into unexpected fields.
Decision note
Input shape, current-state permission, and conflict resolution are separate checks with separate client outcomes.
Common Mistakes
- Binding a request directly into a JPA entity.
- Treating Bean Validation as record permission or concurrency control.
- Returning a raw exception message as a public error body.
Related lessons
Spring Boot Web Service Boundaries; Spring Boot Security Chain and Object Permission; Spring Boot JPA Scope, Fetch, and Page Cost; Spring Boot Transaction Replay and Outbox Handoff; Spring MVC request validation: reject invalid commands before mutation; Spring MVC validation errors: one public ProblemDetail for two failure paths; Spring MVC ProblemDetail: stable errors without leaking internals; API Mutation and Failure Contracts.
Apply and check
Build Project: Spring Boot Permit Service Boundaries and review Web Development: Spring Boot service contracts.
