Week 4 Day 4 — Domain Exceptions and Translation
Goal
Today I want failures expressed as types my application owns, with a cause chain back to the library.
Main questions:
- What is a domain exception?
- What is exception translation?
- Where is the translation boundary?
- How do I log without double-logging?
- What must never happen in a
catch?
1. Domain exceptions
A domain exception names a business failure in the language of the service.
public class OrderNotFoundException extends RuntimeException {
private final long orderId;
public OrderNotFoundException(long orderId) {
super("Order not found: " + orderId);
this.orderId = orderId;
}
public long orderId() {
return orderId;
}
}
I prefer unchecked (RuntimeException) so OrderService methods stay free of throws. Each type is a mapping key for HTTP later: not found, conflict, unprocessable.
Keep the hierarchy small:
| Type | Meaning |
|---|---|
OrderNotFoundException | Missing aggregate |
DuplicateOrderException | Unique rule violated |
PaymentFailedException | Downstream payment |
IllegalArgumentException | Bad input I already validate |
I do not invent OrderServiceException as a checked umbrella that every method throws. That is throws Exception with extra steps.
Memory sentence:
A domain exception is a named business failure. It is usually unchecked so the happy path stays clean.
2. Exception translation
Libraries speak JDBC, HTTP clients, and files. The service speaks orders.
Translation means: catch at the edge of that library, throw a type the rest of the app understands, keep the cause.
public Order find(long id) {
try {
return jdbc.queryForObject(...);
} catch (EmptyResultDataAccessException e) {
throw new OrderNotFoundException(id);
}
}
EmptyResultDataAccessException is already Spring’s translation of “no row.” I may wrap further into OrderNotFoundException so the web layer does not depend on Spring JDBC types.
Spring’s SQLExceptionTranslator turns vendor SQLException into DataAccessException subtypes: DuplicateKeyException, DataIntegrityViolationException, CannotAcquireLockException. I map those to domain types in the persistence adapter, not in the controller.
SQLException → DataAccessException → DuplicateOrderException → HTTP 409
JDBC Spring my adapter web
3. Where the boundary is
Controller — maps exceptions to HTTP (Day 5). Does not catch JDBC.
Service — throws domain exceptions. May catch and translate if it calls a client.
Persistence — translates JDBC / JPA into domain or Spring DataAccessException.
HTTP client — translates timeouts / 5xx into PaymentFailedException.
If every layer catches, logs, wraps, and rethrows, I get a four-layer stack of wrappers and four log lines for one failure. Translate once, at the module edge, then propagate.
4. Logging
Rule I use:
- Log at the boundary that handles the failure (controller advice, listener).
- Do not log-and-rethrow in every layer.
- Use
warn/infofor expected business failures (not found) if the API is public;errorfor unexpected ones. - Include ids, not payloads with secrets.
// service — no log, just throw
throw new OrderNotFoundException(id);
// advice — one log if needed, then HTTP body
5. Spring connection
DataAccessExceptionexists so I never declarethrows SQLException.- JPA
NoResultExceptionvs Spring DataOptional: preferOptional+orElseThrowin my code. That is translation I control. - A persistence adapter (
OrderRepositoryImpl) is the right class to catchDataIntegrityViolationExceptionand throwDuplicateOrderException.
6. Common traps
Trap 1: Empty catch. The operation “succeeds.”
Trap 2: Catch, log, wrap, log, wrap. Duplicate logs and a useless onion.
Trap 3: Returning null or Optional.empty() for a failed payment. Absence is for missing rows I expected might be missing. A failed charge is an exception or a result type I model on purpose — not a silent null.
Trap 4: One AppException for everything. @ControllerAdvice then cannot choose 404 vs 409 vs 500.
Trap 5: Putting SQL state codes in the controller.
Practice Questions and Answers
Question 1
What is exception translation?
Answer:
I catch a low-level type (SQLException, IOException, RestClientException) at an adapter boundary and throw a type the rest of the application understands, with the original as the cause. Callers above that boundary never see JDBC.
Question 2
Why are domain exceptions usually unchecked?
Answer:
The service cannot recover locally. Every method would otherwise declare throws. Unchecked lets the failure propagate to the HTTP (or messaging) boundary, where I map it once.
Question 3
Where should I convert DuplicateKeyException into DuplicateOrderException?
Answer:
In the persistence adapter that called the database, not in the REST controller. The web layer should not import Spring JDBC exception types if I can avoid it.
Question 4
Why is log-and-rethrow in three layers a problem?
Answer:
The same failure appears three times in the log with three stack traces. Operators cannot tell which line is the root. Translate once, log once at the handler.
Question 5
When is returning Optional better than throwing NotFound?
Answer:
When absence is a normal outcome the caller should branch on: findByEmail in a registration flow. When the use case required the order to exist (getOrderForCheckout), throw OrderNotFoundException. The type of the method is the contract.
Memory sentences
Name the business failure. Keep the library exception as the cause.
Translate once at the adapter edge. Log once at the handler.
nullis not an error channel.