Type-safe domain modeling in kotlin. “Se compila, funziona”
In Domain-Driven Design (DDD) esiste il concetto di “ubiquitous language“. Banalizzando, solitamente se ne parla relativamente ai nomi da assegnare alle entity del modello del dominio.
Si può fare un passo in più, e trasformare la codebase in una inequivocabile espressione del dominio.
Per esempio, un esperto di dominio di nazionalità americana, potrebbe descrivere un “contatto” come segue:
- Un contatto ha un nome, un cognome e una e-mail. Il nome potrebbe avere una “middle initial” (iniziale del secondo nome).
- La e-mail deve essere verificata.
- Si possono mandare i reset della password solo a mail verificate
(Trattandosi di un americano, nel codice verranno usati nomi inglesi per la variabili)
Una modellazione, tipicamente persistence-oriented, potrebbe essere la seguente:
data class ContactInfo( val firstname: String, val middleInitial: String, val lastname: String, val email: String, val emailIsVerified: Boolean)
Questo codice esprime i requisiti? Certo, elenca tutti i dati richiesti. Ma quali sono i vincoli sui dati? I dati sono tutti obbligatori?
Possiamo fare di meglio e far si che il modello esprima queste informazioni.
Domain model dichiarativo
Primo, facciamo un richiamo ai fondamentali!
Coesione (Cohesion)
Alcuni dati sono in relazione tra loro. E’ una buona idea raggrupparli.
data class PersonalName( val firstname: String, val middleInitial: String, val lastname: String) data class Email( val value: String, val isVerified: Boolean) data class ContactInfo( val name: PersonalName, val email: Email)
In questo modo il modello esprime quali sono le relazioni tra le informazioni. Mantenere alta la coesione favorisce anche la riusabilità delle strutture dati.
Dichiarazione dei vincoli
Le stringhe di PersonalName
non possono essere vuote. Questo però non è chiaro dal codice. Vediamo come chiarirlo.
data class PersonalName( val firstname: NotEmptyString, val middleInitial: NotEmptyString?, // Qui si chiarisce che il dato è opzionale val lastname: NotEmptyString)
Faccio notare che middleInitial
ora è nullable, così da esprimere a compile-time l’opzionalità di questo dato. (E’ equivalente ad utilizzare un Optional
di Java).
Le stringhe native sono state wrappate da uno nuovo tipo NotEmptyString
, che assicura il rispetto del vincolo, come segue
inline class NotEmptyString @Deprecated(level = DeprecationLevel.ERROR, message = "use companion method 'of'") constructor(val value: String){ @Suppress("DEPRECATION_ERROR") companion object{ fun of(value: String): NotEmptyString = validate(NotEmptyString(value)){ validate(NotEmptyString::value).isNotBlank() } } }
Ho utilizzato una classe inline e ho dichiarato un errore di compilazione per impedire l’uso del costruttore. Per creare un’istanza forzo l’utilizzo del factory method of
, quindi la validazione viene eseguita per forza. Ho utilizzato Valiktor per implementare la validazione.
Le classi inline esistono in Kotlin proprio per wrappare i tipi di dato primitivi e renderli più espressivi. L’utilizzo dei dati primitivi in un modello di dominio è un code smell, noto come primitive obsession.
Rendiamolo sicuro a compile-time
Esaminiamo la firma del metodo NotEmptyString.of
.
fun of(value: String): NotEmptyString
Dichiara che ogni volta che viene fornita una string in input, restituisce un NotEmptyString
come output. Putroppo è una bugia, perchè qualora venisse fornita una stringa vuota, la validazione fallirebbe. In questo caso Valiktor lancerebbe un’ eccezione di tipo ConstraintViolationException
. Questa possibilità non è dichiarata nella firma, il chè potrebbe portare ad errori inattesi a run-time.
Correggiamo questo problema con la libreria Konad.
import io.konad.Result inline class NotEmptyString @Deprecated(level = DeprecationLevel.ERROR, message = "use companion method 'of'") constructor(val value: String){ companion object{ @Suppress("DEPRECATION_ERROR") fun of(value: String): Result<NotEmptyString> = valikate { validate(NotEmptyString(value)){ validate(NotEmptyString::value).isNotBlank() } } } } internal inline fun <reified T> valikate(valikateFn: () -> T): Result<T> = try{ valikateFn().ok() }catch (ex: ConstraintViolationException){ ex.constraintViolations .mapToMessage() .joinToString("\n") { "\t\"${it.value}\" of ${T::class.simpleName}.${it.property}: ${it.message}" } .error() }
Ho spostato la gestione dell’eccezione nella funzione helper, dal fantasioso nome, valikate
. Questa fa il catch dell’eccezione, formatta il messaggio di errore usando l’API di Valiktor, e infine wrappa tutto in una istanza di Result
di Konad.
Le funzioni builder ok()
e error()
sono degli extension method di Konad, che creano un Result.Ok
e un Result.Errors
, rispettivamente.
Analizziamo nuovamente la firma:
fun of(value: String): Result<NotEmptyString>
Ora la possibilità del fallimento è chiara. Result
è una classe sealed, che può essere Result.Ok
oppure Result.Errors
. Utilizzandola, la funziona diventa priva di side-effect.
Comporre un PersonalName
A questo punto, possiamo creare dei Result<NotEmptyString>
. Per creare un PersonalName
ci servono però dei NotEmptyString
. Se fosse sufficiente un solo Result<NotEmptyString>
, sarebbe facile trasformalo in un Result<PersonalName>
come segue:
val personalName: Result<PersonalName> = NotEmptyString.of("banana").map { name -> PersonalName(name) }
Questo metodo dovrebbe essere noto agli utilizzatori degli Optional
di Java.
Però a noi serve fornire tre Result<NotEmptyString>
al costruttore di PersonalName
. Il lettore più smaliziato potrebbe attendersi una lunga lista di flatMap.flatMap.map...
. Fortunamente, questo è il punto forte di Konad, che fornisce una API di composizione:
data class PersonalName( val firstname: NotEmptyString, val middleInitial: NotEmptyString?, val lastname: NotEmptyString){ companion object { fun of(firstname: String, lastname: String, middleInitial: String? = null): Result<PersonalName> = ::PersonalName.curry() .on(NotEmptyString.of(firstname)) .on(middleInitial?.run { NotEmptyString.of(middleInitial) } ?: null.ok()) .on(NotEmptyString.of(lastname)) .result } } // esempio di utilizzo when(val nameResult = PersonalName.of("Foo", "", "")){ is Result.Ok -> nameResult.value.toString() is Result.Errors -> nameResult.description("\n") }.run(::println)
Konad accumula gli errori, quindi l’istruzione println
nell’esempio stamperà la lista degli errori. In particolare, riporterà errore per lastname
e middleInitial
per i quali è stato fornita una stringa vuota.
Rimuoviamo i flag
Una e-mail può essere verificata, oppure no. Nel modello iniziale c’è il flag booleano isVerified
.
data class Email( val value: String, val isVerified: Boolean)
Questo approccio non è type-safe. Dovremo verificare il valore di questo flag in ogni funzione che richieda una mail verificata. Ad esempio:
fun sendPasswordRecovery(email: Email) { if(email.verified) sendTo(email.value) }
Questo codice non può essere controllato a compile-time. L’ approccio che suggerisco è quello di utilizzare un nuovo tipo per lo stato Verified
e uno per lo stato Unverified
.
sealed class Email(open val value: String){ data class Verified(private val email: Unverified): Email(email.value) data class Unverified(override val value: String): Email(value) }
La funzione sendPasswordRecovery
cambia come segue:
fun sendPasswordRecovery(email: Email.Verified) { sendTo(email.value) }
L’ istruzione if
è stata rimossa, e non c’è più possibilità che qualcuno possa dimenticare di controllare lo stato di verifica di una mail. Inoltre non è più necessario leggere l’implementazione, per capire che solo con mail verificate è possibile eseguire la procedura di recupero della password.
Validare il formato della e-mail
Le e-mail hanno un formato preciso. Similmente a NotEmptyString
, dobbiamo implementarne la validazione. Una mail di tipo Verified
può essere istanziata solo a partire da una di tipo Unverified
. Ne segue che è sufficiente implementare la validazione solo per le mail di tipo Unverified
.
sealed class Email(val value: String){ data class Verified(private val email: Unverified): Email(email.value) class Unverified private constructor(value: String): Email(value){ companion object{ fun of(value: String): Result<Email.Unverified> = valikate { validate(Email.Unverified(value)){ validate(Email.Unverified::value).isEmail() } } } } }
Il modello in forma dichiarativa
Ecco come si presenta il modello dopo le modifiche:
data class ContactInfo( val name: PersonalName, val email: Email) data class PersonalName( val firstname: NotEmptyString, val middleInitial: NotEmptyString?, val lastname: NotEmptyString){ ... // construction code here } sealed class Email(val value: String){ data class Verified(private val email: Unverified): Email(email.value) class Unverified private constructor(value: String): Email(value){ ... // construction code here } }
Dalla lettura del modello, si possono comprendere tutti i requisiti. Un contatto è formato da un nome di persona e da una e-mail. Un nome di persona e composto da tre stringhe non vuote: nome, cognome e iniziale. L’iniziale potrebbe mancare. Una mail può essere verificata oppure no.
Vediamo qualche esempio di servizio:
class PasswordResetService(){ fun send(email: Email.Verified): Unit = TODO("send reset") } class EmailVerificationService(){ fun verify(unverifiedEmail: Email.Unverified): Email.Verified? = if(incredibleConditions())Email.Verified(unverifiedEmail) else null private fun incredibleConditions() = true }
Vediamo infine come istanziare un ContactInfo
val contact: Result<ContactInfo> = ::ContactInfo.curry() .on(PersonalName.of("Foo", "Bar", "J.")) .on(Email.Unverified.of("foo.bar@gmail.com")) .result
Conclusioni
In questo articolo propongo un approccio alla modellazione del dominio. Il principale vantaggio è di migliorare l’efficacia nell’ espressione di:
- struttura dei dati;
- vincoli sui dati;
- il flusso dei processi che coinvolgono tali dati.
Queste informazioni sono dichiarate nelle firme del modello e delle funzioni. Non è quindi necessario entrare nei dettagli d’implementazione dei metodi, per ottenerle.
Non è banale implementare un design che, a compile-time, aumenti la confidenza della correttezza di esecuzione. Qui ho proposto un esempio di implementazione, utilizzando:
- Valiktor per implementare le validazioni, in poche e leggibili linee di codice.
- Konad per migliorare la confidenza a compile-time.
L’utilizzo delle monadi rende il codice più complesso. Usando Konad, ci si avvantaggia dell’aiuto del compilatore, pur mantendendo un basso impatto sulla complessità. L’API di composizione di Konad è di facile uso, e non richiede conoscenze di programmazione funzionale.
Codice e materiale
Segue il codice di un esempio completo. Può essere scaricato a questo repo github: https://github.com/lucapiccinelli/typesafe-domain-model.
fun main(args: Array<String>) { val contactResult: Result<ContactInfo> = ::ContactInfo.curry() .on(PersonalName.of("Foo", "Bar", "J.")) .on(Email.Unverified.of("foo.bar@gmail.com")) .result contactResult .map { contact -> when(contact.email){ is Email.Unverified -> EmailVerificationService.verify(contact.email) is Email.Verified -> contact.email } } .map { verifiedMail: Email.Verified? -> verifiedMail ?.run { PasswordResetService.send(verifiedMail) } ?: println("Email was not verified") } .ifError { errors -> println(errors.description("\n")) } } object PasswordResetService{ fun send(email: Email.Verified): Unit = println("send reset to ${email.value}") } object EmailVerificationService{ fun verify(unverifiedEmail: Email.Unverified): Email.Verified? = if(incredibleConditions())Email.Verified(unverifiedEmail) else null private fun incredibleConditions() = true } data class ContactInfo( val name: PersonalName, val email: Email) data class PersonalName( val firstname: NotEmptyString, val middleInitial: NotEmptyString?, val lastname: NotEmptyString){ companion object { fun of(firstname: String, lastname: String, middleInitial: String? = null): Result<PersonalName> = ::PersonalName.curry() .on(NotEmptyString.of(firstname)) .on(middleInitial?.run { NotEmptyString.of(middleInitial) } ?: null.ok()) .on(NotEmptyString.of(lastname)) .result } } sealed class Email(val value: String){ data class Verified(private val email: Unverified): Email(email.value) class Unverified private constructor(value: String): Email(value){ companion object{ fun of(value: String): Result<Email.Unverified> = valikate { validate(Email.Unverified(value)){ validate(Email.Unverified::value).isEmail() } } } } } inline class NotEmptyString @Deprecated(level = DeprecationLevel.ERROR, message = "use companion method 'of'") constructor(val value: String){ companion object{ @Suppress("DEPRECATION_ERROR") fun of(value: String): Result<NotEmptyString> = valikate { validate(NotEmptyString(value)){ validate(NotEmptyString::value).isNotBlank() } } } } internal inline fun <reified T> valikate(valikateFn: () -> T): Result<T> = try{ valikateFn().ok() }catch (ex: ConstraintViolationException){ ex.constraintViolations .mapToMessage() .joinToString("\n") { "\t\"${it.value}\" of ${T::class.simpleName}.${it.property}: ${it.message}" } .error() }
Riconoscimenti
Questo articolo è basato su concetti originariamente divulgati da Scott Wlaschin tramite il talk “Domain modeling made functional“. Consiglio caldamente la visione del video.
Grazie per la lettura.