Junior Devs Use try-catch Everywhere. Senior Devs Use These 4 Exception Handling Patterns
Try-catch on every method? That’s not safe code — that’s a ticking time bomb. Here’s what senior devs do instead.

I joined a startup two years ago. The codebase had try-catch blocks wrapping every single method — controllers, services, repositories, and utility classes. Everything. The team called it “safe coding.”
It wasn’t safe. It was a disaster waiting to happen.
Within three months, we had three production incidents where errors vanished silently. No logs. No alerts. No way to know something broke until a customer complains. The try-catch blocks didn’t protect us. They hid the problems from us.
That experience taught me one thing: wrapping everything in try-catch isn’t defensive programming. It’s fear-based programming. Senior developers don’t reach for try-catch first. They reach for it last — only when it’s the only right tool for the job.
Here’s exactly what that looks like in code.
Why Junior Devs Default to try-catch
It makes sense on the surface. Something might fail? Wrap it. Compiler complains? Add a catch block. The tutorial shows a try-catch. Copy it.
The problem is that try-catch is a reactive tool, not a preventive one. Every time you wrap code in try-catch, you’re saying: “I don’t know what will go wrong here, so I’ll just catch whatever happens.”
Senior devs ask a different question first: Can I prevent this from going wrong in the first place?
The 4 Patterns Senior Devs Use Instead
Pattern 1: Validate First, Catch Neve
The most common reason junior devs add try-catch is to handle invalid input. A null value comes in, something breaks, and the catch block saves the day.
// ❌ WRONG — Catching what validation should have prevented
@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 Senior Devs Do Instead:
// ✅ CORRECT - Validate at the door. Nothing bad gets inside.
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) {
User user = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(user);
}
// The request object itself enforces the rules
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;
}Senior devs never let invalid input reach the point where it breaks.
The Rule: If you’re catching an exception caused by bad input, you have a validation problem — not an exception handling problem.
Pattern 2: A Custom Exception Hierarchy
Junior devs catch Exception. Senior devs build a hierarchy that makes every failure self-explanatory.
// ❌ WRONG — Generic exceptions tell you nothing
try {
orderService.process(order);
} catch (Exception e) {
logger.error("Something failed: " + e.getMessage());
return ResponseEntity.internalServerError().build();
}What Senior Devs Do Instead:
// ✅ CORRECT - A structured exception hierarchy
// Base exception - all app exceptions extend 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 exceptions 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");
}
}
// Usage - no try-catch needed in the service
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);
}Pattern 3: @ControllerAdvice — Handle Exceptions in One Place
Junior devs put try-catch in every controller method. Senior devs handle all exceptions in a single, centralized place.
// ❌ WRONG — Copy-pasting the same catch logic across 15 controllers
@GetMapping("/orders/{id}")
public ResponseEntity<Order> getOrder(@PathVariable Long id) {
try {
return ResponseEntity.ok(orderService.findById(id));
} catch (NotFoundException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().build();
}
}What Senior Devs Do Instead:
// ✅ CORRECT - One handler. All controllers. Zero duplication.
@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"));
}
}
// Now your controllers look like THIS
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.findById(id); // Exceptions handle themselves
}Your controllers become clean. Thin. Readable. All the error logic lives in one file.
The Rule: If you’re writing the same catch block in more than one controller, you need a @ControllerAdvice.
The Mental Model That Changes Everything
Senior developers think about exceptions in two categories:
Exceptional = Something that should not happen under normal conditions. A database connection drops. A third-party API times out. The server runs out of memory. These deserve try-catch, logging, and alerts.
Expected = Something that can happen as part of normal business logic. A user doesn’t exist. An order has already shipped. A payment is declined. These deserve validation, Result objects, and clean control flow — not try-catch.
Once you start categorizing failures this way, you’ll naturally write fewer try-catch blocks. Not because you’re ignoring errors. Because you’re handling them better.
Your Action Plan for Tomorrow
Find one try-catch in your codebase that’s catching bad input — replace it with @Valid a proper annotation. Pick one generic catch (Exception e) — replace it with a specific custom exception. If you have catch blocks repeating across controllers — build a @ControllerAdvice. Find one place where you throw an exception for an expected scenario — replace it with a Result object.
Don’t refactor everything at once. Start with one. Each change makes your code more honest about what’s actually happening — and that’s the real difference between junior and senior exception handling
What’s the worst try-catch block you’ve ever seen in a production codebase? Drop it in the comments — I guarantee someone on this thread has seen worse.
Tags
Serviços utilizados
Autor

Luis Freire
Founder & CTO na Hypnotic
Mais de 15 anos de experiência em diversas categorias e disciplinas do mundo digital, em particular, no desenvolvimento web e mobile, inteligência artificial, gestão de equipas e projetos, desenvolvimento e execução de marketing, desenvolvimento de negócio e operações de agências em geral. Possui um vasto e profundo conhecimento em diversas linguagens e frameworks de programação, tanto tradicionais como modernas, backend e frontend (full stack development), aplicando grande paixão e criatividade em cada projeto.

