Problem Detail Error Responses

A function reports an error by throwing. An escalation handler turns the exception into an HTTP response. This tutorial gives those responses a consistent, structured shape: RFC 7807 Problem Detail, served as application/problem+json.

Three handlers cover three kinds of error. A domain exception becomes a 404. A framework validation failure becomes a 400 with a field-by-field breakdown. Anything else becomes a 500 that does not leak internal detail. Registering them at office level means every endpoint gets the same error contract with no per-endpoint wiring.

Tutorial Source

Problem Detail

Spring's ProblemDetail is the RFC 7807 model. It carries type, title, status, detail, and instance, and it allows extra properties. A handler builds one and sends it in a ResponseEntity. OfficeFloor serialises it as application/problem+json.

A domain exception: 404

The read endpoint throws NotFoundException when the article is absent. The endpoints here are kept as single functions so the focus stays on the responses.

/**
 * Reads an article, or throws {@link NotFoundException} when it is absent.
 * The endpoints here are kept as single functions so the focus stays on the
 * error responses. The escalation handlers turn the thrown exceptions into
 * Problem Detail responses.
 */
public class GetArticle {

	public void service(@PathVariable(name = "id") Long id,
			ArticleRepository repository,
			ObjectResponse<ArticleResponse> response) throws NotFoundException {
		Article article = repository.findById(id)
				.orElseThrow(() -> new NotFoundException(id));
		response.send(new ArticleResponse(article.getId(), article.getTitle(), article.getContent()));
	}
}

A handler registered under officefloor/escalation/ maps the exception to a 404 Problem Detail:

handle:
  class: net.officefloor.tutorial.springrestproblemdetail.NotFoundExceptionHandler
/**
 * Maps a domain {@link NotFoundException} to a 404 Problem Detail (RFC 7807).
 * The response body is an {@code application/problem+json} document.
 */
public class NotFoundExceptionHandler {

	public void handle(@Parameter NotFoundException ex,
			ObjectResponse<ResponseEntity<ProblemDetail>> response) {
		ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
		detail.setType(URI.create("https://example.com/problems/not-found"));
		detail.setTitle("Article not found");
		detail.setDetail("No article exists with id " + ex.getId());
		detail.setProperty("timestamp", Instant.now());
		response.send(ResponseEntity.status(404).body(detail));
	}
}

GET /article/99999 then returns:

{
  "type": "https://example.com/problems/not-found",
  "title": "Article not found",
  "status": 404,
  "detail": "No article exists with id 99999",
  "timestamp": "2026-07-09T12:00:00Z"
}

A validation failure: 400 with field errors

The create endpoint validates the body with @Valid. A blank title makes the framework throw MethodArgumentNotValidException.

/**
 * Creates an article. A blank title fails {@code @Valid}, which the framework
 * reports as a MethodArgumentNotValidException, mapped to a 400 Problem Detail.
 */
@Validated
public class CreateArticle {

	public void service(@Valid @RequestBody ArticleRequest request,
			ArticleRepository repository,
			ObjectResponse<ResponseEntity<ArticleResponse>> response) {
		Article saved = repository.save(new Article(null, request.getTitle(), request.getContent()));
		response.send(ResponseEntity.status(201)
				.body(new ArticleResponse(saved.getId(), saved.getTitle(), saved.getContent())));
	}
}

Its handler maps to a 400 and adds a field-by-field breakdown, so a client can show which fields failed:

handle:
  class: net.officefloor.tutorial.springrestproblemdetail.ValidationExceptionHandler
/**
 * Maps a framework {@link MethodArgumentNotValidException} to a 400 Problem
 * Detail, adding a field-by-field breakdown so clients can show which fields
 * failed and why.
 */
public class ValidationExceptionHandler {

	public void handle(@Parameter MethodArgumentNotValidException ex,
			ObjectResponse<ResponseEntity<ProblemDetail>> response) {
		Map<String, String> errors = new LinkedHashMap<>();
		for (FieldError error : ex.getBindingResult().getFieldErrors()) {
			errors.put(error.getField(), error.getDefaultMessage());
		}

		ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
		detail.setType(URI.create("https://example.com/problems/validation"));
		detail.setTitle("Request validation failed");
		detail.setDetail("The request has " + errors.size() + " invalid field(s)");
		detail.setProperty("timestamp", Instant.now());
		detail.setProperty("errors", errors);
		response.send(ResponseEntity.status(400).body(detail));
	}
}

POST /article with a blank title returns:

{
  "type": "https://example.com/problems/validation",
  "title": "Request validation failed",
  "status": 400,
  "detail": "The request has 1 invalid field(s)",
  "timestamp": "2026-07-09T12:00:00Z",
  "errors": { "title": "title is required" }
}

Anything else: 500 without leaking

A handler for Exception is the catch-all. It maps any exception with no more specific handler to a 500, with a generic message so internal detail never reaches the client.

/**
 * Throws an unexpected error, to show the catch-all handler. Any exception with
 * no more specific handler maps to a 500 Problem Detail that does not leak the
 * internal message.
 */
public class Boom {

	public void service(ObjectResponse<ArticleResponse> response) {
		throw new IllegalStateException("database connection pool exhausted");
	}
}
handle:
  class: net.officefloor.tutorial.springrestproblemdetail.GeneralExceptionHandler
/**
 * Catch-all handler. Any exception with no more specific handler maps to a 500
 * Problem Detail. It uses a generic message so internal detail is never leaked
 * to the client.
 */
public class GeneralExceptionHandler {

	public void handle(@Parameter Exception ex,
			ObjectResponse<ResponseEntity<ProblemDetail>> response) {
		ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
		detail.setType(URI.create("about:blank"));
		detail.setTitle("Internal server error");
		detail.setDetail("An unexpected error occurred while processing the request");
		detail.setProperty("timestamp", Instant.now());
		response.send(ResponseEntity.status(500).body(detail));
	}
}

GET /boom throws an IllegalStateException with an internal message, but the client only sees:

{
  "type": "about:blank",
  "title": "Internal server error",
  "status": 500,
  "detail": "An unexpected error occurred while processing the request",
  "timestamp": "2026-07-09T12:00:00Z"
}

Most specific wins

All three handlers are active at once. Escalation is most specific first. NotFoundException and MethodArgumentNotValidException each have their own handler, so they are used ahead of the Exception catch-all. The catch-all only runs for exceptions nothing else claims. See the Exception tutorial for escalation routing in depth.

Testing

The test drives all three error responses and checks the status, the application/problem+json content type, and the body fields.

@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestProblemDetailTest {

	@Autowired
	private MockMvc mvc;

	@Autowired
	private ObjectMapper mapper;

	@Autowired
	private ArticleRepository repository;

	@AfterEach
	public void clearData() {
		repository.deleteAll();
	}

	// Domain exception -> 404 Problem Detail (application/problem+json)
	@Test
	public void notFoundProblem() throws Exception {
		mvc.perform(get("/article/99999").accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isNotFound())
				.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
				.andExpect(jsonPath("$.title").value("Article not found"))
				.andExpect(jsonPath("$.status").value(404))
				.andExpect(jsonPath("$.detail").value("No article exists with id 99999"))
				.andExpect(jsonPath("$.timestamp").exists());
	}

	// Framework validation exception -> 400 Problem Detail with field errors
	@Test
	public void validationProblem() throws Exception {
		mvc.perform(post("/article")
				.accept(MediaType.APPLICATION_JSON)
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(new ArticleRequest("", "body"))))
				.andExpect(status().isBadRequest())
				.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
				.andExpect(jsonPath("$.title").value("Request validation failed"))
				.andExpect(jsonPath("$.status").value(400))
				.andExpect(jsonPath("$.errors.title").value("title is required"));
	}

	// Anything else -> 500 Problem Detail, no internal leak
	@Test
	public void unexpectedErrorProblem() throws Exception {
		mvc.perform(get("/boom").accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isInternalServerError())
				.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
				.andExpect(jsonPath("$.title").value("Internal server error"))
				.andExpect(jsonPath("$.status").value(500))
				.andExpect(jsonPath("$.detail").value("An unexpected error occurred while processing the request"));
	}

	// Happy path still works
	@Test
	public void createSucceeds() throws Exception {
		mvc.perform(post("/article")
				.accept(MediaType.APPLICATION_JSON)
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(new ArticleRequest("Title", "body"))))
				.andExpect(status().isCreated())
				.andExpect(jsonPath("$.title").value("Title"));
	}
}

Next

The Testing Functions tutorial unit tests each function in isolation, then covers the wiring with one integration test.