Worum geht es hier?
Nachdem ich an verschiedenen Projekten gearbeitet habe, habe ich festgestellt, dass jedes von ihnen einige häufige Probleme hatte, unabhängig von Domäne, Architektur, Codekonvention und so weiter. Diese Probleme waren keine Herausforderung, sondern nur eine mühsame Routine: Stellen Sie sicher, dass Sie nichts Dummes und Offensichtliches verpasst haben. Anstatt diese Routine täglich durchzuführen, war ich besessen davon, nach einer Lösung zu suchen: einem Entwicklungsansatz oder einer Codekonvention oder was auch immer, der mir hilft, ein Projekt so zu gestalten, dass diese Probleme nicht auftreten, damit ich mich auf interessante Dinge konzentrieren kann . Das ist das Ziel dieses Artikels: Diese Probleme zu beschreiben und Ihnen die Mischung aus Werkzeugen und Ansätzen zu zeigen, die ich gefunden habe, um sie zu lösen.
Probleme, mit denen wir konfrontiert sind
Bei der Entwicklung von Software stoßen wir auf viele Schwierigkeiten: unklare Anforderungen, Missverständnisse, schlechter Entwicklungsprozess und so weiter.
Wir haben auch einige technische Schwierigkeiten: Legacy-Code verlangsamt uns, Skalierung ist schwierig, einige schlechte Entscheidungen der Vergangenheit treten uns heute in die Zähne.
Alle von ihnen können, wenn sie nicht beseitigt werden, erheblich reduziert werden, aber es gibt ein grundlegendes Problem, gegen das Sie nichts tun können: die Komplexität Ihres Systems.
Die Idee eines Systems, das Sie selbst entwickeln, ist immer komplex, ob Sie es verstehen oder nicht.
Selbst wenn Sie eine weitere CRUD-Anwendung erstellen , gibt es immer einige Randfälle, einige knifflige Dinge, und von Zeit zu Zeit fragt jemand: "Hey, was passiert, wenn ich dies und das unter diesen Umständen mache?" und du sagst "Hm, das ist eine sehr gute Frage."
Diese kniffligen Fälle, die zwielichtige Logik, die Validierung und die Zugriffsverwaltung - all das summiert sich zu Ihrer großen Idee.
Sehr oft ist diese Idee so groß, dass sie nicht in einen Kopf passt, und diese Tatsache allein bringt Probleme wie Missverständnisse mit sich.
Aber seien wir großzügig und gehen wir davon aus, dass dieses Team von Domain-Experten und Business-Analysten klar kommuniziert und feine, konsistente Anforderungen erstellt.
Jetzt müssen wir sie implementieren, um diese komplexe Idee in unserem Code auszudrücken. Nun, dieser Code ist ein anderes System, viel komplizierter als die ursprüngliche Idee, die wir uns vorgestellt hatten.
Wie so? Es ist Realität: Technische Einschränkungen zwingen Sie dazu, sich zusätzlich zur Implementierung der tatsächlichen Geschäftslogik mit hoher Auslastung, Datenkonsistenz und Verfügbarkeit zu befassen.
Wie Sie sehen, ist die Aufgabe ziemlich herausfordernd, und jetzt brauchen wir geeignete Werkzeuge, um sie zu bewältigen.
Eine Programmiersprache ist nur ein weiteres Werkzeug, und wie bei jedem anderen Werkzeug geht es nicht nur um die Qualität, sondern wahrscheinlich noch mehr um das Werkzeug, das zum Job passt. Sie haben vielleicht den besten Schraubenzieher, den es gibt, aber wenn Sie ein paar Nägel in Holz stecken müssen, ist ein beschissener Hammer besser, oder?
Technische Aspekte
Die beliebtesten Sprachen sind heute objektorientiert. Wenn jemand eine Einführung in OOP macht, verwendet er normalerweise Beispiele:
Stellen Sie sich ein Auto vor, das ein Objekt aus der realen Welt ist. Es hat verschiedene Eigenschaften wie Marke, Gewicht, Farbe, Höchstgeschwindigkeit, aktuelle Geschwindigkeit und so weiter.
Um dieses Objekt in unserem Programm wiederzugeben, sammeln wir diese Eigenschaften in einer Klasse. Eigenschaften können permanent oder veränderlich sein, die zusammen sowohl den aktuellen Status dieses Objekts als auch einige Grenzen bilden, in denen es variieren kann. Das Kombinieren dieser Eigenschaften reicht jedoch nicht aus, da wir prüfen müssen, ob der aktuelle Status sinnvoll ist, z. B. dass die aktuelle Geschwindigkeit die maximale Geschwindigkeit nicht überschreitet. Um sicherzustellen, dass wir dieser Klasse eine Logik hinzufügen, markieren Sie Eigenschaften als privat, um zu verhindern, dass jemand einen illegalen Status erstellt.
Wie Sie sehen können, handelt es sich bei Objekten um ihren internen Zustand und ihren Lebenszyklus.
Diese drei Säulen von OOP sind in diesem Zusammenhang durchaus sinnvoll: Wir verwenden Vererbung, um bestimmte Zustandsmanipulationen wiederzuverwenden, Kapselung zum Schutz des Zustands und Polymorphismus, um ähnliche Objekte auf die gleiche Weise zu behandeln. Die Veränderlichkeit als Standard ist ebenfalls sinnvoll, da unveränderliche Objekte in diesem Zusammenhang keinen Lebenszyklus haben können und immer einen Zustand haben, was nicht der häufigste Fall ist.
Wenn Sie sich eine typische Webanwendung dieser Tage ansehen, werden keine Objekte behandelt. Fast alles in unserem Code hat entweder eine ewige Lebensdauer oder überhaupt keine richtige Lebensdauer. Zwei der häufigsten Arten von "Objekten" sind Dienste wie UserService
, EmployeeRepository
oder einige Modelle / Entitäten / DTOs oder wie auch immer Sie sie nennen. Services haben keinen logischen Status in sich, sie sterben und werden genau gleich wiedergeboren. Wir erstellen einfach das Abhängigkeitsdiagramm mit einer neuen Datenbankverbindung neu.
Entitäten und Modelle haben kein Verhalten, sie sind lediglich Datenbündel, ihre Veränderlichkeit hilft nicht, aber im Gegenteil.
Daher sind die Hauptfunktionen von OOP für die Entwicklung dieser Art von Anwendungen nicht wirklich nützlich.
Was in einer typischen Web-App passiert, ist der Datenfluss: Validierung, Transformation, Auswertung und so weiter. Und es gibt ein Paradigma, das perfekt zu dieser Art von Arbeit passt: funktionale Programmierung. Und dafür gibt es einen Beweis: Alle modernen Funktionen in den heutigen populären Sprachen kommen von dort: async/await
, Lambdas und Delegierte, reaktive Programmierung, diskriminierte Gewerkschaften (Aufzählungen in schnell oder rostig, nicht zu verwechseln mit Aufzählungen in Java oder .net) ), Tupel - alles was von FP ist.
Das sind jedoch nur Krümel, es ist sehr schön, sie zu haben, aber es gibt noch viel mehr.
Bevor ich tiefer gehe, muss noch ein Punkt angesprochen werden. Der Wechsel zu einer neuen Sprache, insbesondere zu einem neuen Paradigma, ist eine Investition für Entwickler und damit für Unternehmen. Wenn Sie dumme Investitionen tätigen, werden Sie nur Probleme haben, aber vernünftige Investitionen können genau das sein, was Sie über Wasser hält.
Viele von uns bevorzugen Sprachen mit statischer Typisierung. Der Grund dafür ist einfach: Der Compiler kümmert sich um langwierige Überprüfungen wie das Übergeben geeigneter Parameter an Funktionen, das korrekte Erstellen unserer Entitäten usw. Diese Schecks sind kostenlos. Was nun die Dinge betrifft, die der Compiler nicht überprüfen kann, haben wir die Wahl: auf das Beste hoffen oder einige Tests durchführen. Das Schreiben von Tests bedeutet Geld, und Sie zahlen nicht nur einmal pro Test, sondern müssen sie warten. Außerdem werden die Leute schlampig, so dass wir von Zeit zu Zeit falsch positive und falsch negative Ergebnisse erhalten. Je mehr Tests Sie schreiben müssen, desto geringer ist die durchschnittliche Qualität dieser Tests. Es gibt noch ein anderes Problem: Um etwas zu testen, müssen Sie wissen und sich daran erinnern, dass dieses Ding getestet werden sollte, aber je größer Ihr System ist, desto einfacher ist es, etwas zu übersehen.
Der Compiler ist jedoch nur so gut wie das Typsystem der Sprache. Wenn es Ihnen nicht erlaubt, etwas statisch auszudrücken, müssen Sie dies zur Laufzeit tun. Was bedeutet, Tests, ja. Es geht aber nicht nur um das Typsystem, sondern auch um die Syntax und die Funktionen für kleinen Zucker, denn letztendlich möchten wir so wenig Code wie möglich schreiben. Wenn Sie also bei einem Ansatz zehnmal mehr Zeilen schreiben müssen, niemand wird es benutzen. Aus diesem Grund ist es wichtig, dass die von Ihnen gewählte Sprache über die passenden Funktionen und Tricks verfügt - insgesamt der richtige Fokus. Wenn dies nicht der Fall ist - anstatt seine Funktionen zu verwenden, um originelle Herausforderungen wie die Komplexität Ihres Systems und sich ändernde Anforderungen zu bewältigen, werden Sie auch gegen die Sprache kämpfen. Und alles hängt vom Geld ab, da Sie die Entwickler für ihre Zeit bezahlen. Je mehr Probleme sie lösen müssen, desto mehr Zeit benötigen sie und desto mehr Entwickler werden Sie benötigen.
Schließlich werden wir einen Code sehen, um all das zu beweisen. Ich bin zufällig ein .NET-Entwickler, daher werden Codebeispiele in C # und F # vorliegen, aber das allgemeine Bild würde in anderen gängigen OOP- und FP-Sprachen mehr oder weniger gleich aussehen.
Lassen Sie die Codierung beginnen
Wir werden eine Webanwendung für die Verwaltung von Kreditkarten erstellen.
Grundvoraussetzungen:
- Benutzer erstellen / lesen
- Kreditkarten erstellen / lesen
- Kreditkarten aktivieren / deaktivieren
- Legen Sie das Tageslimit für Karten fest
- Guthaben aufladen
- Zahlungen verarbeiten (unter Berücksichtigung des Guthabens, des Ablaufdatums der Karte, des aktiven / deaktivierten Status und des Tageslimits)
Der Einfachheit halber verwenden wir eine Karte pro Konto und überspringen die Autorisierung. Im Übrigen werden wir eine leistungsfähige Anwendung mit Validierung, Fehlerbehandlung, Datenbank und Web-API erstellen. Kommen wir also zu unserer ersten Aufgabe: Entwerfen Sie Kreditkarten.
Lassen Sie uns zunächst sehen, wie es in C # aussehen würde
public class Card { public string CardNumber {get;set;} public string Name {get;set;} public int ExpirationMonth {get;set;} public int ExpirationYear {get;set;} public bool IsActive {get;set;} public AccountInfo AccountInfo {get;set;} } public class AccountInfo { public decimal Balance {get;set;} public string CardNumber {get;set;} public decimal DailyLimit {get;set;} }
Aber das ist nicht genug, wir müssen eine Validierung hinzufügen, und FluentValidation
wird dies in einem Validator
, wie dem von FluentValidation
.
Die Regeln sind einfach:
- Die Kartennummer ist erforderlich und muss eine 16-stellige Zeichenfolge sein.
- Der Name ist erforderlich und darf nur Buchstaben enthalten und darf Leerzeichen in der Mitte enthalten.
- Monat und Jahr müssen Grenzen erfüllen.
- Kontoinformationen müssen vorhanden sein, wenn die Karte aktiv ist, und fehlen, wenn die Karte deaktiviert ist. Wenn Sie sich fragen, warum, ist es einfach: Wenn die Karte deaktiviert ist, sollte es nicht möglich sein, das Guthaben oder das Tageslimit zu ändern.
public class CardValidator : IValidator { internal static CardNumberRegex = new Regex("^[0-9]{16}$"); internal static NameRegex = new Regex("^[\w]+[\w ]+[\w]+$"); public CardValidator() { RuleFor(x => x.CardNumber) .Must(c => !string.IsNullOrEmpty(c) && CardNumberRegex.IsMatch(c)) .WithMessage("oh my"); RuleFor(x => x.Name) .Must(c => !string.IsNullOrEmpty(c) && NameRegex.IsMatch(c)) .WithMessage("oh no"); RuleFor(x => x.ExpirationMonth) .Must(x => x >= 1 && x <= 12) .WithMessage("oh boy"); RuleFor(x => x.ExpirationYear) .Must(x => x >= 2019 && x <= 2023) .WithMessage("oh boy"); RuleFor(x => x.AccountInfo) .Null() .When(x => !x.IsActive) .WithMessage("oh boy"); RuleFor(x => x.AccountInfo) .NotNull() .When(x => x.IsActive) .WithMessage("oh boy"); } }
Jetzt gibt es einige Probleme mit diesem Ansatz:
- Die Validierung ist von der Typdeklaration getrennt. Um das vollständige Bild der Karte zu sehen, müssen wir durch den Code navigieren und dieses Bild in unserem Kopf neu erstellen. Es ist kein großes Problem, wenn es nur einmal vorkommt, aber wenn wir das für jede einzelne Entität in einem großen Projekt tun müssen, ist es sehr zeitaufwändig.
- Diese Validierung ist nicht erzwungen, wir müssen daran denken, sie überall zu verwenden. Wir können dies mit Tests sicherstellen, aber Sie müssen sich auch daran erinnern, wenn Sie Tests schreiben.
- Wenn wir die Kartennummer an anderen Stellen validieren möchten, müssen wir dasselbe noch einmal tun. Sicher, wir können Regex an einem gemeinsamen Ort behalten, aber wir müssen es trotzdem in jedem Validator aufrufen.
In F # können wir es anders machen:
(**) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create str = match str with | (null|"") -> Error "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else Error "Card number must be a 16 digits string" (**) type CardAccountInfo = | Active of AccountInfo | Deactivated (**) type Card = { CardNumber: CardNumber Name: LetterString //
Natürlich können wir von hier aus einige Dinge in C # tun. Wir können eine CardNumber
Klasse erstellen, die dort auch ValidationException
CardNumber
. Dieser Trick mit CardAccountInfo
kann jedoch nicht auf einfache Weise in C # ausgeführt werden.
Eine andere Sache - C # ist stark von Ausnahmen abhängig. Damit gibt es mehrere Probleme:
- Ausnahmen haben "Gehe zu" -Semantik. In einem Moment sind Sie in dieser Methode hier, in einem anderen - Sie sind in einem globalen Handler gelandet.
- Sie erscheinen nicht in der Methodensignatur. Ausnahmen wie
ValidationException
oder InvalidUserOperationException
sind Bestandteil des Vertrags, aber das wissen Sie erst, wenn Sie die Implementierung gelesen haben. Und es ist ein großes Problem, da Sie häufig Code verwenden müssen, der von einer anderen Person geschrieben wurde, und anstatt nur die Signatur zu lesen, müssen Sie bis zum Ende des Aufrufstapels navigieren, was viel Zeit in Anspruch nimmt.
Und das ist es, was mich stört: Wenn ich eine neue Funktion implementiere, nimmt der Implementierungsprozess selbst nicht viel Zeit in Anspruch, der Großteil davon hängt von zwei Dingen ab:
- Lesen Sie den Code anderer Leute und finden Sie die Regeln der Geschäftslogik heraus.
- Stellen Sie sicher, dass nichts kaputt ist.
Es mag wie ein Symptom für ein schlechtes Code-Design klingen, aber das Gleiche passiert auch bei anständig geschriebenen Projekten.
Okay, aber wir können versuchen, dasselbe Result
in C # zu verwenden. Die naheliegendste Implementierung würde folgendermaßen aussehen:
public class Result<TOk, TError> { public TOk Ok {get;set;} public TError Error {get;set;} }
und es ist ein reiner Müll, es hindert uns nicht daran, sowohl Ok
als auch Error
und ermöglicht, dass Fehler vollständig ignoriert werden. Die richtige Version wäre ungefähr so:
public abstract class Result<TOk, TError> { public abstract bool IsOk { get; } private sealed class OkResult : Result<TOk, TError> { public readonly TOk _ok; public OkResult(TOk ok) { _ok = ok; } public override bool IsOk => true; } private sealed class ErrorResult : Result<TOk, TError> { public readonly TError _error; public ErrorResult(TError error) { _error = error; } public override bool IsOk => false; } public static Result<TOk, TError> Ok(TOk ok) => new OkResult(ok); public static Result<TOk, TError> Error(TError error) => new ErrorResult(error); public Result<T, TError> Map<T>(Func<TOk, T> map) { if (this.IsOk) { var value = ((OkResult)this)._ok; return Result<T, TError>.Ok(map(value)); } else { var value = ((ErrorResult)this)._error; return Result<T, TError>.Error(value); } } public Result<TOk, T> MapError<T>(Func<TError, T> mapError) { if (this.IsOk) { var value = ((OkResult)this)._ok; return Result<TOk, T>.Ok(value); } else { var value = ((ErrorResult)this)._error; return Result<TOk, T>.Error(mapError(value)); } } }
Ziemlich umständlich, oder? Und ich habe nicht einmal die void
Versionen für Map
und MapError
. Die Verwendung würde folgendermaßen aussehen:
void Test(Result<int, string> result) { var squareResult = result.Map(x => x * x); }
Nicht so schlimm, oder? Stellen Sie sich nun vor, Sie haben drei Ergebnisse und möchten etwas damit anfangen, wenn alle in Ordnung sind. Böse. Das ist also kaum eine Option.
F # Version:
//
Grundsätzlich müssen Sie entscheiden, ob Sie eine angemessene Menge an Code schreiben, aber der Code ist dunkel, beruht auf Ausnahmen, Reflexionen, Ausdrücken und anderer "Magie", oder Sie schreiben viel mehr Code, der schwer zu lesen ist, aber dauerhafter und direkt. Wenn ein solches Projekt groß wird, kann man es einfach nicht bekämpfen, nicht in Sprachen mit C # -ähnlichen Systemen. Betrachten wir ein einfaches Szenario: Sie haben für eine Weile eine Entität in Ihrer Codebasis. Heute möchten Sie ein neues Pflichtfeld hinzufügen. Natürlich müssen Sie dieses Feld überall dort initialisieren, wo diese Entität erstellt wird, aber der Compiler hilft Ihnen überhaupt nicht, da die Klasse veränderbar ist und null
ein gültiger Wert ist. Und Bibliotheken wie AutoMapper
machen es noch schwieriger. Diese Veränderbarkeit ermöglicht es uns, Objekte an einer Stelle teilweise zu initialisieren, sie dann an eine andere Stelle zu verschieben und dort die Initialisierung fortzusetzen. Das ist eine weitere Fehlerquelle.
In der Zwischenzeit ist der Vergleich der Sprachfunktionen nett, aber darum geht es in diesem Artikel nicht. Wenn Sie daran interessiert sind, habe ich dieses Thema in meinem vorherigen Artikel behandelt . Sprachfunktionen selbst sollten jedoch kein Grund sein, die Technologie zu wechseln.
Das bringt uns zu folgenden Fragen:
- Warum müssen wir wirklich von modernem OOP wechseln?
- Warum sollten wir zu FP wechseln?
Die Antwort auf die erste Frage lautet: Die Verwendung gängiger OOP-Sprachen für moderne Anwendungen bereitet Ihnen viele Probleme, da sie für andere Zwecke entwickelt wurden. Dies führt zu Zeit und Geld, die Sie für den Kampf gegen das Design und die Komplexität Ihrer Anwendung aufwenden müssen.
Die zweite Antwort lautet: FP-Sprachen bieten Ihnen eine einfache Möglichkeit, Ihre Funktionen so zu gestalten, dass sie wie eine Uhr funktionieren. Wenn eine neue Funktion die vorhandene Logik verletzt, wird der Code beschädigt, sodass Sie dies sofort wissen.
Diese Antworten reichen jedoch nicht aus. Wie mein Freund in einer unserer Diskussionen betonte, wäre ein Wechsel zu FP nutzlos, wenn Sie keine Best Practices kennen. Unsere große Industrie hat unzählige Artikel, Bücher und Tutorials zum Entwerfen von OOP-Anwendungen erstellt. Wir verfügen über Produktionserfahrung mit OOP, sodass wir wissen, was wir von verschiedenen Ansätzen erwarten können. Leider ist dies bei der funktionalen Programmierung nicht der Fall. Selbst wenn Sie zu FP wechseln, wären Ihre ersten Versuche höchstwahrscheinlich umständlich und würden Ihnen sicherlich nicht das gewünschte Ergebnis bringen: die schnelle und schmerzlose Entwicklung komplexer Systeme.
Genau darum geht es in diesem Artikel. Wie gesagt, wir werden eine produktionsähnliche Anwendung erstellen, um den Unterschied zu erkennen.
Wie gestalten wir Anwendungen?
Viele dieser Ideen, die ich im Designprozess verwendet habe, habe ich aus dem großartigen Buch Domain Modeling Made Functional entlehnt. Ich empfehle Ihnen daher dringend, es zu lesen.
Der vollständige Quellcode mit Kommentaren ist hier . Natürlich werde ich hier nicht alles einfügen, also gehe ich nur die wichtigsten Punkte durch.
Wir werden 4 Hauptprojekte haben: Geschäftsschicht, Datenzugriffsschicht, Infrastruktur und natürlich gemeinsam. Jede Lösung hat es, richtig?
Wir beginnen mit der Modellierung unserer Domain. Zu diesem Zeitpunkt kennen wir die Datenbank nicht und kümmern uns nicht darum. Dies geschieht absichtlich, da wir unter Berücksichtigung einer bestimmten Datenbank dazu neigen, unsere Domain entsprechend zu gestalten, und diese Entitätstabellenbeziehung in die Geschäftsschicht einbringen, was später zu Problemen führt. Sie müssen die Zuordnungsdomäne domain -> DAL
einmal implementieren, während uns ein falsches Design ständig beunruhigt, bis wir es beheben. Wir tun also CardManagement
: Wir erstellen ein Projekt mit dem Namen CardManagement
(sehr kreativ, ich weiß) und <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
sofort die Einstellung <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
in der Projektdatei. Warum brauchen wir das? Nun, wir werden diskriminierte Gewerkschaften stark einsetzen, und wenn Sie einen Mustervergleich durchführen, gibt uns der Compiler eine Warnung, wenn wir nicht alle möglichen Fälle abgedeckt haben:
let fail result = match result with | Ok v -> printfn "%A" v //
Wenn diese Einstellung aktiviert ist, wird dieser Code einfach nicht kompiliert. Genau das benötigen wir, wenn wir vorhandene Funktionen erweitern und möchten, dass sie überall angepasst werden. Als nächstes erstellen wir ein Modul (es wird in einer statischen Klasse kompiliert) CardDomain
. In dieser Datei beschreiben wir Domänentypen und nichts weiter. Beachten Sie, dass in F # die Reihenfolge von Code und Datei wichtig ist: Standardmäßig können Sie nur das verwenden, was Sie zuvor deklariert haben.
Domänentypen
Wir beginnen mit der Definition unserer Typen mit CardNumber
ich zuvor gezeigt habe, obwohl wir mehr praktische Error
als nur eine Zeichenfolge benötigen, sodass wir ValidationError
.
type ValidationError = { FieldPath: string Message: string } let validationError field message = { FieldPath = field; Message = message } (**) let private cardNumberRegex = new Regex("^[0-9]{16}$", RegexOptions.Compiled) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create fieldName str = match str with | (null|"") -> validationError fieldName "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else validationError fieldName "Card number must be a 16 digits string"
Dann definieren wir natürlich die Card
die das Herz unserer Domäne ist. Wir wissen, dass die Karte einige permanente Attribute wie Nummer, Ablaufdatum und Name auf der Karte sowie einige veränderbare Informationen wie Guthaben und Tageslimit aufweist. Daher kapseln wir diese veränderbaren Informationen in einem anderen Typ:
type AccountInfo = { HolderId: UserId Balance: Money DailyLimit: DailyLimit } type Card = { CardNumber: CardNumber Name: LetterString HolderId: UserId Expiration: (Month * Year) AccountDetails: CardAccountInfo }
Nun gibt es hier verschiedene Typen, die wir noch nicht deklariert haben:
Geld
Wir könnten decimal
(und wir werden es tun, aber nicht direkt), aber decimal
sind weniger beschreibend. Außerdem kann es zur Darstellung anderer Dinge als Geld verwendet werden, und wir möchten nicht, dass es verwechselt wird. Wir verwenden also den benutzerdefinierten Typ type [<Struct>] Money = Money of decimal
.
Dailylimit
Das Tageslimit kann entweder auf einen bestimmten Betrag festgelegt werden oder überhaupt nicht vorhanden sein. Wenn es vorhanden ist, muss es positiv sein. Anstatt decimal
oder Money
, definieren wir diesen Typ:
[<Struct>] type DailyLimit = private //
Es ist 0M
als nur zu implizieren, dass 0M
bedeutet, dass es kein Limit gibt, da es auch bedeuten könnte, dass Sie kein Geld für diese Karte ausgeben können. Das einzige Problem ist, dass wir keinen Mustervergleich durchführen können, da wir den Konstruktor ausgeblendet haben. Aber keine Sorge, wir können Active Patterns verwenden :
let (|Limit|Unlimited|) limit = match limit with | Limit dec -> Limit dec | Unlimited -> Unlimited
Jetzt können wir DailyLimit
überall als reguläre DU DailyLimit
.
Letterstring
Das ist einfach. Wir verwenden die gleiche Technik wie in CardNumber
. Eine Kleinigkeit: Bei LetterString
geht es kaum um Kreditkarten, es ist eher eine Sache, und wir sollten sie im Common
Projekt im CommonTypes
Modul verschieben. Mit der Zeit verschieben wir ValidationError
an einen separaten Ort.
Benutzer-ID
Dieser ist nur ein Alias vom type UserId = System.Guid
. Wir verwenden es nur zur Beschreibung.
Monat und Jahr
Die müssen auch nach Common
gehen. Month
wird eine diskriminierte Vereinigung mit Methoden sein, um es in und aus unsigned int16
zu konvertieren. Year
wird wie CardNumber
aber für uint16
anstelle von string.
Lassen Sie uns nun unsere Deklaration der Domänentypen beenden. Wir brauchen User
mit einigen Benutzerinformationen und Kartensammlung, wir brauchen Ausgleichsvorgänge für Aufladungen und Zahlungen.
type UserInfo = { Name: LetterString Id: UserId Address: Address } type User = { UserInfo : UserInfo Cards: Card list } [<Struct>] type BalanceChange = //
Gut, wir haben unsere Typen so gestaltet, dass ein ungültiger Zustand nicht darstellbar ist. Wenn wir uns jetzt mit einer Instanz eines dieser Typen befassen, sind wir sicher, dass die darin enthaltenen Daten gültig sind und wir sie nicht erneut validieren müssen. Jetzt können wir zur Geschäftslogik übergehen!
Geschäftslogik
Wir werden hier eine unzerbrechliche Regel haben: Die gesamte Geschäftslogik wird in reinen Funktionen codiert. Eine reine Funktion ist eine Funktion, die folgende Kriterien erfüllt:
- Das einzige, was es tut, ist die Berechnung des Ausgabewerts. Es hat überhaupt keine Nebenwirkungen.
- Es wird immer der gleiche Ausgang für den gleichen Eingang erzeugt.
Daher lösen reine Funktionen keine Ausnahmen aus, erzeugen keine zufälligen Werte, interagieren in keiner Form mit der Außenwelt, sei es eine Datenbank oder eine einfache DateTime.Now
. Natürlich macht die Interaktion mit unreinen Funktionen die aufrufende Funktion automatisch unrein. Was sollen wir also umsetzen?
Hier ist eine Liste der Anforderungen, die wir haben:
Karte aktivieren / deaktivieren
Zahlungen verarbeiten
Wir können Zahlungen verarbeiten, wenn:
- Karte ist nicht abgelaufen
- Karte ist aktiv
- Es gibt genug Geld für die Zahlung
- Die Ausgaben für heute haben das Tageslimit nicht überschritten.
Guthaben aufladen
Wir können das Guthaben für aktive und nicht abgelaufene Karten aufladen.
Tageslimit festlegen
Der Benutzer kann das Tageslimit festlegen, wenn die Karte nicht abgelaufen und aktiv ist.
Wenn die Operation nicht abgeschlossen werden kann, müssen wir einen Fehler zurückgeben, also müssen wir OperationNotAllowedError
definieren:
type OperationNotAllowedError = { Operation: string Reason: string } //
In diesem Modul mit Geschäftslogik ist dies die einzige Art von Fehler, die wir zurückgeben. Wir führen hier keine Validierung durch, interagieren nicht mit der Datenbank - führen nur Operationen aus, wenn wir andernfalls OperationNotAllowedError
.
Das vollständige Modul finden Sie hier . Ich werde hier den schwierigsten Fall auflisten: processPayment
. Wir müssen den Ablauf, den aktiven / deaktivierten Status, das heute ausgegebene Geld und den aktuellen Kontostand überprüfen. Da wir nicht mit der Außenwelt interagieren können, müssen wir alle notwendigen Informationen als Parameter übergeben. Auf diese Weise wäre diese Logik sehr einfach zu testen und ermöglicht es Ihnen, eigenschaftsbasierte Tests durchzuführen.
let processPayment (currentDate: DateTimeOffset) (spentToday: Money) card (paymentAmount: MoneyTransaction) = //
Diesen heutigen spentToday
- wir müssen ihn aus der BalanceOperation
Sammlung berechnen, die wir in der Datenbank behalten. Dafür benötigen wir ein Modul, das im Grunde 1 öffentliche Funktion hat:
let private isDecrease change = match change with | Increase _ -> false | Decrease _ -> true let spentAtDate (date: DateTimeOffset) cardNumber operations = let date = date.Date let operationFilter { CardNumber = number; BalanceChange = change; Timestamp = timestamp } = isDecrease change && number = cardNumber && timestamp.Date = date let spendings = List.filter operationFilter operations List.sumBy (fun s -> -s.BalanceChange.ToDecimal()) spendings |> Money
Gut Nachdem wir mit der Implementierung der Geschäftslogik fertig sind, ist es Zeit, über das Mapping nachzudenken. Viele unserer Typen verwenden diskriminierte Gewerkschaften, einige unserer Typen haben keinen öffentlichen Konstruktor, daher können wir sie nicht so wie sie sind der Außenwelt aussetzen. Wir müssen uns mit (De-) Serialisierung befassen. Außerdem haben wir derzeit nur einen begrenzten Kontext in unserer Anwendung, aber später im wirklichen Leben möchten Sie ein größeres System mit mehreren begrenzten Kontexten aufbauen, und sie müssen über öffentliche Aufträge miteinander interagieren, was verständlich sein sollte für alle, auch für andere Programmiersprachen.
Wir müssen in beide Richtungen Mapping durchführen: von öffentlichen Modellen zu Domain und umgekehrt. Während die Zuordnung von der Domäne zu den Modellen ziemlich schwierig ist, hat die andere Richtung ein wenig Probleme: Modelle können ungültige Daten haben, schließlich verwenden wir einfache Typen, die für json serialisiert werden können. Keine Sorge, wir müssen unsere Validierung in diesem Mapping erstellen. Die Tatsache, dass wir unterschiedliche Typen für möglicherweise ungültige Daten und Daten verwenden, die immer gültig sind, bedeutet, dass der Compiler nicht zulässt, dass wir die Validierung ausführen.
So sieht es aus:
(**) type ValidateCreateCardCommand = CreateCardCommandModel -> ValidationResult<Card> let validateCreateCardCommand : ValidateCreateCardCommand = fun cmd -> (**) result { let! name = LetterString.create "name" cmd.Name let! number = CardNumber.create "cardNumber" cmd.CardNumber let! month = Month.create "expirationMonth" cmd.ExpirationMonth let! year = Year.create "expirationYear" cmd.ExpirationYear return { Card.CardNumber = number Name = name HolderId = cmd.UserId Expiration = month,year AccountDetails = AccountInfo.Default cmd.UserId |> Active } }
Das vollständige Modul für Zuordnungen und Validierungen finden Sie hier und das Modul für die Zuordnung zu Modellen finden Sie hier .
Zu diesem Zeitpunkt haben wir die Implementierung für die gesamte Geschäftslogik, Zuordnungen, Validierung usw., und bis jetzt ist all dies vollständig von der realen Welt isoliert: Es ist vollständig in reinen Funktionen geschrieben. Jetzt fragen Sie sich vielleicht, wie genau wir das nutzen werden. Weil wir mit der Außenwelt interagieren müssen. Darüber hinaus müssen wir während einer Workflow-Ausführung einige Entscheidungen treffen, die auf dem Ergebnis dieser realen Interaktionen basieren. Die Frage ist also, wie wir das alles zusammenbauen. In OOP verwenden sie IoC-Container, um dies zu erledigen, aber hier können wir das nicht tun, da wir nicht einmal Objekte haben, sondern statische Funktionen.
Wir werden dafür das Interpreter pattern
! Es ist ein bisschen knifflig, vor allem, weil es unbekannt ist, aber ich werde mein Bestes geben, um dieses Muster zu erklären. Lassen Sie uns zunächst über die Funktionszusammensetzung sprechen. Zum Beispiel haben wir eine Funktion int -> string
. Dies bedeutet, dass die Funktion int
als Parameter erwartet und eine Zeichenfolge zurückgibt. Nehmen wir jetzt an, wir haben eine andere Funktionszeichenfolge string -> char
. An diesem Punkt können wir sie verketten, d. H. Die erste ausführen, die Ausgabe übernehmen und der zweiten Funktion zuführen, und dafür gibt es sogar einen Operator: >>
. So funktioniert es:
let intToString (i: int) = i.ToString() let firstCharOrSpace (s: string) = match s with | (null| "") -> ' ' | s -> s.[0] let firstDigitAsChar = intToString >> firstCharOrSpace //
In einigen Szenarien, z. B. beim Aktivieren der Karte, können wir jedoch keine einfache Verkettung verwenden. Hier ist eine Abfolge von Aktionen:
- Überprüfen Sie die Nummer der Eingangskarte. Wenn es gültig ist, dann
- Versuchen Sie, eine Karte mit dieser Nummer zu erhalten. Wenn es einen gibt
- aktiviere es.
- Ergebnisse speichern. Wenn es dann ok ist
- Karte zum Modell und zurück.
Die ersten beiden Schritte haben das If it's ok then...
Das ist der Grund, warum die direkte Verkettung nicht funktioniert.
Wir könnten diese Funktionen einfach wie folgt einfügen:
let activateCard getCardAsync saveCardAsync cardNumber = ...
Aber damit gibt es gewisse Probleme. Erstens kann die Anzahl der Abhängigkeiten groß werden und die Funktionssignatur wird hässlich aussehen. Zweitens sind wir hier an bestimmte Effekte gebunden: Wir müssen wählen, ob es sich um eine Task
oder Async
oder nur um einfache Synchronisierungsaufrufe. Drittens ist es einfach, Dinge durcheinander zu bringen, wenn Sie so viele Funktionen übergeben müssen: z. B. createUserAsync
und replaceUserAsync
haben dieselbe Signatur, aber unterschiedliche Effekte. Wenn Sie sie also hunderte Male übergeben müssen, können Sie einen Fehler mit wirklich seltsamen Symptomen machen. Aus diesen Gründen entscheiden wir uns für Dolmetscher.
Die Idee ist, dass wir unseren Kompositionscode in zwei Teile teilen: Ausführungsbaum und Interpreter für diesen Baum. Jeder Knoten in diesem Baum ist ein Ort für eine Funktion mit Wirkung, die wir injizieren möchten, wie getUserFromDatabase
. B. getUserFromDatabase
. Diese Knoten werden durch den Namen definiert, z. B. getCard
, Eingabeparametertyp, z. B. CardNumber
und Rückgabetyp, z. B. Card option
. Wir geben hier weder Task
noch Async
, das ist nicht der Teil des Baums, sondern ein Teil des Interpreters . Jede Kante dieses Baums besteht aus einer Reihe reiner Transformationen, wie z. B. Validierung oder Ausführung von Geschäftslogikfunktionen. Die Kanten haben auch eine Eingabe, z. B. eine rohe String-Kartennummer, dann gibt es eine Validierung, die uns einen Fehler oder eine gültige Kartennummer geben kann. Wenn ein Fehler getCard
, werden wir diese Flanke unterbrechen. Wenn nicht, führt sie uns zum nächsten Knoten: getCard
. Wenn dieser Knoten eine Some card
zurückgibt, können wir mit der nächsten Kante fortfahren, die Aktivierung wäre, und so weiter.
Für jedes Szenario wie activateCard
oder topUp
oder topUp
wir einen separaten Baum. Wenn diese Bäume gebaut werden, sind ihre Knoten etwas leer, sie haben keine wirklichen Funktionen, sie haben einen Platz für diese Funktionen. Das Ziel des Interpreters ist es, diese Knoten einfach zu füllen. Der Interpreter kennt die von uns verwendeten Effekte, z. B. Task
, und weiß, welche reale Funktion in einen bestimmten Knoten eingefügt werden soll. Wenn es einen Knoten besucht, führt es die entsprechende reale Funktion aus, wartet bei Task
oder Async
und leitet das Ergebnis an die nächste Kante weiter. Diese Kante kann zu einem anderen Knoten führen, und dann ist es wieder eine Arbeit für den Interpreter, bis dieser Interpreter den Stoppknoten erreicht, den unteren Rand unserer Rekursion, wo wir nur das Ergebnis der gesamten Ausführung unseres Baums zurückgeben.
Der gesamte Baum würde mit diskriminierter Vereinigung dargestellt, und ein Knoten würde folgendermaßen aussehen:
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) //
Es wird immer ein Tupel sein, bei dem das erste Element eine Eingabe für Ihre Abhängigkeit ist und das letzte Element eine Funktion ist , die das Ergebnis dieser Abhängigkeit empfängt. In diesen "Raum" zwischen diesen Tupelelementen passt Ihre Abhängigkeit, wie in den Kompositionsbeispielen, in denen Sie die Funktion 'a -> 'b
, 'c -> 'd
und eine andere 'b -> 'c
einfügen müssen. 'b -> 'c
dazwischen, um sie zu verbinden.
Da wir uns in unserem begrenzten Kontext befinden, sollten wir nicht zu viele Abhängigkeiten haben, und wenn wir dies tun, ist es wahrscheinlich eine Zeit, unseren Kontext in kleinere zu teilen.
So sieht es aus, die vollständige Quelle finden Sie hier :
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) | GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>) | CreateCard of (Card*AccountInfo) * (Result<unit, DataRelatedError> -> Program<'a>) | ReplaceCard of Card * (Result<unit, DataRelatedError> -> Program<'a>) | GetUser of UserId * (User option -> Program<'a>) | CreateUser of UserInfo * (Result<unit, DataRelatedError> -> Program<'a>) | GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'a>) | SaveBalanceOperation of BalanceOperation * (Result<unit, DataRelatedError> -> Program<'a>) | Stop of 'a (**) let rec bind f instruction = match instruction with | GetCard (x, next) -> GetCard (x, (next >> bind f)) | GetCardWithAccountInfo (x, next) -> GetCardWithAccountInfo (x, (next >> bind f)) | CreateCard (x, next) -> CreateCard (x, (next >> bind f)) | ReplaceCard (x, next) -> ReplaceCard (x, (next >> bind f)) | GetUser (x, next) -> GetUser (x,(next >> bind f)) | CreateUser (x, next) -> CreateUser (x,(next >> bind f)) | GetBalanceOperations (x, next) -> GetBalanceOperations (x,(next >> bind f)) | SaveBalanceOperation (x, next) -> SaveBalanceOperation (x,(next >> bind f)) | Stop x -> fx (**) let stop x = Stop x let getCardByNumber number = GetCard (number, stop) let getCardWithAccountInfo number = GetCardWithAccountInfo (number, stop) let createNewCard (card, acc) = CreateCard ((card, acc), stop) let replaceCard card = ReplaceCard (card, stop) let getUserById id = GetUser (id, stop) let createNewUser user = CreateUser (user, stop) let getBalanceOperations (number, fromDate, toDate) = GetBalanceOperations ((number, fromDate, toDate), stop) let saveBalanceOperation op = SaveBalanceOperation (op, stop)
With a help of computation expressions , we now have a very easy way to build our workflows without having to care about implementation of real-world interactions. We do that in CardWorkflow module :
(**) let processPayment (currentDate: DateTimeOffset, payment) = program { (**) let! cmd = validateProcessPaymentCommand payment |> expectValidationError let! card = tryGetCard cmd.CardNumber let today = currentDate.Date |> DateTimeOffset let tomorrow = currentDate.Date.AddDays 1. |> DateTimeOffset let! operations = getBalanceOperations (cmd.CardNumber, today, tomorrow) let spentToday = BalanceOperation.spentAtDate currentDate cmd.CardNumber operations let! (card, op) = CardActions.processPayment currentDate spentToday card cmd.PaymentAmount |> expectOperationNotAllowedError do! saveBalanceOperation op |> expectDataRelatedErrorProgram do! replaceCard card |> expectDataRelatedErrorProgram return card |> toCardInfoModel |> Ok }
This module is the last thing we need to implement in business layer. Also, I've done some refactoring: I moved errors and common types to Common project . About time we moved on to implementing data access layer.
Data access layer
The design of entities in this layer may depend on our database or framework we use to interact with it. Therefore domain layer doesn't know anything about these entities, which means we have to take care of mapping to and from domain models in here. Which is quite convenient for consumers of our DAL API. For this application I've chosen MongoDB, not because it's a best choice for this kind of task, but because there're many examples of using SQL DBs already and I wanted to add something different. We are gonna use C# driver.
For the most part it's gonna be pretty strait forward, the only tricky moment is with Card
. When it's active it has an AccountInfo
inside, when it's not it doesn't. So we have to split it in two documents: CardEntity
and CardAccountInfoEntity
, so that deactivating card doesn't erase information about balance and daily limit.
Other than that we just gonna use primitive types instead of discriminated unions and types with built-in validation.
There're also few things we need to take care of, since we are using C# library:
- Convert
null
s to Option<'a>
- Catch expected exceptions and convert them to our errors and wrap it in
Result<_,_>
We start with CardDomainEntities module , where we define our entities:
[<CLIMutable>] type CardEntity = { [<BsonId>] CardNumber: string Name: string IsActive: bool ExpirationMonth: uint16 ExpirationYear: uint16 UserId: UserId } with //
Those fields EntityId
and IdComparer
we are gonna use with a help of SRTP . We'll define functions that will retrieve them from any type that has those fields define, without forcing every entity to implement some interface:
let inline (|HasEntityId|) x = fun () -> (^a : (member EntityId: string) x) let inline entityId (HasEntityId f) = f() let inline (|HasIdComparer|) x = fun () -> (^a : (member IdComparer: Quotations.Expr<Func< ^a, bool>>) x) //
As for null
and Option
thing, since we use record types, F# compiler doesn't allow using null
value, neither for assigning nor for comparison. At the same time record types are just another CLR types, so technically we can and will get a null
value, thanks to C# and design of this library. We can solve this in 2 ways: use AllowNullLiteral
attribute, or use Unchecked.defaultof<'a>
. I went for the second choice since this null
situation should be localized as much as possible:
let isNullUnsafe (arg: 'a when 'a: not struct) = arg = Unchecked.defaultof<'a> //
In order to deal with expected exception for duplicate key, we use Active Patterns again:
//
After mapping is implemented we have everything we need to assemble API for our data access layer , which looks like this:
//
The last moment I mention is when we do mapping Entity -> Domain
, we have to instantiate types with built-in validation, so there can be validation errors. In this case we won't use Result<_,_>
because if we've got invalid data in DB, it's a bug, not something we expect. So we just throw an exception. Other than that nothing really interesting is happening in here. The full source code of data access layer you'll find here .
Composition, logging and all the rest
As you remember, we're not gonna use DI framework, we went for interpreter pattern. If you want to know why, here's some reasons:
- IoC container operates in runtime. So until you run your program you can't know that all the dependencies are satisfied.
- It's a powerful tool which is very easy to abuse: you can do property injection, use lazy dependencies, and sometimes even some business logic can find it's way in dependency registering/resolving (yeah, I've witnessed it). All of that makes code maintaining extremely hard.
That means we need a place for that functionality. We could place it on a top level in our Web Api, but in my opinion it's not a best choice: right now we are dealing with only 1 bounded context, but if there's more, this global place with all the interpreters for each context will become cumbersome. Besides, there's single responsibility rule, and web api project should be responsible for web, right? So we create CardManagement.Infrastructure project .
Here we will do several things:
- Composing our functionality
- App configuration
- Logging
If we had more than 1 context, app configuration and log configuration should be moved to global infrastructure project, and the only thing happening in this project would be assembling API for our bounded context, but in our case this separation is not necessary.
Let's get down to composition. We've built execution trees in our domain layer, now we have to interpret them. Every node in that tree represents some dependency call, in our case a call to database. If we had a need to interact with 3rd party api, that would be in here also. So our interpreter has to know how to handle every node in that tree, which is verified in compile time, thanks to <TreatWarningsAsErrors>
setting. Here's what it looks like:
(**) let rec private interpretCardProgram mongoDb prog = match prog with | GetCard (cardNumber, next) -> cardNumber |> getCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetCardWithAccountInfo (number, next) -> number |> getCardWithAccInfoAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | CreateCard ((card,acc), next) -> (card, acc) |> createCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | ReplaceCard (card, next) -> card |> replaceCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetUser (id, next) -> getUserAsync mongoDb id |> bindAsync (next >> interpretCardProgram mongoDb) | CreateUser (user, next) -> user |> createUserAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetBalanceOperations (request, next) -> getBalanceOperationsAsync mongoDb request |> bindAsync (next >> interpretCardProgram mongoDb) | SaveBalanceOperation (op, next) -> saveBalanceOperationAsync mongoDb op |> bindAsync (next >> interpretCardProgram mongoDb) | Stop a -> async.Return a let interpret prog = try let interpret = interpretCardProgram (getMongoDb()) interpret prog with | failure -> Bug failure |> Error |> async.Return
Note that this interpreter is the place where we have this async
thing. We can do another interpreter with Task
or just a plain sync version of it. Now you're probably wondering, how we can cover this with unit-test, since familiar mock libraries ain't gonna help us. Well, it's easy: you have to make another interpreter. Here's what it can look like:
type SaveResult = Result<unit, DataRelatedError> type TestInterpreterConfig = { GetCard: Card option GetCardWithAccountInfo: (Card*AccountInfo) option CreateCard: SaveResult ReplaceCard: SaveResult GetUser: User option CreateUser: SaveResult GetBalanceOperations: BalanceOperation list SaveBalanceOperation: SaveResult } let defaultConfig = { GetCard = Some card GetUser = Some user GetCardWithAccountInfo = (card, accountInfo) |> Some CreateCard = Ok() GetBalanceOperations = balanceOperations SaveBalanceOperation = Ok() ReplaceCard = Ok() CreateUser = Ok() } let testInject a = fun _ -> a let rec interpretCardProgram config (prog: Program<'a>) = match prog with | GetCard (cardNumber, next) -> cardNumber |> testInject config.GetCard |> (next >> interpretCardProgram config) | GetCardWithAccountInfo (number, next) -> number |> testInject config.GetCardWithAccountInfo |> (next >> interpretCardProgram config) | CreateCard ((card,acc), next) -> (card, acc) |> testInject config.CreateCard |> (next >> interpretCardProgram config) | ReplaceCard (card, next) -> card |> testInject config.ReplaceCard |> (next >> interpretCardProgram config) | GetUser (id, next) -> id |> testInject config.GetUser |> (next >> interpretCardProgram config) | CreateUser (user, next) -> user |> testInject config.CreateUser |> (next >> interpretCardProgram config) | GetBalanceOperations (request, next) -> testInject config.GetBalanceOperations request |> (next >> interpretCardProgram config) | SaveBalanceOperation (op, next) -> testInject config.SaveBalanceOperation op |> (next >> interpretCardProgram config) | Stop a -> a
We've created TestInterpreterConfig
which holds desired results of every operation we want to inject. You can easily change that config for every given test and then just run interpreter. This interpreter is sync, since there's no reason to bother with Task
or Async
.
There's nothing really tricky about the logging, but you can find it in this module . The approach is that we wrap the function in logging: we log function name, parameters and log result. If result is ok, it's info, if error it's a warning and if it's a Bug
then it's an error. That's pretty much it.
One last thing is to make a facade, since we don't want to expose raw interpreter calls. Here's the whole thing:
let createUser arg = arg |> (CardWorkflow.createUser >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createUser") let createCard arg = arg |> (CardWorkflow.createCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createCard") let activateCard arg = arg |> (CardWorkflow.activateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.activateCard") let deactivateCard arg = arg |> (CardWorkflow.deactivateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.deactivateCard") let processPayment arg = arg |> (CardWorkflow.processPayment >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.processPayment") let topUp arg = arg |> (CardWorkflow.topUp >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.topUp") let setDailyLimit arg = arg |> (CardWorkflow.setDailyLimit >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.setDailyLimit") let getCard arg = arg |> (CardWorkflow.getCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.getCard") let getUser arg = arg |> (CardWorkflow.getUser >> CardProgramInterpreter.interpretSimple |> logifyResultAsync "CardApi.getUser")
All the dependencies here are injected, logging is taken care of, no exceptions is thrown — that's it. For web api I used Giraffe framework. Web project is here .
Fazit
We have built an application with validation, error handling, logging, business logic — all those things you usually have in your application. The difference is this code is way more durable and easy to refactor. Note that we haven't used reflection or code generation, no exceptions, but still our code isn't verbose. It's easy to read, easy to understand and hard to break. As soon as you add another field in your model, or another case in one of our union types, the code won't compile until you update every usage. Sure it doesn't mean you're totally safe or that you don't need any kind of testing at all, it just means that you're gonna have fewer problems when you develope new features or do some refactoring. The development process will be both cheaper and more interesting, because this tool allows you to focus on your domain and business tasks, instead of drugging focus on keeping an eye out that nothing is broken.
Another thing: I don't claim that OOP is completely useless and we don't need it, that's not true. I'm saying that we don't need it for solving every single task we have, and that a big portion of our tasks can be better solved with FP. And truth is, as always, in balance: we can't solve everything efficiently with only one tool, so a good programming language should have a decent support of both FP and OOP. And, unfortunately, a lot of most popular languages today have only lambdas and async programming from functional world.