Dieser Artikel wurde ursprünglich auf 47deg.com am 11. Februar 2021 veröffentlicht.
Bei Xebia legen wir großen Wert auf die Domänenmodellierung, um unsere Domäne so genau wie möglich zu beschreiben.
Das Ziel der funktionalen Domänenmodellierung ist es, Ihre Geschäftsdomäne so genau wie möglich zu beschreiben, um mehr Typsicherheit zu erreichen, die Nutzung des Compilers mit unserer Domäne zu maximieren und so Fehler zu vermeiden und Unit-Tests zu reduzieren. Außerdem wird die Kommunikation über die Domäne erleichtert, da die Domäne der Berührungspunkt mit der realen Welt ist.
[Kotlin] (https://xebia.com/blog/7-common-mistakes-in-kotlin/ "Kotlin") eignet sich gut für die funktionale Domänenmodellierung. Es bietet uns data class, sealed class, enum class, und inline class. Und wir haben Arrow, das uns einige interessante generische Datentypen wie Either, Validated, Ior, usw. bietet.
In einigen Codebases finden Sie die folgende, auf einem primitiven Typ basierende Implementierung eines Event:
data class Event(
val id: Long
val title: String,
val organizer: String,
val description: String,
val date: LocalDate
)
Die hier verwendeten Typen haben wenig oder keine Bedeutung, da title, organizer und description alle den gleichen Typ haben.
Das macht unseren Code anfällig für subtile Fehler, bei denen wir uns möglicherweise auf title statt auf description verlassen, und der Compiler wäre nicht in der Lage, uns zu helfen.
Schauen wir uns ein Beispiel an, bei dem etwas schief geht, ohne dass der Compiler uns helfen kann.
Event(
0L,
"Simon Vergauwen",
"In this blogpost we dive into functional DDD...",
"Functional Domain Modeling",
LocalDate.now()
)
Hier haben wir organizer, description verwechselt, aber der Compiler ist zufrieden und konstruiert das Objekt Event. Es gibt noch mehr Fälle, in denen Sie in diese Falle tappen können, zum Beispiel bei der Destrukturierung.
Wie können wir dies also verhindern oder unser Domänenmodell verbessern, damit es besser typisiert ist? Lassen Sie uns eine noch experimentelle, aber sehr aufregende, kommende Funktion von Kotlin verwenden: data class ersetzen, wenn Sie sich in Ihrer Codebasis noch nicht auf @Experimentalcode> @Experimental </code verlassen wollen.)
inline class EventId(val value: Long)
inline class Organizer(val value: String)
inline class Title(val value: String)
inline class Description(val value: String)
data class Event(
val id: EventId
val title: Title,
val organizer: Organizer,
val description: Description,
val date: LocalDate
)
Wenn wir zu unserem vorherigen Beispiel zurückkehren, kann der Compiler jetzt nicht kompilieren, da wir Organizer übergeben, wo Title erwartet wird, Description, wo Organizer erwartet wird, und so weiter.
Event(
EventId(0L),
Organizer("Simon Vergauwen"),
Description("In this blogpost we dive into functional DDD..."),
Title("Functional Domain Modeling"),
LocalDate.now()
)
In der funktionalen Programmierung ist diese Art der Datenzusammensetzung auch als product type oder record bekannt, die eine UND-Beziehung modelliert. Wir können also sagen, dass eine Event besteht aus einem EventId UND ein Title UND ein Organizer UND ein Description UND ein LocalDatewas uns viel mehr sagt als ein Event die aus einem Long UND ein String UND ein String UND ein String UND ein LocalDate.
Nehmen wir an, dass wir unser Event Modell weiterentwickeln müssen, um alle Altersbeschränkungen im Auge zu behalten. Wir könnten dies wieder mit String modellieren, aber das würde unser ursprüngliches Problem nur verschlimmern. Nehmen wir also an, wir orientieren uns an den MPAA-Filmbewertungen, die eine Aufzählung von 5 verschiedenen Fällen darstellen.
Da wir eindeutig von einer festen Menge von Fällen oder einer Aufzählung sprechen, verwenden wir enum class.
enum class AgeRestriction(val description: String) {
General("All ages admitted. Nothing that would offend parents for viewing by children."),
PG("Some material may not be suitable for children. Parents urged to give "parental guidance""),
PG13("Some material may be inappropriate for children under 13. Parents are urged to be cautious."),
Restricted("Under 17 requires accompanying parent or adult guardian. Contains some adult material."),
NC17("No One 17 and Under Admitted. Clearly adult.")
}
Die Verwendung von enum class ist aus Gründen, die über die oben bereits erläuterten Probleme hinausgehen, viel leistungsfähiger als String. Ein AgeRestriction nachzudenken, als mit String zu argumentieren und zu arbeiten.
In der funktionalen Programmierung wird diese Art der Datenkomposition auch als sum type bezeichnet, die eine ODER-Beziehung modelliert. Wir können also sagen, dass eine AgeRestriction ist entweder General OR PG OR PG13 OR Restricted OR NC17. Das sagt uns viel mehr, als wenn es sich nur um ein String handeln würde. Ein sum types kann also die Komplexität unserer Typen drastisch reduzieren.
Da Online-Events auf dem Vormarsch sind, haben wir es mit einer anderen Art von Veranstaltung zu tun, die nicht auf einer Address stattfindet, sondern auf einer bestimmten Url. Je nachdem, um welche Art von Event es sich handelt, werden die darin enthaltenen Daten also leicht unterschiedlich sein. Naiv betrachtet könnten wir dies wie folgt implementieren:
inline class Url(val value: String)
inline class City(val value: String)
inline class Street(val value: String)
data class Address(val city: City, val street: Street)
data class Event(
val id: EventId
val title: Title,
val organizer: Organizer,
val description: Description,
val date: LocalDate,
val ageRestriction: AgeRestriction,
val isOnline: Boolean,
val url: Url?,
val address: Address?
)
Dies ist eine gängige Kodierung, die jedoch recht problematisch sein kann. Je nachdem, ob isOnline sind jedoch sowohl url als auch address immer noch Null, so dass wir am Ende einen Code wie diesen erhalten.
fun printLocation(event: Event): Unit =
if(event.isOnline) {
event.url?.value?.let(::println)
} else {
event.address?.let(::println)
}
Aber noch schlimmer ist, dass wir den beabsichtigten Vertrag auch leicht brechen können, wie in dem folgenden Beispiel.
Event(
Id(0L),
Title("Functional Domain Modeling"),
Organizer("47 Degrees"),
Description("Building software with functional DDD..."),
LocalDate.now(),
AgeRestriction.General,
true,
null,
null
)
Der Compiler ist mit der untenstehenden Definition zufrieden, obwohl unser beabsichtigter Vertrag besagt, dass, wenn es isOnline ist, dann url non-null sein würde. Wir können dieses Problem verhindern, indem wir ein sealed class einführen, um Event.Online und Event.AtAddress auf typisierte Weise zu kombinieren.
sealed class Event {
abstract val id: EventId
abstract val title: Title
abstract val organizer: Organizer
abstract val description: Description
abstract val ageRestriction: AgeRestriction
abstract val date: LocalDate
data class Online(
override val id: EventId,
override val title: Title,
override val organizer: Organizer,
override val description: Description,
override val ageRestriction: AgeRestriction,
override val date: LocalDate,
val url: Url
) : Event()
data class AtAddress(
override val id: EventId,
override val title: Title,
override val organizer: Organizer,
override val description: Description,
override val ageRestriction: AgeRestriction,
override val date: LocalDate,
val address: Address
) : Event()
}
Dies löst nicht nur das Problem, das wir vorher hatten, nämlich dass wir eine Online Event ohne Url instanziieren können, sondern es bietet auch eine viel schönere Art, mit den Daten zu arbeiten. Anstelle von if(event.isOnline) können wir jetzt ein erschöpfendes when verwenden, um einen Mustervergleich mit Event durchzuführen, und dank des intelligenten Castings von Kotlin können wir sicher auf url zugreifen, falls es Event.Online ist.
fun printLocation(event: Event): Unit =
when(event) {
is Online -> println(event.url.value)
is AtAddress -> println("${event.address.city}: ${event.address.street}")
}
Diese Art der Datenkomposition ist auch als sum type bekannt, die eine ODER-Beziehung modelliert, aber sealed class bietet uns mächtigere Möglichkeiten als enum class. Ein sealed class ermöglicht es, dass unsere sum oder Fälle aus object, data class oder sogar aus einem anderen sealed class heraus existieren. Eine enum class kann keine andere Klasse erweitern, also kann sie kein Fall einer sealed class sein. Hier kann unsere sealed class besteht aus 2 Fällen, einer Online OR AtAddress Event, wobei Online und AtAddress sind Produkttypen von mehreren anderen Typen. Eine Faustregel in Kotlin lautet, enum class zu verwenden, wenn die Cases keine Daten enthalten oder, mit anderen Worten, wenn alle Cases als Objekt modelliert werden können.
Wie wir bereits in den obigen Beispielen gesehen haben, hat die genaue Modellierung Ihrer Domäne viele Vorteile. Sie kann bestimmte Fehler beseitigen, z. B. die falsche Instanziierung von Daten. Durch die Eliminierung ungültiger Werte ist unser Code/Modell leichter zu verstehen, und der Code, der sich auf unsere Modelle stützt, kann verbessert werden.
Schauen wir uns an, wie wir die Datentypen von Arrow verwenden können, um Domänenprobleme in unserem Code weiter zu klären. In unserem Programm haben wir einige EventService, die auf der Grundlage eines EventId eine kommende Event abrufen können.
interface EventService {
suspend fun fetchUpcomingEvent(id: EventId): Event
}
Was in unserem EventService völlig fehlt, sind die verschiedenen Arten von Fehlerszenarien, denen wir begegnen könnten. Sie werden nur durch
Hier modellieren wir 2 verschiedene Fälle:
- Ein Ereignis wird nicht gefunden.
- Ein Ereignis steht nicht mehr bevor, sondern hat bereits stattgefunden.
sealed class Error {
data class EventNotFound(val id: EventId): Error()
data class EventPassed(val event: Event): Error()
}
Wir können diese beiden getrennten Domänen, Error und Event, mit Either aus Arrow Core zusammensetzen. Dadurch können wir eine ODER-Beziehung modellieren, was bedeutet, dass fetchUpcomingEvent entweder ein Error oder ein Event zurückgibt, aber niemals beides. Either ist also eine generische sum type, die es uns ermöglicht, zwei separate Domänen in einer ODER-Beziehung miteinander zu verknüpfen.
Wenn wir also unsere EventService aktualisieren:
interface EventService {
suspend fun fetchUpcomingEvent(id: EventId): Either<Error, Event>
}
Da Either in Arrow Core als sealed class definiert ist, können wir die gleiche Technik wie oben bei when anwenden, um Error oder Event auf sichere Weise zu extrahieren. Arrow Core ist ein eigenständiges Modul, so dass Sie es wie folgt abrufen können:
depdendencies {
def arrowVersion = "0.11.0"
implementation "io.arrow-kt:arrow-core:$arrowVersion"
}
In diesem Beitrag haben wir gesehen, wie wir unsere Domain verbessern können, indem wir:
- Eliminierung von primitiven Typen in unserer Domänendefinition und Verwendung von
inline class, um Laufzeit-Overhead zu vermeiden. - Verwenden Sie
enum classundsealed class, um Disjunktionen in unserer Domäne zu modellieren, z.B. dass bestimmte Daten je nach Typ vonEventverfügbar sind. - Verwendung von Arrow's
Either, um zwei verschiedene Domänen mit einer ODER-Beziehung zusammenzusetzen.
In zukünftigen Blog-Beiträgen werden wir erörtern, wie wir unsere Domänenmodelle noch weiter verbessern können, indem wir andere Arten von Arrow verwenden und andere Techniken wie die Typverfeinerung einsetzen, um unsere Modelle noch weiter einzuschränken.
Verfasst von
Simon Vergauwen
Arrow maintainer & Principal Engineer @ Xebia Functional from Antwerp, Belgium Working with Kotlin since 2015 & Fanatic OSS'er
Unsere Ideen
Weitere Blogs
Contact




