DEV Community

Cover image for Junior devs catch exceptions. Senior devs prevent them. Here’s the difference.
<devtips/>
<devtips/>

Posted on

Junior devs catch exceptions. Senior devs prevent them. Here’s the difference.

Four patterns that replace most of the try-catch in your codebase and the mental model that makes the right choice obvious.

Every developer has written this code. You’re building something, you get a compiler warning, or maybe a runtime blow-up in testing, and the fastest fix feels obvious: wrap it. Slap a try-catch around it, log the message, return null, move on. Done. Safe.

Except it isn’t safe. You’ve just installed a smoke detector that beeps once and then goes quiet forever.

I’ve been inside codebases where try-catch was the default answer to every uncomfortable question the compiler asked. Controllers wrapped in it. Services wrapped in it. Utility methods wrapped in it. The team described it as “defensive programming.” What it actually was: a distributed system of silence. Errors happened. Nobody knew. The first sign of a problem was usually a customer email.

There’s a reason tutorials teach try-catch first. It works, in the narrowest possible sense. The app doesn’t crash. The stack trace disappears. The demo runs clean. What the tutorial doesn’t show you is the 3am incident six months later when a payment silently failed for two hundred users and your logs say "Something went wrong: null".

Senior devs don’t write more try-catch than juniors. They write significantly less. Not because they’re reckless, but because they’ve learned to ask a different question before reaching for it: can I stop this from going wrong in the first place?

This article is about the four patterns that replace most of the try-catch you’re writing. Each one handles a real category of failure invalid input, unclear errors, duplicated handling logic, and expected outcomes that aren’t actually exceptional. Together they form a mental model that changes how you read code, not just how you write it.

TL;DR: try-catch is a reactive tool. Most of the time, the right move is to prevent, name, centralize, or model the failure not catch it in silence.

Why junior devs default to try-catch

Here’s the thing: defaulting to try-catch isn’t stupidity. It’s pattern recognition from incomplete data.

You learn Java, Python, or whatever backend language the bootcamp or degree threw at you. The first exception handling example looks like this: something might fail, so you wrap it, you catch it, you print the error, you move on. The tutorial works. You internalize the shape of the solution. From that point forward, any time the compiler complains or the runtime blows up, your brain pattern-matches straight to the familiar shape.

Stack Overflow reinforces it. You search for your error, the accepted answer has a try-catch, it has 847 upvotes, someone accepted it in 2014, and it fixes your immediate problem. You copy it. You ship it. Repeat a few hundred times across a year of coding and you’ve built a reflex.

The problem isn’t the reflex. The problem is what it actually does to your codebase.

Every time you wrap code in try-catch, you’re making an implicit declaration: I don’t know what’s going to go wrong here, so I’ll just intercept whatever happens and deal with it. That sounds humble. It isn’t. It’s actually abdication. You’ve handed the decision to runtime and told it to figure things out while you look away.

Think of it this way: try-catch is a bucket under a leaky pipe. It catches the drip. It keeps the floor dry for now. But the pipe is still leaking, the water is still going somewhere it shouldn’t, and the leak is getting slightly worse every week. The senior move is to call a plumber. The junior move is to empty the bucket on a cron job and call it fixed.

Senior developers ask a different question before they reach for try-catch:

should this be able to fail at all?

If invalid input causes the failure is the input even allowed in?

If a null reference causes the crash why is null a valid state here?

The goal isn’t to catch the explosion. It’s to make the explosion impossible, or at least to make it impossible to ignore.

That reframe is where the four patterns come from. Each one answers a version of that question for a different category of failure.

Pattern 1: validate first, catch never

The single most common reason junior devs reach for try-catch is bad input. A null slips through. A string arrives where a number was expected. An email field is missing. Something downstream blows up, the catch block fires, and the problem disappears into a log message nobody reads.

Here’s the thing though: none of that is an exception handling problem. It’s a validation problem. The failure didn’t happen because the system encountered something genuinely unexpected it happened because you let garbage through the front door and were surprised when it broke something in the kitchen.

I spent three hours debugging a production issue once that turned out to be a null email field on a user registration request. Three hours. The stack trace was unhelpful, the catch block had swallowed the context, and the only log entry was something like "User creation failed". The fix itself took forty seconds once I found it. The try-catch didn't protect anything it just made the debugging slower and more painful.

The senior pattern here is straightforward: validate at the boundary. Nothing invalid gets past the entry point, so nothing downstream needs to defend against it.

In a Spring application this looks like replacing a defensive catch block with a @Valid annotation and letting the framework enforce the contract:

// what fear-based programming looks like
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody UserRequest request) {
try {
User user = userService.create(request);
return ResponseEntity.ok(user);
} catch (NullPointerException e) {
return ResponseEntity.badRequest().body(null);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(null);
}
}
// what confidence looks like
@PostMapping("/users")
public ResponseEntity<User> createUser(@valid @RequestBody UserRequest request) {
User user = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}

public class UserRequest {
@notblank(message = "Name is required")
private String name;
@Email(message = "Must be a valid email")
@NotNull(message = "Email is required")
private String email;
@min(value = 18, message = "Must be at least 18")
private int age;
}

The second version is shorter, cleaner, and more honest. The constraints live on the data object itself, which means any developer who opens UserRequest immediately knows the rules. There's no hidden catch block somewhere else in the call stack silently absorbing violations. If the input is bad, the framework rejects it immediately with a clear message before it touches a single line of your business logic.

This pattern isn’t Spring-specific either. Django has form and serializer validation. Express has middleware validators. Laravel has request classes. Every mature backend framework has a first-class answer to this problem, and that answer is never “catch the NullPointerException deeper in the stack.”

The rule worth internalizing: if you’re catching an exception caused by bad input, you don’t have an exception handling problem. You have a validation problem. Fix it upstream, not downstream.

Pattern 2: build a custom exception hierarchy

So you’ve validated your input. Clean data is flowing through the system. But things still go wrong because they always do. A record doesn’t exist. A business rule gets violated. An order is already processed. These are real failures, and they need real handling.

The junior response to this is catch (Exception e). Log the message. Return a 500. Hope the message is descriptive enough to debug later. It never is.

I’ve been in that 3am incident call. The alert fires, you pull up the logs, and staring back at you is: "Something failed: null". That's it. No context. No indication of which service, which record, which rule. Just a generic catch block somewhere in the stack that swallowed everything meaningful and handed you nothing. You start adding log statements, redeploying, waiting for it to happen again. It is not a fun way to spend a night.

The problem is that Exception is the nuclear option. It catches everything the failure you expected, the one you didn't, the one that's actually a bug in a completely different part of the system. When you catch everything, you lose the ability to distinguish between any of it.

Senior devs build a hierarchy instead. You start with a base application exception, then extend it into specific types that are self-describing:

// base — everything app-specific extends this
public class AppException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;

public AppException(String message, HttpStatus status, String errorCode) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
}
// specific types that speak for themselves
public class NotFoundException extends AppException {
public NotFoundException(String resource, Long id) {
super(resource + " not found with id: " + id,
HttpStatus.NOT_FOUND, "NOT_FOUND");
}
}
public class BusinessRuleViolatedException extends AppException {
public BusinessRuleViolatedException(String rule) {
super("Business rule violated: " + rule,
HttpStatus.CONFLICT, "BUSINESS_RULE_VIOLATED");
}
}

Now your service code becomes readable without a single try-catch in sight:

public Order processOrder(OrderRequest request) {
Order order = orderRepository.findById(request.getOrderId())
.orElseThrow(() -> new NotFoundException("Order", request.getOrderId()));

if (order.isAlreadyProcessed()) {
throw new BusinessRuleViolatedException("Order already processed");
}
return orderRepository.save(order);
}

When NotFoundException gets thrown anywhere in the system, you already know what failed, why it failed, and what HTTP status to return. You don't need to grep through logs trying to reconstruct context. The exception is the context.

Think of it like hospital triage codes versus a nurse shouting “something’s wrong with someone.” Triage codes exist because specificity saves time when time costs lives. Your exception hierarchy exists for the same reason specificity saves debugging time when production is on fire.

There’s also a less obvious benefit here: a good exception hierarchy forces you to think about your failure modes upfront. When you’re defining BusinessRuleViolatedException, you're implicitly cataloguing what your business rules actually are. That clarity leaks into your design in good ways.

The rule: if you’re catching a generic exception and then trying to figure out what actually went wrong from the message string, your exceptions aren’t specific enough. Name the failure. Make it impossible to confuse with anything else.

Pattern 3: handle everything in one place

You’ve got clean input coming in. You’ve got specific, named exceptions being thrown. Now the question is: where do you actually handle them?

The junior answer is in every controller. You write a try-catch in the first endpoint, it works, you copy the pattern to the second endpoint, and the third, and the fifteenth. Six months later you have a codebase where every controller method is forty lines long and thirty of those lines are catch blocks doing nearly identical things. Changing the error response format means touching fifteen files. Adding a new exception type means hunting through every controller to add another catch branch. It compounds fast.

This is the copy-paste catch block epidemic, and almost every team above a certain age has lived through it.

The senior pattern collapses all of that into one place. In Spring, that’s @RestControllerAdvice a single global handler that intercepts every unhandled exception from every controller in the application:

@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}

@ExceptionHandler(BusinessRuleViolatedException.class)
public ResponseEntity<ErrorResponse> handleBusinessRule(
BusinessRuleViolatedException e)
{
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException e)
{
String details = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("VALIDATION_FAILED", details));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
logger.error("Unexpected error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong"));
}
}

And here’s what your controllers look like after:

@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.findById(id);
}

That’s it. Three lines. The controller does exactly one thing: call the service and return the result. If NotFoundException gets thrown, the global handler catches it, formats it correctly, and returns the right HTTP status. The controller never needed to know about any of that.

This is the bouncer analogy applied to architecture. You don’t put a bouncer at every table in a restaurant. You put one at the door. One point of entry, one set of rules, consistently enforced. Your GlobalExceptionHandler is the door. Every exception has to pass through it, which means every exception gets handled the same way, every time, from one file you can actually reason about.

The practical benefits stack up quickly. Want to change your error response format? One file. Adding a new custom exception type? One new method in the handler. Need to add request ID tracking to every error response for distributed tracing? One change, instantly applied everywhere. What used to be a fifteen-file refactor becomes a three-line addition.

The framework doesn’t matter much here either the pattern exists everywhere. Django has exception middleware. Express has error-handling middleware with the four-argument signature. Laravel has the Handler class in app/Exceptions. The idea is universal: centralize your exception handling, stop duplicating it.

The rule: if you’re writing the same catch logic in more than one place, you need a centralized handler. Every duplicate catch block is future maintenance debt you’re taking out a loan on right now.

Pattern 4: result objects for expected failures

This is the pattern that tends to produce the most argument in code reviews, which is usually a sign it’s worth understanding properly.

The premise is simple: not every failure is exceptional. “User not found” isn’t a system error it’s a Tuesday. “Payment declined” isn’t a catastrophe it’s an expected outcome of the payment flow. “Item out of stock” isn’t a bug it’s a valid state your business logic needs to handle gracefully.

When you model these as exceptions, you’re using the wrong tool. Exceptions exist for genuinely unexpected conditions things that shouldn’t happen under normal operation. Using them for routine business outcomes is like calling an ambulance because you got a parking ticket. Technically it gets the job done, but it’s expensive, it misleads everyone involved, and it trains the system to treat normal outcomes as emergencies.

The result object pattern makes expected outcomes explicit in the return type instead:

public class Result<T> {
private final T value;
private final String error;
private final boolean success;

private Result(T value, String error, boolean success) {
this.value = value;
this.error = error;
this.success = success;
}

public static <T> Result<T> ok(T value) {
return new Result<>(value, null, true);
}

public static <T> Result<T> failure(String error) {
return new Result<>(null, error, false);
}

public boolean isSuccess() { return success; }
public T getValue() { return value; }
public String getError() { return error; }
}

Now your service communicates both possible outcomes in its signature:

// before — the caller has no idea this can fail
// until the exception surprises them at runtime
public User findUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User", id));
}

// after - the return type tells the whole story upfront
public Result<User> findUser(Long id) {
return userRepository.findById(id)
.map(Result::ok)
.orElse(Result.failure("User not found with id: " + id));
}

And the caller handles both paths without needing a try-catch anywhere:

Result<User> result = userService.findUser(123L);
if (result.isSuccess()) {
return ResponseEntity.ok(result.getValue());
}

return ResponseEntity.notFound().build();

The difference in readability is significant. In the exception-throwing version, a developer reading the method signature sees User findUser(Long id) and assumes it always returns a user. The failure mode is invisible until it bites them. In the result version, Result<User> findUser(Long id) makes the contract obvious this operation produces either a user or an explanation of why it didn't. The caller is forced to acknowledge both possibilities.

This isn’t a new idea and it definitely isn’t Java-specific. Rust builds this directly into the language with Result<T, E> if your function can fail, the type system requires you to say so and requires the caller to handle it. Go uses the idiomatic (value, error) return pair for the same reason. Haskell has Maybe. These languages treat explicit failure modeling as a first-class concern, not an afterthought.

The Java ecosystem is slowly catching up. Libraries like Vavr bring proper Either and Try types to the JVM. The direction the industry is moving is clear: make failure visible in the type signature, not hidden behind runtime surprises.

The rule: if a scenario is expected not a bug, not a system fault, just a normal outcome your business logic needs to handle don’t throw an exception. Return a result. Make the failure a first-class citizen of your API, not a trap waiting for the next developer.

The mental model that ties it all together

Four patterns is a lot to hold in your head at once. The good news is there’s a single question underneath all of them that makes the right choice obvious once you’ve internalized it:

Is this failure exceptional, or is it expected?

That’s it. Two buckets. Everything goes in one of them.

Exceptional failures are things that shouldn’t happen under normal operating conditions. The database connection drops mid-request. A third-party API times out after working fine for months. The server runs out of memory. A file that should always exist gets deleted by something outside your application. These are infrastructure-level surprises conditions your code didn’t cause and can’t reasonably prevent. They deserve try-catch, proper logging, alerting, and a human looking at them.

Expected failures are outcomes that are entirely normal within your business domain. A user searches for a record that doesn’t exist. A payment gets declined because the card is expired. Someone tries to book a seat that just got taken. An order is submitted twice. These aren’t bugs. They’re valid states your system needs to navigate gracefully. They deserve validation, result objects, and clean control flow not try-catch, not stack traces, not PagerDuty alerts at midnight.

Once you start sorting failures into these two buckets automatically, the right pattern follows almost without thinking. Bad input coming in? That’s preventable validate it at the boundary. A business rule getting violated? Name it explicitly with a custom exception. Error handling logic spreading across controllers? Centralize it. A routine “not found” outcome? Model it as a result, not an emergency.

The reason most codebases end up with try-catch everywhere is that nobody ever drew this line. Everything got treated as equally unpredictable, equally dangerous, equally worthy of the same blunt instrument. The bucket metaphor is simple, but the discipline of actually applying it consistently is what separates a codebase you can debug at 3am from one that makes you want to update your resume at 3am.

Think of it like your notification system. A fire alarm and a calendar reminder are both alerts. But they belong to completely different categories of urgency, and you’d never design a system that treated them the same way. Your exception handling is your application’s notification system. Design it with the same intentionality.

Conclusion

Here’s the uncomfortable truth the tutorials won’t tell you: most of the try-catch in production codebases today exists because the people who wrote it were taught a pattern without being taught the reasoning behind it. Copy, paste, ship. It works until it doesn’t, and when it doesn’t, it fails in the worst possible way silently, slowly, invisibly.

The four patterns in this article aren’t advanced techniques. They’re not senior-level secrets. They’re just what happens when you start asking “why is this failing?” instead of “how do I make this stop crashing?” Validate the input. Name the failure. Centralize the handling. Model the expected outcomes. None of it is complicated. All of it compounds.

The industry is already moving in this direction. Languages like Rust make explicit error modeling non-negotiable at the compiler level. TypeScript’s ecosystem is increasingly leaning on discriminated unions for the same purpose. The direction is clear: failures belong in the type signature, not hiding behind runtime surprises in catch blocks nobody reads.

Do this tomorrow one change at a time:

Find one try-catch in your codebase that’s catching bad input. Replace it with proper validation a @Valid annotation, a schema check, a guard clause. Just one. Find one catch (Exception e) that's swallowing everything. Replace it with a specific custom exception that names the actual failure. If you have catch blocks duplicated across multiple controllers or handlers, build a centralized exception handler and delete the copies. Find one place where you're throwing an exception for an outcome that's entirely expected. Replace it with a result object.

Four changes. One codebase that’s measurably more honest about what it does and what can go wrong.

The worst try-catch I ever saw in production wrapped an entire service class constructor and returned null on failure. The service would silently initialize as null, work fine for a while, then NullPointerException somewhere completely unrelated, no trail, no context, nothing. Three days of debugging. The fix was six lines.

What’s yours? Drop the worst try-catch you’ve ever shipped in the comments. No judgment we’ve all got one.

Helpful resources

Top comments (0)