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.

Luis FreireLuis Freire
4 min readMar 19, 2026
Image

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.

Leonardo Dicaprio pointing out to little detail

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

JavaProgrammingTechnologySoftware DevelopmentSoftware Engineering

Services used

Back-End DevelopmentWeb Development

Author

Luis Freire

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.

Ready to build something real?