Definition
- Throw early = validate inputs at the method entry point and fail immediately with a clear, specific exception. Do not let bad data travel through 5 method calls before failing deep in the stack.
- Catch late = do not catch in the service or repository layer. Let exceptions propagate up to the boundary layer (controller, message listener, batch scheduler) that has enough context to decide the response.
- Services throw domain exceptions. Controllers catch via @ControllerAdvice and map to HTTP responses. Kafka listeners catch and send to DLQ. Batch processors catch, log, and skip the record.
Throw early: the teller at National Bank of India checks Priya's withdrawal form immediately. "Amount is negative? Reject now." Do not pass the form to the vault, the accountant, and the manager, only for the manager to say "this form is wrong." Catch late: the teller does not decide what to tell Priya about a system error. The branch manager (controller) decides: "Tell her we are experiencing technical difficulties."
Understanding the Concepts
What does throw early mean?
Throw early means validating inputs at the service entry point and failing immediately with a clear exception. This produces clean stack traces and meaningful error messages.
package com.bank.service;
import java.math.BigDecimal;
import java.util.Objects;
import java.util.Optional;
public class ThrowEarlyDemo {
// WRONG: throw late. Null travels deep before failing.
public static void wrongThrowLate(String tradeId) {
// tradeId could be null. No check here.
String normalized = tradeId.trim(); // NPE here if null!
System.out.println("Normalized: " + normalized);
}
// CORRECT: throw early. Validate at entry.
public static void correctThrowEarly(String tradeId) {
Objects.requireNonNull(tradeId, "tradeId must not be null");
if (tradeId.isBlank()) {
throw new IllegalArgumentException("tradeId must not be blank");
}
String normalized = tradeId.trim();
System.out.println("Normalized: " + normalized);
}
public static void main(String[] args) {
try {
correctThrowEarly(" TRD-001 ");
} catch (Exception e) {
System.out.println("Error: " + e.getMessage());
}
try {
correctThrowEarly(null);
} catch (NullPointerException e) {
System.out.println("Caught early: " + e.getMessage());
}
}
}
Normalized: TRD-001
Caught early: tradeId must not be null
What does catch late mean?
Catch late means do not catch in the service layer. Let exceptions propagate to the boundary (controller, listener) that has the context to decide the response.
package com.bank.demo;
public class CatchLateDemo {
// WRONG: service catches and returns null
static String wrongCatchEarly(String tradeId) {
try {
if (tradeId.equals("BAD")) throw new RuntimeException("Not found");
return "SETTLED";
} catch (RuntimeException e) {
return null; // Caller has NO idea why it failed
}
}
// CORRECT: service throws, boundary catches
static String correctThrowUp(String tradeId) {
if (tradeId.equals("BAD")) {
throw new RuntimeException("Trade not found: " + tradeId);
}
return "SETTLED";
}
public static void main(String[] args) {
// Wrong: controller gets null, no context
String result1 = wrongCatchEarly("BAD");
System.out.println("Wrong: result is " + result1 + " (why? no idea)\n");
// Correct: controller catches with full context
try {
String result2 = correctThrowUp("BAD");
} catch (RuntimeException e) {
System.out.println("Correct: controller catches - " + e.getMessage());
System.out.println("Can return: 404 NOT_FOUND with meaningful message");
}
}
}
Wrong: result is null (why? no idea)
Correct: controller catches - Trade not found: BAD
Can return: 404 NOT_FOUND with meaningful message
How does this create clean layering?
Each layer has a clear responsibility: validate, throw, or catch.
package com.bank.demo;
public class CleanLayeringDemo {
public static void main(String[] args) {
System.out.println("Clean Exception Layering:");
System.out.println();
System.out.println(" Repository layer:");
System.out.println(" - Throws DataAccessException (Spring wraps JDBC)");
System.out.println(" - Does NOT catch");
System.out.println();
System.out.println(" Service layer:");
System.out.println(" - Validates early (throw IllegalArgumentException)");
System.out.println(" - Wraps in domain exception (SettlementException)");
System.out.println(" - Does NOT catch and return null");
System.out.println();
System.out.println(" Controller layer:");
System.out.println(" - ZERO try-catch");
System.out.println(" - @ControllerAdvice catches late");
System.out.println(" - Maps to HTTP: 404, 400, 500");
}
}
Clean Exception Layering:
Repository layer:
- Throws DataAccessException (Spring wraps JDBC)
- Does NOT catch
Service layer:
- Validates early (throw IllegalArgumentException)
- Wraps in domain exception (SettlementException)
- Does NOT catch and return null
Controller layer:
- ZERO try-catch
- @ControllerAdvice catches late
- Maps to HTTP: 404, 400, 500
What should a senior engineer know?
- **Kafka/SQS listeners.** These are "catch late" boundaries. Catch Exception, decide: retry, send to DLQ, or skip with logging. - **Batch processors.** Catch per-record, log, accumulate errors, continue processing. Report all failures at the end. - **Optional.orElseThrow().** The modern "throw early" pattern: `repo.findById(id).orElseThrow(() -> new NotFoundException(id))`.
What are common interview follow-up questions?
- What is the anti-pattern of catching too early? (Service catches and returns null/error code. Controller has no context.)
- What is the anti-pattern of throwing too late? (Null propagates deep, NPE in the wrong place with misleading trace.)
- Where should you catch in a REST API? (In @ControllerAdvice, not in the controller method itself.)
Visual Overview
THROW EARLY: CATCH LATE:
┌────────────────────────┐ ┌────────────────────────┐
│ Service entry point │ │ Controller / Boundary │
│ if (id == null) │ │ @ControllerAdvice │
│ throw IAE("id null") │ │ catches domain exception│
│ if (!exists(id)) │ │ maps to HTTP response │
│ throw NotFound(id) │ │ 404 / 400 / 500 │
└────────────────────────┘ └────────────────────────┘
│ ▲
│ exception propagates │
└───────────────────────────────┘
Key Takeaway
"Throw early, catch late" is the foundational exception handling principle. Throw early means validating inputs at the service entry point and failing immediately with a clear, specific exception. Do not let a null tradeId travel through 5 methods before causing a NullPointerException deep in the repository. Use Objects.requireNonNull() and Optional.orElseThrow() at the boundary.
Catch late means not catching in the service or repository layer. Let exceptions propagate to the boundary that has the context to decide the response: the REST controller maps to HTTP status codes via @ControllerAdvice, the Kafka listener sends to a dead letter queue, and the batch scheduler logs and skips the record.
This creates clean layering at National Bank of India: the repository throws DataAccessException, the service validates early and wraps in domain exceptions, and the controller catches late via the global handler. Each layer has one job. The anti-pattern is catching too early (service catches and returns null) or throwing too late (null propagates deep with a misleading stack trace).
Remember one sentence: validate and throw at the service entry point, and catch at the boundary that has the context to decide the response.
---