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:
1 |
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.
1 |
sealed case class Employee (id : EmployeeId, name: String, surname: String) sealed case class EmployeeId(uuid: UUID) |
1 |
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 |
1 |
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) |
1 |
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 ofExpenseSheet
as an id ofClaim
.
Using a sealed trait
with the related case class
es 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:
1 |
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.
1 |
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.
1 |
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.
1 |
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 ofEmployeeRepository
;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.
1 |
object AcceptanceTestUtils { case class TestState(employees: List[Employee], expenseSheets: List[ExpenseSheet], claims: List[Claim]) type Test[A] = State[TestState, A] } |
1 |
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.
1 |
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.
1 |
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", "jdbcpostgres", "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 } |
1 |
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.
1 |
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 ofState
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
.
1 |
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.
1 |
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.
1 |
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
eExpenseServiceTest
):- 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!