TODO: Rewrite showing how to implement email changing
In an ideal world with limitless computational resources we would just make one big aggregate consisting of all the entities in our domain and keep it in-memory. That would give us an immediate consistency and an ability to check all the invariants in the domain code.
But we have to adapt to our limitations. So sometimes we have to use smaller aggregates and check cross-aggregate invariants using the infrastructure.
My example invariant is from IAM domain — “An email must be unique across all accounts”. It does not make sense to implement an aggregate consisting of all the accounts. It won’t scale — it will grow unbounded and lead to contention. We can’t use the email as a natural primary key too — it might be changed. How do we check that using infrastructure? I see multiple ways.
exists
-like Repository MethodThis approach requires a repository to provide a method that check if an aggregate with a given email exists.
First let’s add a method to the repo
trait AccountRepo[Tx] {
def existsWithEmail(email: Email): URIO[Tx, Boolean]
def save(account: Account): URIO[Tx, Unit]
def findById(userId: UserId): URIO[Tx, Option[Account]]
}
This invariant is considered a domain logic so the check should probably be implemented in one of the domain components. Keep in mind that this invariant should be checked both during account creation and when changing the account email. Also, it has to be checked in all possible application services. So I think it makes sense to put it in a domain service.
final class AccountManager[Tx](
accounts: AccountRepo[Tx]) {
def createAccount(
email: Email,
fullName: FullName): ZIO[Tx, DomainError, Account] =
for {
_ <- checkEmailUniqueness(email)
account <- Account.create(email, fullName)
} yield account
def changeAccountEmail(
account: Account,
newEmail: Email): ZIO[Tx, DomainError, Account] =
for {
_ <- checkEmailUniqueness(email)
updatedAccount = account.changeEmail(newEmail)
yield updatedAccount
// Should this logic be extracted into its own class?
// I guess it depends on whether you decide to split this service into two.
// I don't have an opinion on the optimal domain service granularity yet.
private def checkEmailUniqueness(email: Email): ZIO[Tx, DomainError, Unit] =
ZIO.whenM(accounts.existsWithEmail(email)) {
ZIO.fail(CreateAccountError.duplicateEmail)
}
}
<aside>
💡 I think this is why they say that the need for a domain service might be a sign of a missing aggregate. In theory this could be modeled as a an aggregate called AccountDirectory
that would contain all the Account
instances. Then this invariant could be checked without infrastructure. But in practice that would require a lot of effort to keep it scalable.
</aside>
This is how the application service will look
final class AccountManagementService[Tx](
accounts: AccountRepo[Tx],
accountManager: AccountManager[Tx]
transactor: Transactor[Tx]
) {
def createAccount(
email: Email,
fullName: FullName): IO[DomainError, UserId] =
transactor.transact {
for {
account <- accountManager.createAccount(email, fullName)
_ <- accounts.save(account)
} yield account.id
}
def changeAccountEmail(
userId: UserId,
newEmail: Email): IO[DomainError, Unit] =
transactor.transact {
for {
account <- accounts.findById(userId)
updatedAccount <- accountManager.changeAccountEmail(account, newEmail)
_ <- accounts.save(updatedAccount)
} yield ()
}
}
Pros
Cons
How do we protect Account#changeEmail
from being used bypassing AccountManagement
service?