After my first experiment with functional programming, I decided to further study it in depth. Therefore, last March I attended “Lean and Functional Domain Modelling” workshop, organized by Avanscoperta, and held by Marcello Duarte. The workshop gave me good hints about functional modeling and fueled my curiosity to learn Scala and experiment more this paradigm.

In order to tackle this challenge I studied and practiced a lot. After some months, and several discussions with Matteo Baglini, I have been able to put together the puzzle, and I wrote this post. The main goal is to walk through the steps I took to implement a simple domain logic, described below, and the related persistence layer. Pushing side effects at the application boundaries, in order to create a pure domain logic, has been my North Star in this experiment.

As stated above, I used Scala as programming language for the sample code. Moreover I used Cats library in order to obtain more functional abstraction than those available in the language itself.

As usual, the source code is on GitHub.

Domain definition

In this post I implement the domain used by Marcello Duarte in his workshop: the expense sheet process. This is the process followed by the employees of a company in order to request reimbursement for travel expenses.

Below are listed the three types of reimbursable expenses of this domain:

  • travel expenses, which need to specify departure and arrival cities;
  • food expenses, whose amount have to be less than a threshold defined by the company;
  • accommodation expenses, which need to specify the name of the hotel where the employee stayed.

An employee can claim reimbursement also for expenses other than those described above, but, in this case, she has to provide a detailed description. Finally, for all expenses, the date, antecedent to the filling of the expense sheet, and the due amount have to be specified.

In order to claim a reimbursement, the employee has to fill an expense sheet with her name and at least an expense. Once claimed, the expense sheet cannot be modified.

In this post, the approval process of the claim request is out of scope.

Roadmap

I am going to describe the approach I followed when developing the application. In particular:

  • how to implement the domain logic according to the pure functional paradigm;
  • how to use contract test in order to implement the persistence layer, which allowed me to create two completely exchangeable implementations: one that accesses PostgreSQL using Doobie, and an in-memory test double;
  • the implementation of the application services;
  • how to simplify the code by removing some of the effects previously introduced for error management.

Pure implementation of the domain logic

The first thing I did in order to implement the domain logic was to design the signatures of the functions of the domain algebra. Following the requirements described above, I came up with this:

ExpenseService.scala
1
2
3
4
5
6
7
8
9
10
object ExpenseService[Employee, Expense, OpenExpenseSheet, ClaimedExpenseSheet,
  PendingClaim] {
  def openFor(employee: Employee): ValidationResult[OpenExpenseSheet] = ???

  def addExpenseTo(expense: Expense, expenseSheet: OpenExpenseSheet):
    ValidationResult[OpenExpenseSheet] = ???

  def claim(expenseSheet: OpenExpenseSheet):
    ValidationResult[(ClaimedExpenseSheet, PendingClaim)] = ???
}

At first I did not implement nor the operations neither the data types involved. Using the generic type and ??? notation of Scala, I just defined the signatures of the functions.

Since in pure functional programming functions should not have any effects except those declared in the function signature, you cannot use exceptions for error management. For this reason I used the effect ValidationResult as the return type of all the functions.

ValidationResult is an alias of the generic class ValidateNel provided by Cats. Such class is an applicative which could contain a valid result or a non empty list of errors. In this way, just looking at the function signature, the user could understand that the computations could return a valid result, e.g. OpenExpenseSheet for openFor, or a list of errors.

After this first analysis, I decided to implement the data types needed by the above depicted operations. Therefore I defined the following classes and traits.

Employee.scala
1
2
3
sealed case class Employee (id : EmployeeId, name: String, surname: String)

sealed case class EmployeeId(uuid: UUID)
Expense.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
sealed trait Expense {
  def cost: Money
  def date: Date
}

case class TravelExpense(cost: Money, date: Date, from: String, to: String)
  extends Expense

case class FoodExpense(cost: Money, date: Date) extends Expense

case class AccommodationExpense(cost: Money, date: Date, hotel: String) extends Expense

case class OtherExpense(cost: Money, date: Date, description: String) extends Expense
ExpenseSheet.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sealed trait ExpenseSheet {
  def id: ExpenseSheetId
  def employee: Employee
  def expenses: List[Expense]
}

case class OpenExpenseSheet (id: ExpenseSheetId,
                             employee: Employee,
                             expenses:List[Expense]) extends ExpenseSheet

case class ClaimedExpenseSheet (id: ExpenseSheetId,
                                employee: Employee,
                                expenses:List[Expense]) extends ExpenseSheet

sealed case class ExpenseSheetId(uuid: UUID)
Claim.scala
1
2
3
4
5
6
7
8
9
10
sealed trait Claim {
  def id: ClaimId
  def employee: Employee
  def expenses: NonEmptyList[Expense]
}

case class PendingClaim (id: ClaimId, employee: Employee,
  expenses: NonEmptyList[Expense]) extends Claim

sealed case class ClaimId(uuid: UUID)

These classes have some interesting features which I would like to highlight:

  • all classes are case classes. This allows, among other things, to use pattern matching on them;
  • traits are declared sealed. This instructs Scala that all extending classes have to be placed in the same .scala file. This guarantees that the types used by the domain logic can be extended only from within the current project;
  • I defined an id case class for each classes that has one. By avoiding to directly use Java’s UUID, it is not possible, for example, to mistakenly use an id of ExpenseSheet as an id of Claim.

Using a sealed trait with the related case classes is useful for two purposes. Regarding ExpenseSheet, it allowed to define its feasible states (Open and Claimed), while for the Expense, it allows define the allowed kinds of expense (Travel, Accomodation, Food and Other).

Smart constructor idiom

Once defined the data types, I implemented the business rules. Among them there are some which are related to the process, discussed further on, and others which are related to the validation of input data needed to create domain objects. For example:

  • for travel expenses is mandatory to specify the departure and arrival cities;
  • each expense item need to contain the amount and the date when it happened;
  • etc.

In order to implement this kind of rule and to ensure that the domain entities used by the application are valid, it is really useful the “smart constructor idiom” pattern described in “Functional and Reactive Domain Modeling”. In order to apply the pattern I just declared the above class constructors as private and defined, in the related companion objects, the needed factory methods. These functions are responsible to validate data before creating the expected instance. The code below shows an example of this pattern:

Expense.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
object Expense {
  private def validateDate(date: Date): ValidationResult[Date] = {
    if (date == null || date.after(Calendar.getInstance.getTime))
      "date cannot be in the future".invalidNel
    else date.validNel

  private def validateCost(cost: Money): ValidationResult[Money] =
    if (cost.amount <= 0) "cost is less or equal to zero".invalidNel
    else cost.validNel

  private def maxCostLimitValidation(cost: Money): ValidationResult[Money] =
    if (cost.amount >= 50) "cost is greater than or equal to 50".invalidNel
    else cost.validNel

  def createFood(cost: Money, date: Date): ValidationResult[FoodExpense] =
    (validateCost(cost), validateDate(date), maxCostLimitValidation(cost))
      .mapN((c, d, _) => FoodExpense(c, d))
}

The code above is a typical usage of ValidationResult applicative. The three required validations (validateCost, validateDate and maxCostLimitValidation) are independently executed and, thanks to Cats’ function mapN, the instance of ExpenseFood is created only if all the validations successfully complete. On the other hand, if one or more validations fail, the result of mapN will be an Invalid containing the list of found errors. See Validated for more details.

I implemented the smart constructors of other entities in the same way.

Domain service

Once defined all the data types of the domain and the related smart constructors, the implementation of the domain service described above has been straightforward.

ExpenseService.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
object ExpenseService {
  def openFor(employee: Employee): ValidationResult[OpenExpenseSheet] =
    ExpenseSheet.createOpen(employee, List[Expense]())

  def addExpenseTo(expense: Expense, expenseSheet: OpenExpenseSheet):
    ValidationResult[OpenExpenseSheet] =
    ExpenseSheet.createOpen(expenseSheet.id,
                            expenseSheet.employee,
                            expenseSheet.expenses :+ expense)

  def claim(expenseSheet: OpenExpenseSheet):
    ValidationResult[(ClaimedExpenseSheet, PendingClaim)] =
    expenseSheet.expenses match {
      case h::t =>
        (ExpenseSheet.createClaimed(expenseSheet.id,
                                    expenseSheet.employee,
                                    expenseSheet.expenses),
        PendingClaim.create(expenseSheet.employee, NonEmptyList(h, t))).mapN((_, _))
      case _ => "Cannot claim empty expense sheet".invalidNel
    }
}

As already stated before, in order to have a pure functional domain logic is mandatory to avoid hidden side effects. For this reason the function claim return a pair. The first is the claimed expense sheet, thus no more modifiable, while the second is the pending claim, which will follow the related approval process.

Database access

In order to implement the data access layer, I decided to use the repository pattern and the contract test approach to simultaneously develop a in-memory test double, to be used in the application service tests, and a real version which access PostgreSQL, to be used in the real application. I used ScalaTest as test library.

Let’s start from the trait which defines the function provided by the Employee repository.

EmployeeRepository.scala
1
2
3
4
trait EmployeeRepository[F[_]] {
  def get(id: EmployeeId) : F[ApplicationResult[Employee]]
  def save(employee: Employee): F[ApplicationResult[Unit]]
}

The repository provides two simple operations: get e save. Moreover, it is generic w.r.t. the effect F[_] which will be defined by the concrete implementations. As shown below, this allows to use different effects in the real and in-memory implementations.

In the signature of the methods I also used a concrete effects: ApplicationResult. The latter is an alias of the generic type Either of Scala, which is used when a computation may succeed or not. E.g., the get function will return a Right of Employee if it will find the employee, otherwise it will return a Left of ErrorList. Unlike ValidateNel, Either is a monad, this will allow to write the application service more concisely.

Once defined the interface of the repository, I wrote the first test.

EmployeeRepositoryContractTest.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
abstract class EmployeeRepositoryContractTest[F[_]](implicit M:Monad[F])
  extends FunSpec with Matchers {

  describe("get") {
    it("should retrieve existing element") {
      val id : EmployeeId = UUID.randomUUID()
      val name = s"A $id"
      val surname = s"V $id"
      val sut = createRepositoryWith(List(Employee(id, name, surname)))

      run(sut.get(id)) should be(Right(Employee(id, name, surname)))
    }
  }

  def createRepositoryWith(employees: List[Employee]): EmployeeRepository[F]

  def run[A](toBeExecuted: F[A]) : A
}

The test is defined in an abstract class since, to be actually run, it needs two support functions which will be defined differently for each concrete implementation:

  • createRepositoryWith, which allows to initialize the persistence (DB or memory) with the needed data, and returns a concrete instance of EmployeeRepository;
  • run, which allows to actually run the effects returned by the methods of the repository.

Moreover, the abstract class requires that a monad exists for the effect F. The implicit keyword instructs Scala to automatically look for a valid instance of Monad[F] when creating an instance of the repository.

Now let’s see the in-memory implementation of the test.

AcceptanceTestUtils.scala
1
2
3
4
5
6
7
object AcceptanceTestUtils {
  case class TestState(employees: List[Employee],
                       expenseSheets: List[ExpenseSheet],
                       claims: List[Claim])

  type Test[A] = State[TestState, A]
}
InMemoryEmployeeRepositoryTest.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class InMemoryEmployeeRepositoryTest extends EmployeeRepositoryContractTest[Test]
  implicit var state : TestState = _

  override def createRepositoryWith(employees: List[Employee]):
    EmployeeRepository[Test] = {
    state = TestState(
      employees,
      List(),
      List())
    new InMemoryEmployeeRepository
  }

  override def run[A](executionUnit: Test[A]): A = executionUnit.runA(state).value
}

The test uses the State monad with the state TestState in order to simulate the persistence. State is a structure used in functional programming to functionally express computations which requires changing the application state. This let us observe, for example, the changed application state when the save function is used.

The InMemoryEmployeeRepository is really simple. It uses the State functions to represent the desired elaboration.

InMemoryEmployeeRepository.scala
1
2
3
4
5
6
7
8
9
10
11
12
class InMemoryEmployeeRepository extends EmployeeRepository[Test] {
  override def get(id: EmployeeId): Test[ApplicationResult[Employee]] =
    State {
      state => (state, state.employees.find(_.id == id)
                         .orError(s"Unable to find employee $id"))
    }

  override def save(employee: Employee): Test[ApplicationResult[Unit]] =
    State {
      state => (state.copy(employees = employee :: state.employees), Right(()))
    }
}

Analyzing the get function, you can notice that the state does not change after the elaboration and the returned result is the required employee, if present. On the other hand, for the save function the returned state is a copy of the previous one, with the new employee added to the corresponding list, while the return value is just Right of Unit. As you can see from the code, the initial instance of TestState is never modified, instead a new instance is always created.

Once verified the correct behavior of InMemoryRepository, lets see the implementation of the test and production classes to access the data on PostgreSQL.

DoobieEmployeeRepositoryTest.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class DoobieEmployeeRepositoryTest
  extends EmployeeRepositoryContractTest[ConnectionIO] {
  implicit var xa: Aux[IO, Unit] = _

  override protected def beforeEach(): Unit = {
    super.beforeEach()
    xa = Transactor.fromDriverManager[IO](
      "org.postgresql.Driver",
      "jdbc:postgresql:postgres",
      "postgres",
      "p4ssw0r#"
    )
  }

  override def createRepositoryWith(employees: List[Employee]):
    EmployeeRepository[ConnectionIO] = {
    val employeeRepository = new DoobieEmployeeRepository

    employees.traverse(employeeRepository.save(_))
      .transact(xa).unsafeRunSync()

    employeeRepository
  }

  def run[A](toBeExecuted: ConnectionIO[A]): A =
    toBeExecuted.transact(xa).unsafeRunSync
}
DoobieEmployeeRepository.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DoobieEmployeeRepository extends EmployeeRepository[ConnectionIO] {
  override def get(id: EmployeeId): ConnectionIO[ApplicationResult[Employee]] =
    sql"select * from employees where id=$id".query[Employee]
      .unique
      .attempt
      .map(_.leftMap({
        case UnexpectedEnd => ErrorList.of(s"Unable to find employee $id")
        case x => x.toError
      }))

  override def save(employee: Employee): ConnectionIO[ApplicationResult[Unit]] =
    sql"insert into employees (id, name, surname) values (${employee.id}, ${employee.name}, ${employee.surname})"
      .update.run.attempt.map(_.map(_ =>()).leftMap(_.toError))
}

Beyond the particularities due to the use of Doobie for accessing the database, what is more interesting in this implementation is the usage of the effect ConnectionIO. The latter is just an alias of Free monad provided by Cats. Free is a structure of the functional programming used to represent the side effects in a pure way. E.g., accessing a database, writing a log. etc.

ConnectionIO is a specialization of Free, provided by Doobie, to represent the interaction with databases. As shown in the run method of the DoobieEmployeeRepositoryTest class, the execution of this monad is unsafe since, interacting with an external system, exception can be thrown during its execution. This is clearly depicted in the name of the function unsafeRunSync. What is more fascinating of this approach is that everything, except the method run, is purely functional and the error management can be done in a single place.

Application services

Once implemented the domain logic and data access layer, all I needed to do is to put everything together by implementing an application service. In order to verify the correct behavior of the latter, I created some tests which use the in-memory test doubles of the repositories. Lets see an example.

Employee.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class ExpenseApplicationServiceTest extends FunSpec with Matchers {
  implicit val er: InMemoryEmployeeRepository = new InMemoryEmployeeRepository()
  implicit val esr: InMemoryExpenseSheetRepository =
    new InMemoryExpenseSheetRepository()
  implicit val cr: InMemoryClaimRepository = new InMemoryClaimRepository()

  describe("addExpenseTo") {
    it("should add an expense to an open expense sheet") {
      val employee = Employee.create("A", "V").toOption.get
      val expense = Expense.createTravel(
        Money(1, "EUR"), new Date(), "Florence", "Barcelona").toOption.get
      val expenseSheet = ExpenseSheet.createOpen(employee, List()).toOption.get

      val newState = ExpenseApplicationService
        .addExpenseTo[Test](expense, expenseSheet.id)
        .runS(TestState(List(employee), List(expenseSheet), List())).value

      newState.expenseSheets should be(
        List(OpenExpenseSheet(expenseSheet.id, employee, List(expense))))
    }
  }
}

In order to let the test be more realistic I took advantage of the smart constructor previously defined. I used toOption.get method to obtain the instance of the domain entities built by the smart constructors. This should never happen in a functional program. In fact, if the result of the smart constructor was of type Invalid, calling toOption.get method on it would raise an exception. This would break the referential transparency of the code, which will not be pure anymore. I did it in the test code just because I was sure that data were valid.

The flow of the test above is quite simple:

  • arrange the application state as expected by the test;
  • invoke the application service function under test, using the runS method of State to actually execute the operations;
  • verify that the new application state matches the expected one.

Let’s see the complete implementation of the application service ExpenseApplicationService.

Employee.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
object ExpenseApplicationService {
  def openFor[F[_]](id: EmployeeId)
    (implicit M:Monad[F],
      er: EmployeeRepository[F],
      esr: ExpenseSheetRepository[F]) : F[ApplicationResult[Unit]] =
    (for {
      employee <- er.get(id).toEitherT
      openExpenseSheet <- ExpenseService.openFor(employee).toEitherT[F]
      result <- esr.save(openExpenseSheet).toEitherT
    } yield result).value

  def addExpenseTo[F[_]](expense: Expense, id: ExpenseSheetId)
    (implicit M:Monad[F],
      esr: ExpenseSheetRepository[F]) : F[ApplicationResult[Unit]] =
    (for {
      openExpenseSheet <- getOpenExpenseSheet[F](id)
      newOpenExpenseSheet <- ExpenseService.addExpenseTo(expense, openExpenseSheet)
                               .toEitherT[F]
      result <- esr.save(newOpenExpenseSheet).toEitherT
    } yield result).value

  def claim[F[_]](id: ExpenseSheetId)
    (implicit M:Monad[F],
      esr: ExpenseSheetRepository[F],
      cr: ClaimRepository[F]) : F[ApplicationResult[Unit]] =
    (for {
      openExpenseSheet <- getOpenExpenseSheet[F](id)
      pair <- ExpenseService.claim(openExpenseSheet).toEitherT[F]
      (claimedExpenseSheet, pendingClaim) = pair
      _ <- esr.save(claimedExpenseSheet).toEitherT
      _ <- cr.save(pendingClaim).toEitherT
    } yield ()).value

  private def getOpenExpenseSheet[F[_]](id: ExpenseSheetId)
    (implicit M:Monad[F], esr: ExpenseSheetRepository[F]) :
    EitherT[F, ErrorList, OpenExpenseSheet] =
    for {
      expenseSheet <- esr.get(id).toEitherT
      openExpenseSheet <- toOpenExpenseSheet(expenseSheet).toEitherT[F]
    } yield openExpenseSheet

  private def toOpenExpenseSheet(es: ExpenseSheet) :
    ApplicationResult[OpenExpenseSheet] = es match {
    case b: OpenExpenseSheet => Right(b)
    case _ => Left(ErrorList.of(s"${es.id} is not an open expense sheet"))
  }
}

As previously explained for the repository traits, the implementation of the application service is generic w.r.t. the effect F[_]. Therefore also this piece of code is purely functional even if it interacts with the persistence.

It’s worth to point out that each function provided by the application service gets the needed repositories as implicit parameters. As shown in the test code, using the keyword implicit makes the invocation of the functions easier since the caller does not need to explicitly pass the repository as parameters. Scala is responsible to locate valid instances, if any, and pass them to the function.

Using the for comprehensions notation of Scala the body of the functions are really clean. It looks like reading an imperative program. We are actually looking to the description of a computation, which will be executed only when the monad’s will be unwrapped (e.g. using the method run of the State monad).

Using the monads with for comprehensions let the computation fails as soon as any of the instruction fails, i.e. a function returns Left[T]. In this case the computation stops and the error is returned to the client code.

Usage of EitherT monad trasformer

You probably noticed the use of toEitherT methods almost everywhere. This is due to the fact that the for comprehensions notation works with one monad at a time. The functions involved instead use more monads. For example, the get function of EmployeeRepository returns F[ApplicationResult[Employee]] while openFor of ExpenseService returns ValidationResult[OpenExpenseSheet] which, to be precise, is not even a monad.

That’s why I decided to use the EitherT monad transformer. Through this structure it is possible to combine an Either monad with any other monad, in our case F, obtaining a monad whose effect is the composition of the effects of the original monads.

The toEitherT functions that are seen in the code are used to transform all the types used in EitherT[F, _, ErrorList]. In this way the for comprehensions can be used effectively and the code is much cleaner.

You can see the code before and after using the monad transformer by browsing the GitHub repository.

In the next section we will see how, by modifying the application code, it is possible to eliminate the use of EitherT and further improve the readability of the application service.

Removing nested effects

As anticipated, in the code there are nested effects. This is due to the fact that I developed the application trying to use the appropriate effect for each layer. Once completed, the redundancy/verbosity of the code was evident due to the accumulation of these effects. Therefore, it was appropriate a refactoring of the program to simplify it as much as possible.

The ApplicationResult type, which is simply an alias of the EitherT monad, was introduced to handle application errors at the ExpenseApplicationService service level. On the other hand, the ConnectionIO monad, used by Doobie, also has the ability to handle errors. Obviously, the application logic can not directly use ConnectionIO because this would make it unusable in different contexts (e.g. with another database access library). What would be needed is to guarantee that the generic effect F[_] has the ability to handle errors. This would allow, for example, to simplify the type of return of the functions of ExpenseApplicationService from so F[ApplicationResult[_]] to so F[_].

To obtain the necessary guarantee, it was enough to request that a MonadError exists for F (see, line 3 below) instead of requesting just a Monad as previously seen.

ExpenseApplicationService.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
object ExpenseApplicationService {
  def openFor[F[_]](id: EmployeeId)
    (implicit ME:MonadError[F, Throwable],
      er: EmployeeRepository[F],
      esr: ExpenseSheetRepository[F]) : F[ExpenseSheetId] =
    for {
      employee <- er.get(id)
      openExpenseSheet <- ExpenseService.openFor(employee)
      _ <- esr.save(openExpenseSheet)
    } yield openExpenseSheet.id

  def addExpenseTo[F[_]](expense: Expense, id: ExpenseSheetId)
    (implicit ME:MonadError[F, Throwable],
      esr: ExpenseSheetRepository[F]) : F[Unit] =
    for {
      openExpenseSheet <- getOpenExpenseSheet[F](id)
      newOpenExpenseSheet <- ExpenseService.addExpenseTo(expense, openExpenseSheet)
      result <- esr.save(newOpenExpenseSheet)
    } yield result

  def claim[F[_]](id: ExpenseSheetId)
    (implicit ME:MonadError[F, Throwable],
      esr: ExpenseSheetRepository[F],
      cr: ClaimRepository[F]) : F[ClaimId] =
    for {
      openExpenseSheet <- getOpenExpenseSheet[F](id)
      pair <- ExpenseService.claim(openExpenseSheet)
      (claimedExpenseSheet, pendingClaim) = pair
      _ <- esr.save(claimedExpenseSheet)
      _ <- cr.save(pendingClaim)
    } yield pendingClaim.id

  private def getOpenExpenseSheet[F[_]](id: ExpenseSheetId)
    (implicit ME:MonadError[F, Throwable],
      esr: ExpenseSheetRepository[F]): F[OpenExpenseSheet] =
    for {
      expenseSheet <- esr.get(id)
      openExpenseSheet <- toOpenExpenseSheet(expenseSheet)
    } yield openExpenseSheet

  private def toOpenExpenseSheet[F[_]](es: ExpenseSheet)
    (implicit ME:MonadError[F, Throwable]) : F[OpenExpenseSheet] =
    es match {
      case b: OpenExpenseSheet => ME.pure(b)
      case _ => ME.raiseError(new Error(s"${es.id} is not an open expense sheet"))
    }
}

With this simple change, I was able to remove all invocation to toEitherT from the code. At line 45 you can see how, using the MonadError, the way to notify errors to the caller is changed. The application service does not know how this happens, it only knows that the effect F has this capability.

Obviously I had to adapt the rest of the code to this change, for example, I could simplify the DoobieEmployeeRepository because I no longer need to map the exceptions in the ApplicationResult type.

DoobieEmployeeRepository.scala
1
2
3
4
5
6
7
8
9
10
11
12
13
class DoobieEmployeeRepository(implicit ME: MonadError[ConnectionIO, Throwable]) 
  extends EmployeeRepository[ConnectionIO] {
  override def get(id: EmployeeId): ConnectionIO[Employee] =
    sql"select * from employees where id=$id".query[Employee]
      .unique
      .recoverWith({
        case UnexpectedEnd => ME.raiseError(new Error(s"Unable to find employee $id"))
      })

  override def save(employee: Employee): ConnectionIO[Unit] =
    sql"insert into employees (id, name, surname) values (${employee.id}, ${employee.name}, ${employee.surname})"
      .update.run.map(_ => ())
}

The only exception still mapped is UnexpectedEnd because in this case I wanted the repository to throw an exception with a more meaningful message for the domain.

It was not easy to find a refactoring method that would allow to maintain the code compilable, the tests green and, at the same time, would allow to replace the effect used by the functions in small steps. In fact, changing the effect at one point in the code inevitably led me to change the majority of the code. This made the code non-compilable, preventing me from performing the tests, and then verifying the correctness of the changes, for unacceptable periods.

For this reason, I decided to tackle the refactoring by duplication, namely:

  • for each set of functions and the related tests (e.g. ExpenseService e ExpenseServiceTest):
    • I created copies with suffix ME (Monad Error);
    • I modified the copied tests and functions to make them work correctly with the new effect;
  • once the whole production code has been duplicated and the correct behaviors of both versions has been verified through tests, I have been able to eliminate the old functions and rename the new ones by eliminating the suffix ME.

This process allowed me to refactor the code incrementally avoiding spending a lot of time without verifying the outcome of the made changes.

Conclusions

This experiment was very useful for several aspects. In particular, it let me:

  • improve the approach to functional domain modeling;
  • experiment and use some common effects of functional programming, and understand their possible applications;
  • understand to what extent the side effects, that inevitably a real software produces, can be pushed to the boundaries of the application.

Moreover, from a practical point of view I realized the difficulty in doing refactoring, especially when I modified the used effects. I am now convinced that in functional programming it is better to dedicate more attention to the design phase, at least for the effects, compared to OOP.

Overall this experiment was challenging. In fact, during the different development phases, I had to consider and deal with three distinct aspects (each equally important):

  • Scala syntax;
  • the concepts of functional programming;
  • the implementation provided by Cats and Scala of these concepts.

There are still aspects that I intend to deepen in the future. The most important is the composition of effects. In fact, in the above example the only used effect is ConnectionIO to allow access to the DB, but a more complex application may require the use of other effects: write/read on filesystems, access resources using HTTP requests, etc. There are various approaches to dealing with these scenarios and I would like to try them out to understand their applicability.

I conclude by thanking Matteo Baglini for the passion he always demonstrates when explaining functional programming concepts, and for his precious suggestions that have been very useful to clean up the code and to better understand what I was doing.

Full speed ahead!