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:

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)] = ???
}

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.

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)

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 un ExpenseSheet con quello di un Claim.

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:

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))
}

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.

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
    }
}

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.

EmployeeRepository.scala
1
2
3
4
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.

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
}

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 di EmployeeRepository;
  • 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.

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
}

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.

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(()))
    }
}

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.

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))
}

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.

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))))
    }
  }
}

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 di State;
  • verifico che il nuovo stato dell’applicazione sia quello atteso.

Vediamo ora l’implementazione completa del servizio applicativo 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"))
  }
}

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.

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"))
    }
}

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.

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(_ => ())
}

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 e ExpenseServiceTest):
    • 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!