Centralized Exception Handling and Error Management

How ModusFocus handles errors and keeps failures easier to understand.

When I first think about exception handling, it is tempting to put try/catch blocks everywhere. In a service-oriented application, that usually creates the opposite of clarity: duplicate logs, inconsistent HTTP responses, hidden root causes, and sensitive details leaking to clients.

The approach I tried adopting in ModusFocus is built around one rule: Throw low, translate high, log once, return safely.

Understanding the Problem

A database timeout, a Mongo duplicate-key error, an OCI Queue outage, and a coding defect are technically all exceptions. But they should not be treated the same way.

Clients need a stable answer:

{
"code": "DATABASE_UNAVAILABLE",
"message": "The service is temporarily unavailable. Please try again later.",
"errorId": "..."
}

Operation Support Team (Operators) need different information: the real exception type, cause chain, dependency, trace, safe database metadata, and correlation ID.

The key design decision was to separate those two audiences.

How the design works

At the application level, known failures are represented by AppException.

An AppException contains:

  • a stable Error Code;
  • a safe client-facing message;
  • the original cause;
  • optional operation and dependency context;
  • safe diagnostic context.

A service catches a low-level exception only when it can add meaning. For example, a Mongo or JDBC failure can become a database failure with context such as operation=workspace.create and dependency=mongodb.

It must preserve the original exception as the cause. That matters because the final handler may need to inspect deep causes to distinguish a timeout from a connectivity failure.

Why one terminal handler matters

AppExceptionHandler is the sole terminal owner for HTTP failures.

It performs five jobs in one place:

  1. Resolves the final error code from the full cause chain.
  2. Maps that code to the HTTP status.
  3. Produces a masked client response with an errorId.
  4. Emits exactly one structured terminal log.
  5. Records metrics and marks the active OpenTelemetry span as failed.

This prevents the classic “same failure logged three times” problem: once in a repository, once in a service, and once in the controller.

Handling expected and unexpected failures

Expected failures—validation, not-found, conflict, forbidden, known dependency failures—are thrown as AppException.

Unexpected runtime exceptions go to UnexpectedExceptionHandler. It wraps the original exception as INTERNAL_ERROR and delegates to the same central handler.

That delegation is important. Even an unexpected failure gets the same safe response, one correlation ID, one log, one metric path, and one trace policy.

The fallback deliberately handles Exception, not Throwable. Fatal JVM Error types are not application-level HTTP failures and should remain under process/runtime policy.

Cause-chain classification

Think of it like a hospital.

The service layer is the doctor who examines the patient. The exception handler is the reception desk that gives the final, standard report.

Something fails
   ↓
Service understands where it failed
   ↓
AppExceptionHandler gives the final API answer

Example: saving a workspace fail

MongoDB says: “I timed out”

The service knows the business context:

“I was trying to create a workspace, and MongoDB failed.”

So it preserves the original Mongo exception and adds that context

operation: workspace.create
dependency: mongodb

It does not decide the HTTP response.

Then AppExceptionHandler receives the full story:

AppException
  “Unable to create workspace”
  operation = workspace.create
  dependency = mongodb
  cause = MongoTimeoutException

The handler makes the final decision:

MongoTimeoutException
→ DATABASE_TIMEOUT
→ HTTP 503
→ safe message for the client
→ one log + metrics + trace update

The simplest way to remember it:

Services explain “what I was doing when it failed.”
The central handler decides “what this failure means to the client and system.”

Thumb Rule

We do not ask every service to understand HTTP error policy. Services only retain the original technical failure and add meaningful business context. The centralized AppExceptionHandler then reads the complete cause chain and applies one consistent rule for error codes, HTTP status, safe client messages, logging, metrics, and tracing.

Privacy and observability can coexist

The client never needs raw SQL, a database hostname, an OCI response, token data, or a stack trace.

But hiding everything from clients does not mean hiding everything from operators.

The terminal handler writes structured fields such as error code, HTTP status, operation, dependency, root exception type, cause types, and errorId. It uses sanitized diagnostics so traces and logs retain exception structure and stack locations without copying unsafe exception messages.

Security principle we adopted

Preserve failure evidence internally; expose only safe recovery information externally.

HTTP services and background workers need different boundaries

HTTP services have a natural terminal boundary: the exception handler.

Queue consumers do not. For them, the poller is the terminal boundary. It must own logging, trace status, retry behavior, acknowledgement, and eventual dead-letter decisions.

The lesson is not “centralize every error into one class.” The lesson is:

Every execution model needs one clear owner for a terminal failure.

The takeaway

Centralized exception handling is not about catching more exceptions. It is about making every failure predictable:

  • predictable for clients;
  • diagnosable for operators;
  • safe for security;
  • consistent across services;
  • testable over time.

The best result is not fewer failures. It is failures that tell the right story, to the right audience, exactly once.

Leave a Reply