architectureclean-architectureddd-repositories

Should Repositories Throw Domain Errors


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.


Solution

  • 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.