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.

Use an exists-like Repository Method

This 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?