Dopo il mio primo esperimento con la programmazione funzionale, ho deciso di approfondire ulteriormente l’argomento. Per questo ho partecipato al workshop “Lean and Functional Domain Modelling” organizzato da Avanscoperta e tenuto da Marcello Duarte lo scorso marzo. Il workshop mi ha fornito dei buoni spunti su come affrontare la modellazione in ottica funzionale e ha alimentato ancor di più la mia voglia di sperimentare questo paradigma utilizzando Scala.
Per affrontare questa sfida c’è voluto studio e allenamento. Dopo qualche mese, grazie anche a diversi confronti con Matteo Baglini, ho rimesso insieme tutti i pezzi e ho deciso di scrivere questo post. L’obiettivo è di illustrare il percorso che ho fatto per implementare una semplice logica di dominio, descritta in seguito, con la relativa persistenza. La mia stella polare in questo esperimento è stata di spingere gli effetti collaterali il più possibile ai margini esterni dell’applicazione, e di avere quindi la logica più pura possibile.
Come anticipato, questa volta ho deciso di utilizzare Scala come linguaggio per il codice di esempio. Inoltre ho utilizzato la libreria Cats per avere a disposizione altre astrazioni per la programmazione funzionale oltre a quelle messe a disposizione dal linguaggio stesso.
Il codice, come sempre, è disponibile su GitHub.
Definizione del dominio
Per questo post ho deciso di prendere in prestito il dominio utilizzato come esempio da Marcello Duarte durante il suo workshop. Si tratta del processo di presentazione delle note spese che, tipicamente, i dipendenti di un’azienda devono seguire per richiedere il rimborso delle spese di trasferta.
Nel dominio esistono tre tipi di spese per le quali è possibile richiedere un rimborso:
- le spese di viaggio, per le quali è necessario indicare il luogo di partenza e quello di arrivo;
- le spese di vitto, il cui ammontare deve essere inferiore ad un limite stabilito dall’azienda;
- le spese di alloggio, per le quali è necessario indicare i dati dell’hotel in cui il dipendente ha soggiornato.
È possibile presentare una richiesta di rimborso anche per spese che non rientrano nelle categorie precedenti, ma il dipendente deve fornire una descrizione sufficientemente dettagliata dei motivi per cui ha effettuato la spesa. Infine, per tutte le voci di spesa, deve essere indicata la data in cui sono avvenute, ovviamente antecedente alla compilazione della nota spese, e l’ammontare corrispondente.
Per presentare una richiesta di rimborso, il dipendente deve compilare una nota spese che contenga almeno una voce di spesa e sia intestata al dipendente stesso. Una volta presentata la richiesta di rimborso la nota spese non può più essere modificata.
In questo post non ho preso in considerazione il processo di approvazione della richiesta di rimborso.
Roadmap
Nel seguito del post descriverò l’approccio che ho seguito per sviluppare l’applicazione, ed in particolare:
- come implementare la logica di dominio seguendo il paradigma funzionale puro;
- l’uso dei contract test per implementare lo strato di accesso ai dati, che ha consentito di creare due implementazioni completamente intercambiabili: una che accede a PostgreSQL utilizzando la libreria Doobie, e l’altra che lavora in memoria e che ho utilizzato per i test successivi;
- l’implementazione dei servizi applicativi;
- come semplificare il codice applicativo rimuovendo anche gli effetti introdotti inizialmente per la gestione degli errori.
Implementazione pura della logica di dominio
Per implementare la logica di dominio, sono partito immaginandomi quali fossero le operazioni dell’algebra di dominio. Facendo riferimento ai requisiti descritti sopra, il risultato è stato il seguente:
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)] = ??? } |
Inizialmente non ho implementato né le operazioni né i tipi di dati utilizzati ma,
sfruttando i tipi generici di Scala e la notazione ???
, mi sono limitato
a definire le firme delle funzioni.
Dato che nella programmazione funzionale pura non si devono avere effetti collaterali
che non siano espressi nella firma delle funzioni, non è possibile utilizzare le
eccezioni per comunicare eventuali errori. Per questo motivo ho fatto in modo che
il tipo di ritorno delle funzioni fosse contenuto all’interno dell’effetto ValidationResult
.
ValidationResult
è un alias del tipo generico ValidateNel
fornito dalla
libreria Cats. Tale tipo è un applicative
che può contenere un risultato valido oppure una lista di errori non vuota. In questo
modo è possibile evincere immediatamente dalla firma delle funzioni che la computazione
può ritornare un risultato valido, e.g. OpenExpenseSheet
nel caso di openFor
,
o una lista di errori.
Dopo questa prima analisi, ho deciso di implementare i tipi di dati necessari per le operazioni di cui sopra. Per questo ho definito le seguenti classi/trait.
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) |
Tali classi hanno alcune peculiarità che è utile sottolineare:
- sono tutte definite come classi
case
. Questo permette, tra le altre cose, di utilizzare il pattern matching su esse; - i trait sono dichiarati
sealed
. Questo dice a Scala che tutte le classi che estendono i trait si devono trovare all’interno dello stesso file.scala
. In tal modo è possibile garantire che i tipi utilizzati dalla logica siano estendibili solamente all’interno del progetto corrente; - per ogni classe che lo necessita, ho definito una case class per l’id della classe
stessa. Non utilizzando direttamente la classe
UUID
di Java non è possibile confondere, ad esempio, l’id di unExpenseSheet
con quello di unClaim
.
L’uso di un sealed trait
con le relative case class
ha una doppia utilità.
Nel caso di ExpenseSheet
ha consentito di definire quali sono gli stati possibili
(Open
e Claimed
) di una nota spese, mentre nel caso di Expense
ha permesso di definire i tipi di spese consentiti dal dominio in esame (Travel
,
Accomodation
, Food
e Other
).
Smart constructor idiom
Una volta definiti i tipi dell’algebra sono passato ad implementare le regole di business. Tra queste ce ne sono alcune che riguardano l’evoluzione dei dati, che vedremo più avanti, e altre che invece riguardano la validazione dei dati al momento della creazione degli oggetti di dominio. Ad esempio:
- per le spese di viaggio è necessario indicare il luogo di partenza e quello di arrivo;
- per tutte le voci di spesa deve essere indicata la data in cui sono avvenute e l’ammontare corrispondente;
- etc.
Per implementare questo tipo di regole e garantire la validità delle entità di dominio
all’interno dell’applicazione, torna particolarmente utile il pattern “smart constructor”
descritto nel libro “Functional and Reactive Domain Modeling”.
Per applicare tale pattern è sufficiente dichiarare private
i costruttori della
classi sopra e definire, nei companion object
delle stesse, dei factory method.
Il compito di quest’ultimi è di verificare la bontà dei dati prima di creare l’istanza
richiesta. Il seguente pezzo di codice mostra un esempio di tale implementazione:
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)) } |
Quello sopra è un classico esempio dell’uso dell’applicative ValidationResult
.
Le tre validazioni (validateCost
, validateDate
e maxCostLimitValidation
)
vengono eseguite in modo indipendente l’una dall’altra e, grazie alla funzione mapN
di Cats, l’oggetto ExpenseFood
viene creato solamente se tutte hanno esito
positivo. In questo caso il risultato della funzione sarà un Valid
contenente
l’istanza creata. Viceversa, se una o più validazioni falliscono, il risultato di
mapN
sarà un Invalid
contenente la lista di tutti gli errori trovati.
Guardare Validated
per
ulteriori chiarimenti.
In modo analogo ho implementato i costruttori per tutte le altre entità definite in precedenza.
Domain service
Una volta definite tutte le entità di dominio e i relativi smart constructor, implementare il domain service abbozzato in precedenza è stato relativamente semplice.
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 } } |
Nell’ottica di creare una logica di dominio puramente funzionale è essenziale non
avere effetti collaterali nascosti. Per questo motivo la funzione claim
ritorna
una coppia di risultati. Il primo è la nota spese presentata, e quindi non più modificabile,
mentre il secondo è la richiesta di rimborso pendente, la quale dovrà poi seguire
il proprio iter di approvazione.
Interazione con il database
Per implementare lo strato di accesso ai dati ho deciso di utilizzare il pattern repository e di utilizzare i contract test per sviluppare contemporaneamente una versione in memoria, utilizzabile successivamente per i test, e una che facesse accesso ad un istanza PostgreSQL, da utilizzare nell’applicazione vera. Per scrivere i test ho utilizzato la libreria ScalaTest.
Partiamo prima di tutto dal trait che definisce le funzioni messe a disposizione
dal repository degli Employee
.
1 |
trait EmployeeRepository[F[_]] { def get(id: EmployeeId) : F[ApplicationResult[Employee]] def save(employee: Employee): F[ApplicationResult[Unit]] } |
Il repository mette a disposizione due semplici operazioni: get
e save
.
Inoltre, è generico rispetto all’effetto F[_]
che verrà definito nelle implementazioni
concrete. Come vedremo in seguito questo consente di utilizzare effetti diversi nel
caso dell’implementazione reale rispetto a quella in memoria.
Nella definizione della firma dei metodi è presente anche un effetto concreto: ApplicationResult
.
Quest’ultimo è un alias del tipo generico Either
di Scala, il quale viene
usato quando un’elaborazione può avere successo o meno. Ad esempio, nel caso
di get
il risultato sarà un Right
di Employee
se il dipendente
verrà trovato, altrimenti il risultato sarà un Left
di ErrorList
. A differenza
di ValidateNel
, Either
è una monade,
e questo ci consentirà, in seguito, di scrivere in maniera più concisa il servizio
applicativo.
Una volta definita l’interfaccia del repository, ho potuto scrivere il primo 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 } |
Il test è definito all’interno di una classe astratta perché, per poterlo eseguire veramente, è definire due funzioni di supporto:
createRepositoryWith
per consentire di inizializzare la persistenza (DB o memoria) con i dati desiderati, e ritornare un’istanza concreta diEmployeeRepository
;run
che consenta di eseguire effettivamente l’effetto ritornato dai metodi del repository concreto.
Inoltre la classe astratta richiede che per l’effetto F
esista una monade.
La parola chiave implicit
fa sì che Scala cerchi di ricavare in autonomia quale sia l’istanza corretta da
passare in fase di creazione della classe.
Vediamo ora l’implementazione in memoria del 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 } |
Il test utilizza la monade State
con lo stato TestState
per simulare in memoria il database. State
è un
costrutto della programmazione funzionale che consente di esprimere funzionalmente
computazioni che richiedono un cambio di stato. Questo fa sì, ad esempio quando implementeremo
il metodo save
, di poter vedere l’effetto di quest’ultimo sullo stato dell’applicazione.
Il repository corrispondente è molto semplice e sfrutta i costrutti di State
per rappresentare l’elaborazione desiderata.
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(())) } } |
Nel caso di get
si vede che lo stato dopo l’elaborazione non cambia e il risultato
della stessa è, se presente, il dipendente. Viceversa, nel caso di save
, lo
stato ritornato è uguale al precedente con l’aggiunta del nuovo dipendente alla lista
dei dipendenti, mentre il valore di ritorno è semplicemente Right
di Unit
.
Come si intuisce dal codice, l’istanza originale di TestState
non viene mai
modificata ma ne viene sempre creata una copia nuova.
Appurato il corretto funzionamento di InMemoryRepository
vediamo l’implementazione
della classe di test e della relativa classe di produzione per l’accesso ai dati
su 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)) } |
Al di là delle peculiarità dovute all’uso di Doobie per accedere al database, ciò
che è interessante in questa implementazione è l’uso dell’effetto ConnectionIO
.
Quest’ultimo è semplicemente un alias della monade Free
della libreria Cats. Free
è il costrutto classico con cui in programmazione
funzionale si rappresentano in modo puro gli effetti collaterali quali, ad esempio,
l’accesso al database, la scrittura dei log, etc.
ConnectionIO
è la specializzazione di Free
, fornita dalla libreria Doobie,
per rappresentare le interazioni con il database. Come si vede dal metodo run
della classe DoobieEmployeeRepositoryTest
, l’esecuzione della monade è insicura
perché, interagendo con un sistema esterno, si potrebbero verifica delle eccezioni
non gestite e questo viene reso esplicito nel nome del metodo unsafeRunSync
.
La bellezza di questo approccio è che tutto il resto del codice è puramente funzionale,
e la gestione di eventuali errori è concentrata in un solo punto.
Servizi applicativi
Una volta implementata la logica di dominio e l’accesso ai dati, non resta che mettere tutto insieme creando il servizio applicativo. Per verificare il corretto funzionamento di quest’ultimo è sufficiente creare un test che utilizzi le versioni in memoria dei vari repository. Vediamo un esempio.
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)))) } } } |
Per rendere il test più realistico utilizzo gli smart constructor descritti in precedenza
per creare le istanze per il test. Nel codice, utilizzo la funzione toOption.get
per ottenere le istanze delle entità di dominio create con gli smart constructor.
In un programma funzionale questo non dovrebbe mai essere fatto. Infatti, se il risultato
della funzione su cui si invoca toOption.get
fosse di tipo Invalid
, questo
causerebbe il lancio di un eccezione, rompendo così di fatto la referential transparency
del codice e rendendolo quindi non più funzionale puro. Nel codice di test ho potuto
farlo perché sono sicuro della correttezza dei dati passati alle funzioni.
Il flusso del test riportato sopra è molto semplice:
- preparo lo stato dell’applicazione come atteso dal test;
- invoco la funzione del servizio applicativo che voglio testare, eseguendo le operazioni
tramite la funzione
runS
diState
; - verifico che il nuovo stato dell’applicazione sia quello atteso.
Vediamo ora l’implementazione completa del servizio applicativo 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")) } } |
Come già visto per i trait dei repository, l’implementazione del servizio applicativo
è generica rispetto all’effetto utilizzato F[_]
. Questo implica che anche questa
parte del codice è funzionale pura, nonostante interagisca con la persistenza.
È interessante notare come le singole funzioni messe a disposizione dal servizio
applicativo prendano come parametri di input impliciti i repository di cui hanno
bisogno. L’uso della parola chiave implicit
fa sì che, come si può vedere dal
codice del test, non sia necessario passare esplicitamente i repository quando si
invocano le funzioni. È Scala che automaticamente passa le istanze corrette, se
riesce a trovarle.
Utilizzando il costrutto for comprehensions
di Scala il corpo delle funzioni risulta molto pulito. Sembra sostanzialmente di
leggere un tipico programma imperativo. Quello che succede invece è che stiamo solo
descrivendo una computazione, la quale verrà eseguita al momento dell’invocazione
delle funzioni specifiche della monade utilizzata (e.g. la funzione run
della
monade State
).
L’uso del for comprehensions e delle monadi fa sì che non appena una delle istruzioni
invocate fallisce, cioè ritorna un Left[T]
, la computazione della funzione
si interrompe e l’errore viene ritornato al chiamante.
Utilizzo del monad trasformer EitherT
Probabilmente avrete notato l’uso delle funzioni toEitherT
su quasi tutte le
righe. Questo è dovuto al fatto che il costrutto for comprehensions deve lavorare
con un unica monade. Le funzioni coinvolte invece utilizzano più monadi. Ad esempio,
la funzione get
di EmployeeRepository
ritorna F[ApplicationResult[Employee]]
mentre openFor
di ExpenseService
ritorna ValidationResult[OpenExpenseSheet]
il quale, per essere precisi, non è nemmeno una monade.
Per questo ho deciso di utilizzare il monad transformer EitherT
. Tramite questo
costrutto è possibile combinare una monade Either
con qualsiasi altra monade,
nel nostro caso F
, ottenendo una monade il cui effetto è la composizione degli
effetti delle monadi originali.
Le funzioni toEitherT
che si vedono nel codice servono appunto a trasformare
tutti i costrutti utilizzati in EitherT[F, _, ErrorList]
. In questo modo il
for comprehensions può essere usato efficacemente e il codice risulta molto più
pulito.
È possibile vedere il codice prima e dopo l’utilizzo del monad trasformer scorrendo i commit del repository GitHub.
Nel prossimo paragrafo vedremo come, modificando il codice dell’applicazione, è possibile
eliminare l’uso di EitherT
e migliorare ulteriormente la leggibilità del servizio
applicativo.
Rimozione degli effetti annidati
Come accennato in precedenza nel codice ci sono degli effetti annidati. Questo è dovuto al fatto che ho sviluppato l’applicazione a strati, cercando di utilizzare gli effetti più giusti per ognuno di essi. Una volta completato il quadro, è stata evidente la ridondanza/verbosità del codice dovuta all’accumularsi di questi effetti. Per questo motivo ho deciso di rifattorizzare il programma per semplificarlo il più possibile.
Il tipo ApplicationResult
, il quale è semplicemente un alias della monade EitherT
,
è stato introdotto per gestire gli errori applicativi a livello del servizio ExpenseApplicationService
.
D’altro canto, anche la monade ConnectionIO
, utilizzata da Doobie, ha la
capacità di gestire gli errori. Ovviamente la logica applicativa non può utilizzare
direttamente ConnectionIO
perché questo la renderebbe inutilizzabile in contesti
diversi da quello attuale (e.g. con un’altra libreria di accesso al database). Quello
che servirebbe è la garanzia che l’effetto generico F[_]
abbia la capacità
di gestire gli errori. Questo permetterebbe, ad esempio, di semplificare il tipo
di ritorno delle funzioni di ExpenseApplicationService
da così F[ApplicationResult[_]]
a così F[_]
.
Per ottenere la garanzia necessaria, è stato sufficiente richiedere che per F
esistesse una MonadError
(e.g. riga 3) e non solamente una Monad
come
visto in precedenza.
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")) } } |
Con questa semplice modifica, ho potuto rimuovere dal codice tutte le invocazione
a toEitherT
. Alla riga 45 si nota come, utilizzando la MonadError
, cambia
il modo di notificare gli errori al chiamante. Il servizio applicativo non sa come
ciò avvenga, sa solo che l’effetto F
ha questa capacità.
Ovviamente ho dovuto adeguare anche il resto del codice a questa modifica, ad esempio,
ho potuto semplificare il DoobieEmployeeRepository
perché non avevo più la
necessità di mappare le eccezioni nel tipo ApplicationResult
.
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(_ => ()) } |
L’unica eccezione ancora mappata è rimasta UnexpectedEnd
perché in questo caso
ho voluto che il repository lanciasse un’eccezione con un messaggio più significativo
per il dominio.
Non è stato facile trovare un metodo di refactoring che consentisse di mantenere il codice compilabile, i test verdi e, contemporaneamente, permettesse di sostituire a piccoli passi l’effetto utilizzato dalle funzioni. Infatti, modificare l’effetto in un punto del codice mi portava, inevitabilmente, a modificare quasi tutto il resto del codice. Questo rendeva di fatto il codice non compilabile per lunghi periodi di tempo, impedendomi di eseguire i test, e quindi di verificare la correttezza delle modifiche, per periodi inaccettabili.
Per questo motivo, ho deciso di affrontare il refactoring per duplicazione, ovvero:
- per ogni insieme di funzioni e i relativi test (e.g.
ExpenseService
eExpenseServiceTest
):- ho creato delle copie con suffisso ME (Monad Error);
- ho modificato i test e le funzioni copiate per farle funzionare correttamente con il nuovo effetto;
- una volta duplicato tutto il codice di produzione e verificato il corretto funzionamento di entrambe le versioni grazie ai test, ho potuto eliminare le vecchie funzioni e rinominare le nuove eliminando il suffisso ME.
Questo processo mi ha permesso di rifattorizzare il codice in modo incrementale evitando di spendere molto tempo senza poter verificare l’esito delle modifiche fatte.
Conclusioni
Questo esperimento mi è stato molto utile per diversi aspetti, tra cui:
- migliorare l’approccio alla modellazione funzionale della logica di dominio;
- sperimentare e utilizzare alcuni effetti comuni della programmazione funzionale, e di capirne le possibili applicazioni;
- capire fino a che punto si possono spingere ai margini dell’applicazione gli effetti collaterali che inevitabilmente un software reale produce.
Inoltre, da un punto di vista operativo mi sono reso conto della difficoltà nel fare refactoring, soprattutto quando ho modificato gli effetti utilizzati. Mi sono convinto che in programmazione funzionale sia meglio fare più design a priori, almeno per quanto riguarda la scelta degli effetti, rispetto a quanto sia necessario in OOP.
Il percorso formativo non è stato banale. Questo perché ho dovuto affrontare le diverse fasi dello sviluppo sotto tre aspetti distinti (e ognuno ugualmente importante):
- la sintassi di Scala;
- i concetti della programmazione funzionale;
- l’implementazione messa a disposizione da Cats e Scala di tali concetti.
Ci sono ancora alcuni aspetti che ho intenzione di approfondire in futuro. Il più
importante è la composizione degli effetti. Infatti, nell’esempio sopra l’unico effetto
utilizzato è ConnectionIO
per consentire l’accesso al DB, ma un’applicazione
più complessa potrebbe richiedere l’utilizzo di altri effetti:
scrivere/leggere su filesystem, accedere a risorse utilizzando richieste HTTP,
etc. Esistono vari approcci affrontare questi scenari e mi piacerebbe provarli per
capirne l’applicabilità.
Concludo ringraziando Matteo Baglini per la passione con cui spiega la programmazione funzionale e per alcuni suggerimenti che mi sono stati molto utili per pulire il codice e capire meglio quello che stavo facendo.
Avanti tutta!