Should Repositories Throw Domain Errors
Asked Answered
L

3

7

I am building an application that tries to abide by clean architecture. I understand that the repository is meant to abstract away the persistence layer and return back entities in terms of the domain language. But, does that mean that it is also supposed to check and throw domain errors if something goes wrong. Let's consider a situation where I want to add a user via the user repository. I can do the following:

// in user repo
const add = (user: User): void => {
  try {
    // do some database stuff
  } catch() {
    throw new EmailAlreadyInUse(user.email);
  }
}

But is this implementation advisable? We are now relying upon the database to have been setup correctly with the correct unique key schema to enforce a domain rule (no two users can register with the same email). This seems to me like we are potentially spilling domain rules to the perisitence layer.

Would it make more sense to throw this exception from the use case layer in stead.

const AddNewUserUseCase = (userRepository, email) => {
  const user = userRepository.findByEmail(email);
  if(user) {
    throw new EmailAlreadyInUseError(email)
  }
  else {
    const user = new User(email);
    userRepository.add(user);
  }
}

This works and removes any spillage from the persistance layer. but I'd have to do this every single place I'd want to add a user. What is the recommended pattern you would go for? Do you have another approach you'd encourage? Where would you do those checks to throw errors.

Lewie answered 4/3, 2021 at 18:17 Comment(0)
G
5

Repositories are usually declared in the use case layer, because they are a definition of what the use case needs. Thus these interfaces should be domain oriented. Since they must be implemented in an outer layer it means that the outer layer must raise the domain exception if one is defined.

But is this implementation advisable? We are now relying upon the database to have been setup correctly with the correct unique key schema to enforce a domain rule (no two users can register with the same email)

From the use case's perspective it is not important how the the interface is implemented. You can implement a db, file or in-memory repository and it's up to the implementation how the repository's interface definition is fulfilled. If you implement a relational database repository you can use db contstraints to satisfy the repository's interface definition. But you still must map the raised ConstraintViolationException to the domain exception.

The main point is that the repository interface is a describtion of what the use case wants in a domain oriented way and not how it is done. It is the nature of any interface to describe what the client wants and not how. Interfaces are made for clients not for implementors.

The domain constraint is defined at the interface, e.g.

public interface UserRepository {

    /**
     *
     * throws an UserAlreadyExistsException if a user with the given email already exists.
     * returns the User created with the given arguments or throws an UserAlreadyExistsException. 
     *         Never returns null.
     */
    public User createUser(String email, ....) throws UserAlreadyExistsException;

}

An interface is more then just a method signature. It has pre- and post conditions that are often described in non-formal ways.

Alternative option

In Java for example you can also use abstract classes if you want the implementations to follow a path that you have defined. Since I don't know which language you use I will give you this Java example.

public abstract class UserRepository {
   
     public User createUser(String email, ...) throws UserAlreadyExistsException {
        User user = findByEmail(email);

        if(user) {
            throw new UserAlreadyExistsException(email)
        } else {
            User user = new User(email);
            add(user);
        }
     }

     protected abstract findByEmail(String email);
     protected abstract add(User user);
}

But when you use abstract classes you already define a part of the implementation. The implementation is not as free as it is in the interface example. And your implementation must extend the abstract class. This might be a problem, e.g. in Java, since Java doesn't allow multiple inheritence. Thus it depends on the language you use.

Conclusion

I would use the first example, just define an interface that throws the domain exception and let the implementation choose how it is done.

Sure this means that I usually must test the implementation with slower integration tests and I can not use fast unit tests. But the use case can still be easily tested with a unit test.

Guffaw answered 10/3, 2021 at 4:46 Comment(3)
I was considering something like this but a few questions arise from this strategy. 1. Doesn't this mean that you are now capturing domain rules (not allowing duplicate emails), in the repository layer? 2. What would you do in cases where an infrastructural error occurs rather than a domain error (e.g. database not reachable)? If you throw an infrastructural error, wouldn't that mean that the contract has been broken?Lewie
After thinking about this after some years, this is definitely the correct answer. Thank you so much ReneLewie
You're welcome! I appreciate that you came back after a long time to accept this answer in order to give feedback to me and the community.Roice
P
8

Exclusively relying on database capabilities to enforce business rules is a bad practice.

That said, given the fact that raising a domain exception follows some business validation check, you should not raise domain exceptions from inside the class representing your database (the repository).

Domain exceptions, as the name implies, should be used inside the domain (or application) layer.

Your duplicate-email validation should therefore be positioned inside use case, followed by the repository operation (add-user). As for code repetition the solution is simple: create a domain service with a method containing this two-phase logic (validation then operation) and use this service anywhere you'd like.

A key principle of the clean architecture is to form a stabilized domain layer while letting infrastructural details be exchangeable. But, when you put a business rule inside a repository (infrastructure), consider what will happen if you decide to create an alternative repository: you have to remember to copy your business rule into the new repository.

Pechora answered 4/3, 2021 at 22:3 Comment(4)
You make a lot of sense. Thank you very much for your input. So does that mean you think repositories should throw infrastructural errors when they occur? Would you standardize those errors across different repository implementations or would that not make senseLewie
Repositories are by definition expected to bubble up infrastructural exceptions thrown by remote resources they encapsulate. You may find it useful in some cases to have your repository method catch an exception thrown by the remote resource, but anyway application layer should not directly handle such specific exceptions (they can keep bubbling up to api layer then output to log). Also, different repository implementations usually dictate different types of exceptions, so I see no benefit in standardization.Pechora
How do you handle this type of logic when the validation is tied up to the operation?, For example, in firestore, the validation and creation are inside a so call "transaction", we just can't decouple that. So our use case can call a repository and expect a "created" response or a "duplicated" one, but that will add an if on the use case to check what happen, is it not odd?, Wouldn't that add more complexity to the project in general?Potherb
A database with validation mechanism is a given, it is up to you how to use that mechanism in the context of each use-case. If I were to choose to have it as my only validator, I would check for "duplicated" in repository class and raise appropriate domain exception. If I want my rules to be more abstract, and transactional behavior is supported, I can make a read operation as part of use-case to check if email exists, then raise a domain exception or move on to insertion which will again cause validation (and that is ok, this is the nature of that database, the implementation details).Pechora
G
5

Repositories are usually declared in the use case layer, because they are a definition of what the use case needs. Thus these interfaces should be domain oriented. Since they must be implemented in an outer layer it means that the outer layer must raise the domain exception if one is defined.

But is this implementation advisable? We are now relying upon the database to have been setup correctly with the correct unique key schema to enforce a domain rule (no two users can register with the same email)

From the use case's perspective it is not important how the the interface is implemented. You can implement a db, file or in-memory repository and it's up to the implementation how the repository's interface definition is fulfilled. If you implement a relational database repository you can use db contstraints to satisfy the repository's interface definition. But you still must map the raised ConstraintViolationException to the domain exception.

The main point is that the repository interface is a describtion of what the use case wants in a domain oriented way and not how it is done. It is the nature of any interface to describe what the client wants and not how. Interfaces are made for clients not for implementors.

The domain constraint is defined at the interface, e.g.

public interface UserRepository {

    /**
     *
     * throws an UserAlreadyExistsException if a user with the given email already exists.
     * returns the User created with the given arguments or throws an UserAlreadyExistsException. 
     *         Never returns null.
     */
    public User createUser(String email, ....) throws UserAlreadyExistsException;

}

An interface is more then just a method signature. It has pre- and post conditions that are often described in non-formal ways.

Alternative option

In Java for example you can also use abstract classes if you want the implementations to follow a path that you have defined. Since I don't know which language you use I will give you this Java example.

public abstract class UserRepository {
   
     public User createUser(String email, ...) throws UserAlreadyExistsException {
        User user = findByEmail(email);

        if(user) {
            throw new UserAlreadyExistsException(email)
        } else {
            User user = new User(email);
            add(user);
        }
     }

     protected abstract findByEmail(String email);
     protected abstract add(User user);
}

But when you use abstract classes you already define a part of the implementation. The implementation is not as free as it is in the interface example. And your implementation must extend the abstract class. This might be a problem, e.g. in Java, since Java doesn't allow multiple inheritence. Thus it depends on the language you use.

Conclusion

I would use the first example, just define an interface that throws the domain exception and let the implementation choose how it is done.

Sure this means that I usually must test the implementation with slower integration tests and I can not use fast unit tests. But the use case can still be easily tested with a unit test.

Guffaw answered 10/3, 2021 at 4:46 Comment(3)
I was considering something like this but a few questions arise from this strategy. 1. Doesn't this mean that you are now capturing domain rules (not allowing duplicate emails), in the repository layer? 2. What would you do in cases where an infrastructural error occurs rather than a domain error (e.g. database not reachable)? If you throw an infrastructural error, wouldn't that mean that the contract has been broken?Lewie
After thinking about this after some years, this is definitely the correct answer. Thank you so much ReneLewie
You're welcome! I appreciate that you came back after a long time to accept this answer in order to give feedback to me and the community.Roice
P
0

No. Because the "Uses Cases" or "Services" should catch the exceptions beafore call to respository.

Production answered 1/11, 2023 at 5:15 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.