Lag domeneobjekter som betyr noe. I denne bloggposten gir deg tre enkle tips for smarte og bedre typer. Tipsene er inspirert av domenedreven design og funksjonell programmering. De kan brukes i de fleste moderne programmeringsspråk 👉
Introduksjon
Har du noen gang fått en NullPointerException på et uventet sted? Eller lagt inn en ekstra validering på userId-variabelen, bare for å være helt sikker? Og har plutselig de fine domeneobjektene dine endt opp med flere boolske flagg for å skille ulike varianter fra hverandre? Hvis du har skrevet kode, har du mest sannsynlig opplevd dette. Men fortvil ikke, i denne bloggposten utforsker vi domenemodeller, og du får med deg tre gode tips på hvordan du kan modellere de smartere.
La oss først definere hva en domenemodell er: en representasjon av en eller annen form for problemområde. Den representerer viktige konsepter, regler og relasjoner og fungerer som en mental bro mellom koden og den fysiske verden. Et eksempel på dette kan være julenissens applikasjon for å bestemme hvilke barn som får gaver i år med tilhørende domenemodell:
// Nissens domenemodell for hvilke barn som får innvilget gaveønsker
data class Barn(val id: String, val snillhetsscore: Int)
data class Gaveønske(
val ting: String,
val kvantum: Int
)
fun beregnGaverForBarn(gaveønsker: List<Gaveønske>, barn: Barn): List<Gave>
Å skrive gode domenemodeller gjør at det blir lettere å resonere rundt koden vi skriver og applikasjonene vi leverer. Det gjelder både blant utviklere, men også når vi kommuniserer med designere, produkteiere og andre interessenter. Innenfor domenedrevet design (DDD) vektlegges det å skrive domenemodeller så “naturtro” som mulig, samtidig som man bruker et felles begrepsapparat som alle forstår.
I smidig utvikling ønsker vi å jobbe som tett som mulig på brukerne og interessentene av systemet. Vi ønsker å kjapt kunne iterere på det vi leverer i produksjon med små justeringer. Har vi et felles begrepsapparat og en naturtro domenemodell så kan dette bidra til å gjøre prosessen smidigere.
Tre tips til Ascipot
Inspirert av en annen magisk julefortelling, så presenterer jeg tre tips du kan bruke for å lage smartere objekter/typer i koden din. Tipsene er inspirert av prinsipper fra DDD og funksjonell programmering.
For å illustrere tipsene har selveste julenissen bygd opp et større system for å håndtere gavelevering til snille barn og registrering av disse. Kodeeksemplene er skrevet i kotlin, men teknikkene som brukes kan anvendes i de fleste programmeringspråk.
Tips 1: Value Object
Sentralt i nissesystemet har nissen laget en funksjon for å registere vellykkede gaveleveringer. Funksjonen tar imot identifikatoren til hjelpenissen og den aktuelle pipen der gaven har blitt levert.
fun registerGavelevering(String nisseId, String pipeId)
I implementasjonen av denne funksjonen kan vi se for oss noen potensielle problemer:
- Er ID-ene definerte og korrekte? Har de blitt validert tidligere i kjeden eller ikke? I større systemer er det ikke alltid mulig å ha kontroll, og da ender man gjerne opp med defensiv kode og på den måte “forsøpler” det som kunne vært renere domenelogikk.
- Det kan også være fort gjort å mikse rekkefølgen på parametrene som man sender inn. Hva skjer om man sender inn en nisseId som pipeId og vice versa?
Nei, her bør vi nok la kompilatoren ta seg av disse problemene. Møt value class i kotlin.
@JvmInline
value class NisseId(val value: String)
@JvmInline
value class PipeId(val value: String)
Dette gjør at vi kan endre funksjonssignaturen vår til:
fun registerGavelevering(NisseId nisseId, PipeId pipeId)
Når vi kaller på funksjonen blir vi nå tvunget av kompilatoren til å sende med forventet type:
val nisseId = NisseId("Rudolfsen")
val pipeId = PipeId("123")
registerGavelevering(nisseId, pipeId)
// Følgende ville nå gitt kompileringsfeil:
registerGavelevering("123", "Rudolfsen")
registerGavelevering(pipeId, nisseId)
Gevinsten med å gjøre det på denne måten er at vi øker type-sikkerheten: vi garanterer at vi får inn forventet type og ikke hvilket som helst string. I tillegg sørger JvmInline
for at ytelsen ikke blir forringet.
Tips 2: Private konstruktører og invarianter
Nissens HR-ansvarlige har gitt beskjed om at det har blitt krøll med utregningen av akkord-utbetalingen til nissehjelperne. Det har blitt registert mange ugyldige nisseId-er! Nisseid en et løpenummer og i følgen nissens HR-ansvarlige et primtall mellom 1 000-9 999.
Vår første innskytelse ville kanskje vært å implementert en validering direkte i registerGavelevering-funksjonen. Men hva om vi bruker disse idene flere steder? Må vi huske på å implementere valideringen alle steder vi bruker variabelene? Eller anta at de allerede er validerte, noe som før eller siden kan føre til feil.
La oss utforske om kompilatoren kan hjelpe oss her også med en teknikk som kan kalles flere ting; invariant, factory-method, private constuctor etc. Formålet er å sikre at typen kun kan bli skapt med gyldige verdier, ellers vil en feil bli kastet ved opprettelse. Da kan vi senere i kjeden være garantert at invarianten holder, og heller fokusere på det som virkelig betyr noe: forretningslogikken.
@JvmInline
value class NisseId private constructor(val value: Int) {
companion object {
fun create(value: String): NisseId {
val number = value.toIntOrNull()
?: throw IllegalArgumentException("Value must be a valid integer")
if (number !in 1000..9999) {
throw IllegalArgumentException("Value must be between 1000 and 9999")
}
if (!isPrime(number)) {
throw IllegalArgumentException("Value must be a prime number")
}
return NisseId(number)
}
}
}
Her tar vi i bruk en privat konstruktør, som umuliggjør å lage klassen utenifra, slik at den eneste måten å lage klassen på er via create
funksjonen. Slik sørger vi for at vi invarianten alltid være sann for alle instanser av NisseId.
Tips 3 - Union-typer
Regnskapskontoret til nissen har ringt inn en request; de ønsker å differensere utbetalingen til nissens hjelpere basert på hvilken bakgrunn de har. En første innskytelse ville kanskje vært å introdusert en boolean på NisseHjelper-typen:
data class Nissehjelper(val id: String, val erAlv: boolean, val erReinsdyr: boolean, val erFugl: boolean)
fun beregnLønn(hjelper: Nissehjelper) {
if (hjelper.erAlv) return 5
if (hjelper.erReinsdyr) return 4
if (hjelper.erFugl) return 7
}
Her kan vi se for oss noen problemer. Her er det fullt mulig at vi ender opp med “umulige” tilstander ved at en hjelper både er en alv og et reinsdyr. I tillegg blir det mye støy i koden ved å holde styr på alle disse bolske flaggene.
La oss se på hvordan vi kan representere en begrenset mengde med alternativer i kotlin. Ved å ta i bruk Sealed Classes kan vi definere en felles klasse som begrenser hvilke klasser som kan arve fra den. Dette gjør at vi kan representere et fast sett av mulige alternativer, men med flere fordeler enn en enum
. De forskjellige mengdene kan ha ulike datafelter og kompilatoren hjelper (eller tvinger) oss til å håndtere alle mulige alternativer.
sealed class Nissehjelper {
object Elf : Nissehjelper()
object Reindeer : Nissehjelper()
object Hawk : Nissehjelper()
}
fun beregnLønn(hjelper: Nissehjelper): Int {
return when (hjelper) {
Nissehjelper.Elf -> 5
Nissehjelper.Reindeer -> 4
Nissehjelper.Hawk -> 7
}
}
Ved å bruke sealed classes oppnår vi en domenemodell der kun lovlige tilstander er mulig å produsere, vi får hjelp av kompilatoren til å sjekke at alle mulige tilstander er håndtert.
Avslutning
I de tre tipsene har vi sett på hvordan vi kan modellere domenemodellene våre slik at vi får en mer typesikker kode, og at vi kan fokusere mer på det som gir faktisk verdi - forretningslogikken. Vi slipper å skrive mange defensive tester da typenene våre sikrer invarianten. Vi har anvendt prinsipper fra DDD og funksjonell programering, og har sett på implementasjon av dette. Synes du dette var spennende anbefaler jeg å lese mer om domenedreven design.
God koding!