
🤳 You can read this article to learn more about the project I’m referencing on this post.
What is exception handling?
Exception handling is the mechanism a program uses to react when something goes wrong at runtime, a failed payment call, a missing record, a network timeout, instead of letting the whole application crash.
- Runtime is the period while a program is actually executing, as opposed to when it is being written or compiled, so a runtime error is one that only shows up once the code is running with real data.
- Failed payment call: When order-service calls
paymentServiceClient.processPayment(orderId, amount)and the payment provider rejects the card, that call throws aPaymentFailedExceptionright in the middle of the order saga. - Missing record: When
OrderSagaOrchestratorlooks up an order byorderIdafter a client sends an invalid or already deleted ID, the repository returns nothing and the code throws something like anOrderNotFoundException. - Network timeout: When order-service sends a request to the payment service but the response never arrives within the configured limit, the HTTP client throws a timeout exception even though nothing is technically wrong with the payment logic itself.
In Java, this means wrapping risky code in a try block and deciding, inside a catch block, how the program should recover: retry, roll back, notify someone, or simply log the failure and move on.

The important part that many developers overlook is this
Catching an exception is not the same as handling it well.
Where the exception ends up, whether it silently disappears, gets written to a log file, or turns into an HTTP error response, depends entirely on what you do after you catch it.
Let's trace that journey step by step, using order-service from the LogiBox project as a concrete example.
Step 1: an exception with nowhere to go
Imagine order-service's OrderSagaOrchestrator calls out to a payment client, and that call fails.
public void someMethod() {
throw new PaymentFailedException("결제 실패");
// if nobody catches this...
}If nothing in the call chain catches PaymentFailedException, the JVM eventually gives up looking for a handler and prints a full stack trace straight to the console:
Exception in thread "main" com.example.PaymentFailedException: 결제 실패
at com.example.PaymentService.processPayment(PaymentService.java:23)
at com.example.OrderSagaOrchestrator.executeOrderSaga(OrderSagaOrchestrator.java:15)
at com.example.Application.main(Application.java:10)This is Java's default behavior.
It is useful for debugging on your own machine, but it is not a strategy. In a saga-based flow like order-service's order creation process, an unhandled exception here could mean a payment step fails while earlier steps, like reserving inventory, already succeeded, leaving the order in an inconsistent state.
Step 2: catching it, but saying nothing
The next step order-service takes is wrapping the payment call in a try catch block:
try {
paymentServiceClient.processPayment(orderId, amount);
} catch (PaymentFailedException e) {
compensateOrder(orderId); // quietly handled
}This is already better because the saga can trigger a compensating action, compensateOrder, to undo the earlier steps.
But notice what is missing: nothing is printed anywhere.
From the JVM's point of view, this is now a normal, successfully handled situation, so there is no automatic output at all. If you want visibility into how often this failure happens, you have to add it yourself.
Step 3: logging it on purpose
This is where order-service, and most production services, add an explicit logging call:
@Slf4j // Lombok generates the logger
@Service
public class OrderSagaOrchestrator {
public void executeOrderSaga(OrderRequest request) {
try {
paymentServiceClient.processPayment(orderId, amount);
} catch (PaymentFailedException e) {
log.error("결제 실패, orderId: {}", orderId, e); // now it's explicit
compensateOrder(orderId);
}
}
}Once log.error(...) runs, where that log line actually shows up depends on where order-service is deployed:
- Local development (running from an IDE) - The IDE’s console or terminal
- Deployed production server - Usually written to a log file, not shown in a terminal
- Cloud environment(AWS, Kubernetes, etc) - Shipped to a log aggregation system such as CloudWatch, the ELK stack, or Datadog
In other words, running order-service locally lets you watch failures scroll by in real time, but nobody is staring at a terminal on a production server all day.
That's why teams route logs into files or monitoring platforms and query them later when something needs investigating.
Step 4: turning it into an HTTP response
Since order-service exposes a REST API, there is one more layer to consider. When a request triggers PaymentFailedException, the client calling the API also needs to know something went wrong, separately from whatever gets logged on the server.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PaymentFailedException.class)
public ResponseEntity<ErrorResponse> handlePaymentFailed(PaymentFailedException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getMessage()));
}
}With this in place, a single failure in order-service now produces two distinct outcomes at the same time:
- On the server: whatever
log.error(...)recorded, for developers to review later. - On the client, a frontend, Postman, or another service, an HTTP 400 status along with a JSON error message they can act on.
Putting it all together
Exception is thrown
├─ Nobody catches it → stack trace printed automatically, program may crash
├─ Caught, nothing done → fails silently, hard to debug, a bad habit
├─ Caught, then log.error → recorded in a file, console, or monitoring system
└─ Web app, @ExceptionHandler → also returned to the client as an HTTP responseorder-service's own flow, catch the payment failure, log it explicitly, then compensate the saga, reflects the principle most teams settle on in practice: always catch with intent, and always leave a trace.
Swallowing an exception silently might make the code look clean today, but it removes the one clue you'll need when something breaks in production and nobody can explain why.

