I agree email uniqueness logic like this belongs in the domain: it's a universal business rule that should be enforced universally regardless of the application layer(s) built on top of it.
However, I disagree that the repo is the right place. Here’s my reasoning:
- The repo interfaces are part of the domain - it needs to know there’s an IThingRepo and IStuffProvider and what they do - but I don’t consider their implementations to be. Storage details like which SQL dialect we’re using aren't the domain's concern. The domain encapsulates the important logic and delegates the mundane act of persistence to the repo.
- As further backup for this, you should be able to swap out repo layers (e.g. for testing, perf, or infra changes), and you shouldn’t lose business logic if you do so. Some may regard this as theoretical, but I’ve done it various times in production systems (Dapper vs. EF, RDBMS vs. document vs. graph, etc.).
King-side-slide
correctly notes that the rule may be needed in updates as well as adds, but then puts the rule in the repo's add
method, which would leave a gap. This points out that in the repo, you’d indeed have to choose a place or duplicate logic (or contort things to call common code).
- In this case the email field is in the same DB table as our aggregate root (User), but in many cases we may require coordination among multiple root types - and therefore multiple repos - and possibly other domain services. As a result, putting code like this into a specific repo doesn't work as a general solution.
So where to put it? I think this is a great case for a domain service, which lives in the domain but coordinates among multiple aggregate roots (which is the case here, since we need to vet an email address against all existing ones). Let’s say we start with this:
class EmailUniquenessService
{
public function validateUniqueness($email)
{
if ($this->userProvider->emailExists($email)) {
throw new UserAlreadyExistsException();
}
}
}
Same basic code you started with - in this case using your repo that's injected by interface - but now it's encapsulated in the domain, reliable and ready to use even if you rewrite your app and persistence layers.
But there's still a problem: your app layer can ignore it, grab a repo, and save away; maybe the dev who does the next rewrite doesn't even know the service exists. I like to have guarantees that if something gets saved, it must go through the domain. There are various ways to achieve this, depending on other rules, your architecture, and your language. Consider this instead:
class UserSaveService
{
public function saveUser($user)
{
validateUniqueness($user->email);
$this->userRepo->save($user);
}
private function validateUniqueness($email)
{
if ($this->userRepo->emailExists($email)) {
throw new UserAlreadyExistsException();
}
}
}
That's better: it encapsulates the operation so each save is preceded by a uniqueness check (and no hidden chrono dependencies). To force its use, you could put the repo's save
method on an interface that's internal to the domain and callers can't access (see edit); they'd have to inject this domain service instead.
This service, unlike the repo, is easily unit testable (just inject a mock for the repo). It can also be extended to transparently add further rules that your domain must enforce but that other layers needn't bother with unless a domain exception is thrown.
Also: you may want validate
and save
to run in a single transaction to prevent race conditions; this is a point in favor of king-side-slide
's answer, since both could run in one query. However, I don't think it's worth scattering domain logic and running against my points above; use a unit-of-work (UoW) instead. (And if you're relying on the repo for enforcement, you can add a DB constraint and catch a SQL exception on collision.)
EDIT:
Struck the internal interface idea per Jordan's comment. In langs like C# that support the internal
keyword, the following could work (I'd love to see a PHP way to require domain involvement):
Add a new class to the domain alongside User called VerifiedUser; make its constructor internal. Change your repo signature to accept a VerifiedUser, not a User, as its parameter; your domain is now the only layer capable of fulfilling the repo's Save() method contract. It could be as simple as a wrapper object that holds the real User.
I've never personally gone to this extent of guarantees in my own code (relying on knowing to call the domain service), but I'd certainly consider it - at least on larger, more confusing projects w/ many devs of varying skill/experience levels.