Application service methods are supposed to be an atomic unit of change. DDD articles suggest to use a Unit of Work pattern. Let’s take a look at the definition of the pattern.

The Unit of Work pattern is a software design pattern that helps manage and organize the execution of multiple database operations as a single, cohesive unit. It ensures that these operations are either all completed successfully or all rolled back in case of any failure, maintaining data integrity and consistency.

So basically it is just a way to pass an ongoing transaction to all repository methods in a call chain. In C# and Java this is probably done using some thread local magic. But in FP-flavored Scala we prefer explicitness and composability. Gladly we have R in ZIO[R, E, A] and this is one of the rare cases when it’s actually useful.

Let’s say we have a repository

trait AccountRepository {
  def findByNumber(number: AccountNumber): UIO[Option[Account]]
  def save(account: Account): UIO[Unit]
}

and a scenario of executing a funds transfer (a very simplified one)

def transfer(
		userId: UserId, 
		from: AccountNumber, 
		to: AccountNumber,
		amount: Money): IO[TransferError, Unit] =
	for {
		fromAccount <- accounts.findByNumber(from)
									   .someOrFail(TransferError.accountNotFound(from))
		_ <- auth.checkCanTransfer(userId, fromAccount)
		toAccount <- accounts.findByNumber(to)
								   .someOrFail(TransferError.accountNotFound(to))
		result <- transferManager.transfer(fromAccount, toAccount, amount)
		_ <- accounts.save(result.fromAccount)
		_ <- accounts.save(result.toAccount)
	} yield ()

where transferManager is a domain service responsible for debiting and crediting the supplied accounts

class TransferManager {
  def transfer(
			from: Account, 
			to: Account, 
			amount: Money): IO[TransferError, TransferResult] = 
		for {
			debitedAccount <- ZIO.fromEither(from.debit(amount))
			creditedAccount = to.credit(amount)
		} yield TransferResult(debitedAccount, creditedAccount)
}

We certainly don’t want to have double spendings here. So we need a way to execute this atomically. But still abstract away any concrete persistence mechanism.

Let’s introduce a transactor

trait Transactor[Tx] {
	def transact[E, A](io: ZIO[Tx, E, A]): IO[E, A]
}

This trait is implementation agnostic and it could be reused as a part of some util package.

Now let’s refactor the repository interface to accept an active transaction via ZIO’s environment channel

trait AccountRepository[Tx] {
  def findByNumber(number: AccountNumber): URIO[Tx, Option[Account]]
  def save(account: Account): URIO[Tx, Unit]
}

and our scenario to use the transactor

def transfer(
		userId: UserId, 
		from: AccountNumber, 
		to: AccountNumber,
		amount: Money): IO[TransferError, Unit] =
	transactor.transact {
		for {
			fromAccount <- accounts.findByNumber(from)
										   .someOrFail(TransferError.accountNotFound(from))
			toAccount <- accounts.findByNumber(to)
									   .someOrFail(TransferError.accountNotFound(to))
			_ <- checkCanTransfer(userId, fromAccount)
			result <- transferManager.transfer(fromAccount, toAccount, amount)
			_ <- accounts.save(result.fromAccount)
			_ <- accounts.save(result.toAccount)
		} yield ()
	}

This chapter is not concerned with the actual implementation of the underlying infrastructure but I can assure you that it is doable with Doobie, ReactiveMongo or Finagle MySQL. Maybe one day Whisk will open-source its toolbox around that.