Domain Driven Challenges: How to handle exceptions

Learn how to handle errors effectively in Domain-Driven Design, with best practices and strategies for managing exceptions in applications.

Domain Driven Challenges: How to handle exceptions
Spoiler: The end result in Domain Driven Design

Welcome to my series Domain Driven Challenges, where we dive into Domain Driven Design topics that often are not focused on enough and I have seen implemented wrong while being a Tech Lead. The examples are written in Java but can be applied in most languages.

One of the core concepts of Domain Driven Design (DDD from now on) is the Ubiquitous Language. We understand what the function or class is doing based on the naming, and even the business domain users should be able to follow along.

Many times we read a lot of great examples on how to implement a lot of different things:

  • Service Layer pattern
  • Repository pattern
  • Using interfaces and infrastructure
  • Dependency inversion

However, a lot of these topics are mostly covered in the positive or happy flow, but what if we also want to properly handle edge cases or scenario’s that should trigger an error?

Enter Exceptions

Exceptions allow us to abort functions and code when certain conditions are met and let higher level code know that something went wrong (by catching the exception). To explain this concept, let’s setup an example:

  • We are building a payment service
  • The user pays through their balance
  • If the balance is not high enough, we can not process the payment
  • The payment is handled by an external API service

Because not every API we integrate with is perfect, we add some additional complexity:

  • The external API service does not check balance when handling the payment
  • The external API does have an endpoint to retrieve balance
  • The external API has an unstable uptime.

Happy flow

Before we can explore error handling, we should setup a simple function to handle the happy flow:

class PaymentController { 
 
    private PaymentService paymentService; 
 
    public PaymentController(PaymentService paymentService) { 
        this.paymentService = paymentService; 
    } 
 
    public Response handlePayment(Request request, int amountInCents) { 
        this.paymentService.createPaymentForUser(request.getUser(), amountInCents); 
        return new Response(200, new HashMap<>()); 
    } 
} 
 
class PaymentService { 
 
    private PaymentGateway paymentGateway; 
 
    public PaymentService(PaymentGateway paymentGateway) { 
        this.paymentGateway = paymentGateway; 
    } 
 
    public void createPaymentForUser(User user, int amountInCents) throws Exception { 
        int balance = this.paymentGateway.getBalance(user); 
 
        if (amountInCents > balance) { 
            throw new Exception("Balance is not enough"); 
        } 
 
        this.paymentGateway.createPayment(user, amountInCents); 
    } 
}

There we go, a simple implementation, where API details are hidden in the Payment Gateway interface and we focus on readable business logic. We also added an exception to cover our invalid scenario, so I think we can start shipping our application 🚢.

Balance is not enough

But hold on, I hope you as reader have already frowned at this line:

throw new Exception("Balance is not enough");

This does not make our code more scalable, we can not catch this specific exception and currently your Controller will most likely display a 500 Internal Server Error to the user. Someone unapprove that PR ❌.

Did you know: A lot of static code analyzers report throwing and catching generic Exceptions as a code smell.

We should start improving this, we want to let the end-user know they are exceeding their balance. Most frameworks have some sort of ExceptionHandler which allows you to throw a specific exception and map it into a pretty response, e.g. ResponseStatusException(code: int, message: string) . This is very easy to use, but the common pitfall I see is that people start using this exception outside of their framework’s controller.

While we are at it, we should also let the user know their max. balance so they are able to fix the action they tried to dispatch.

class PaymentService { 
 
    private PaymentGateway paymentGateway; 
 
    public PaymentService(PaymentGateway paymentGateway) { 
        this.paymentGateway = paymentGateway; 
    } 
 
    public void createPaymentForUser(User user, int amountInCents) { 
        int balance = this.paymentGateway.getBalance(user); 
 
        if (amountInCents > balance) { 
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Balance is not enough, current balance: " + balance); 
        } 
 
        this.paymentGateway.createPayment(user, amountInCents); 
    } 
}

This now displays a nice error in the API request on execution, but unfortunately, we just took an infrastructure dependency into our domain and tightly coupled the entire application.

Throwing domain errors

What we actually should do, will feel as a bit more work, but is both clear, expressive and very easy to follow.

First of all, we can introduce a new exception class for our business logic error, we should once again use Ubiquitos Language for our exception names:

class BalanceExceededException extends Exception { 
    public BalanceExceededException(int balance) { 
        super("Balance is not enough, current balance: " + balance); 
    } 
} 
 
class PaymentService { 
    public void createPaymentForUser(User user, int amountInCents) { 
        int balance = this.paymentGateway.getBalance(user); 
 
        if (amountInCents > balance) { 
            throw new BalanceExceededException(balance); 
        } 
 
        this.paymentGateway.createPayment(user, amountInCents); 
    } 
} 
 
class PaymentController { 
 
    public Response handlePayment(Request request, int amountInCents) { 
        try { 
          this.paymentService.createPaymentForUser(request.getUser(), amountInCents); 
          return new Response(200, new HashMap<>()); 
        } catch (BalanceExceededException ex) { 
          throw new ResponseStatusException(400, ex.getMessage()) 
        } 
    } 
}

You’ve nicely decoupled the exception from the controller now, the controller handles our “known” unhappy scenario and properly reports back the exception message to the user.

When you feel like you are catching a lot of different exceptions (e.g. you have 10 possible scenario’s that are from user input, we should group the exceptions by type. For example:

class PaymentValidationException extends Exception { 
    public PaymentValidationException(string message) { 
      super(message); 
    } 
} 
 
class BalanceExceededException extends PaymentValidationException { 
    public BalanceExceededException(int balance) { 
        super("Balance is not enough, current balance: " + balance); 
    } 
} 
 
class MaximumAmountExceededException extends PaymentValidationException { 
    public MaximumAmountExceededException() { 
        super("You have exceeded the maximum amount of 10000"); 
    } 
} 
 
class PaymentController { 
 
    public Response handlePayment(Request request, int amountInCents) { 
        try { 
          this.paymentService.createPaymentForUser(request.getUser(), amountInCents); 
          return new Response(200, new HashMap<>()); 
        } catch (PaymentValidationException ex) { 
          throw new ResponseStatusException(400, ex.getMessage()) 
        } catch (UserNotFoundException ex) { 
          throw new ResponseStatusException(401, "You are not authorized to create payments for this user"); 
        } 
    } 
}

See how we’ve grouped all PaymentValidation related exceptions into a single catch block at the controller level. It’s up to your domain to define and group exceptions, which is not easy the first few times, but keep trying!

Now that you’ve got the hang of this…

(Not) gotta catch them all

We should not expect our application to catch all exceptions, only the ones we actually want to do something with.

In our example, we talked about how the external API is unstable. Our business requirements do not tell us how to handle this, so we will let the request fail. We decide to not handle this and let an Internal Server Error pop up. We should however, install something like Sentry to report us these errors, so we have visibility or maybe find some errors we should properly handle.

Don’t rethrow an exception

When you start implementing logging or are busy with exceptions, sometimes you want to handle exceptions in your domain service layer:

class PaymentService { 
    public void createPaymentForUser(User user, int amountInCents) { 
        logger.info("Creating new payment for %s: %d cents", user.getId(), amountInCents); 
         
        try { 
            int balance = this.paymentGateway.getBalance(user); 
        } catch (UserNotFoundException ex) { 
            logger.error("User was not found") 
            throw new UserNotFoundException(ex.getMessage()) 
        } 
 
        if (amountInCents > balance) { 
            throw new BalanceExceededException(balance); 
        } 
 
        this.paymentGateway.createPayment(user, amountInCents); 
    } 
}

When you log the exception, you decide to handle the exception. When you handle a exception, you no longer pass it upwards. Doing something like this, will cause you to start handling errors more then once. For example: your controller exception handler might generate a second error log about the same error.

If you are not going to do anything with the error, such as “Create a new user when the user was not found” which would be a valid scenario to catch the error in the Service, you do not throw it again, as you’ve handled it already. The same counts for logging and any other interaction with the exception.

Conclusion

We’ve reached the end of this part of the series and I hope you have a better understanding of exception handling in both general and DDD space.

From this point, there is a lot more you can do I was not able to cover here to improve your error handling and also update your unit tests to assert for specific domain exceptions being thrown.

Good error handling makes your application more stable, less prone to bugs and more easily testable for edge scenario’s which are not happy.

As always, if you have any questions after reading this article, feel free to ask for any additional guidelines or help.