Checked vs Unchecked Exceptions in Modern Microservices: Why Try-Catch Everywhere Is an Anti-Pattern

Page content

Exception handling is a fundamental part of application design, yet many systems — especially legacy enterprise codebases — still rely heavily on checked exceptions and extensive try-catch blocks across every layer.

In modern microservices architectures, this approach often causes more harm than good.

Let’s explore:

  • The difference between checked and unchecked exceptions
  • Why checked exceptions don’t scale well in distributed systems
  • Why catching exceptions everywhere is an anti-pattern
  • The modern Spring Boot approach to error handling

Checked vs Unchecked Exceptions in Java

Checked Exceptions

Checked exceptions must be explicitly handled or declared:

public void process() throws IOException {
}

Typical examples:

  • IOException
  • SQLException

They force developers to use try-catch or add throws everywhere.


Unchecked Exceptions (RuntimeException)

Unchecked exceptions extend RuntimeException:

public class PaymentFailedException extends RuntimeException {
}

They don’t require explicit handling at each call site.


Why Checked Exceptions Worked in Monoliths

In older monolithic systems:

  • Most failures were local (DB, file system)
  • Call stacks were short
  • Recovery logic was sometimes possible

Checked exceptions helped enforce local handling.

But this world has changed.


Why Checked Exceptions Are a Poor Fit for Microservices

In distributed systems:

❌ Failures often come from remote services
❌ Recovery usually requires orchestration, not local handling
❌ Call chains span multiple services

Forcing each layer to handle technical failures leads to:

  • Boilerplate try-catch blocks
  • Duplicate logging
  • Artificial exception wrapping
  • Hard-to-read code

Example of a common anti-pattern:

try {
    repository.save(entity);
} catch (SQLException e) {
    log.error("DB error", e);
    throw new ServiceException(e);
}

Repeated again in upper layers.


The Try-Catch Everywhere Anti-Pattern

Many enterprise systems look like this:

try {
    service.process();
} catch (Exception e) {
    log.error("Error occurred", e);
    throw e;
}

in controllers, services, repositories, clients — everywhere.

Problems:

  • Same exception logged multiple times
  • Logs filled with noise
  • Hard to identify real failure point
  • Bloated code

This adds complexity without improving reliability.


The Modern Spring Boot Approach

1️⃣ Prefer Unchecked Exceptions

Use RuntimeException for:

  • Business errors
  • Technical failures
  • Integration issues

Example:

public class OrderCreationException extends RuntimeException {
    public OrderCreationException(String message) {
        super(message);
    }
}

2️⃣ Let Exceptions Propagate Naturally

Avoid catching unless you can:

✔ add meaningful context
✔ transform to a domain-specific exception

Bad:

catch (Exception e) {
    throw e;
}

Good:

catch (PaymentClientException e) {
    throw new PaymentFailedException(orderId, e);
}

3️⃣ Handle Centrally with @ControllerAdvice

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderCreationException.class)
    public ResponseEntity<?> handleOrderError(OrderCreationException ex) {
        return ResponseEntity.badRequest().body(ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleUnexpected(Exception ex) {
        return ResponseEntity.status(500).body("Internal server error");
    }
}

Optionally log here or where business context exists.


When Does Catching Make Sense?

Use try-catch when:

✅ You can recover (retry, fallback)
✅ You need to translate low-level errors into domain errors
✅ You can enrich the exception with context

Example:

try {
    paymentClient.charge(order);
} catch (TimeoutException ex) {
    throw new PaymentServiceUnavailableException(order.getId(), ex);
}

Logging and Exceptions: A Clean Strategy

A good rule:

Either handle the exception or let it propagate — don’t log and rethrow everywhere.

Recommended:

  • Log once with context
  • Use centralized handling

Benefits of the Modern Approach

✔ Cleaner code
✔ Less boilerplate
✔ Better observability
✔ Easier maintenance
✔ Better fit for distributed systems


Final Thoughts

Checked exceptions and heavy try-catch usage belong mostly to legacy monolithic designs.

In modern Spring Boot microservices:

  • Prefer unchecked exceptions
  • Let them propagate
  • Handle centrally
  • Catch only when you add real value

This leads to simpler, more robust, and more maintainable systems.


Key Takeaways

  • Checked exceptions don’t scale well in microservices
  • Try-catch everywhere is an anti-pattern
  • Use unchecked exceptions for most failures
  • Centralize exception handling
  • Catch only when necessary