De quoi s'agit-il
Après avoir travaillé sur différents projets, j'ai remarqué que chacun d'entre eux avait des problèmes communs, indépendamment du domaine, de l'architecture, de la convention de code, etc. Ces problèmes n'étaient pas difficiles, juste une routine fastidieuse: s'assurer de ne rien manquer de stupide et d'évident. Au lieu de faire cette routine quotidiennement, je suis devenu obsédé par la recherche d'une solution: une approche de développement ou une convention de code ou quoi que ce soit qui m'aiderait à concevoir un projet de manière à empêcher ces problèmes de se produire, afin que je puisse me concentrer sur des choses intéressantes . C'est le but de cet article: décrire ces problèmes et vous montrer ce mélange d'outils et d'approches que j'ai trouvé pour les résoudre.
Problèmes auxquels nous sommes confrontés
Lors du développement de logiciels, nous rencontrons de nombreuses difficultés en cours de route: exigences peu claires, mauvaise communication, processus de développement médiocre, etc.
Nous sommes également confrontés à des difficultés techniques: le code hérité nous ralentit, la mise à l'échelle est délicate, certaines mauvaises décisions du passé nous mettent à rude épreuve aujourd'hui.
Tous peuvent être sinon éliminés puis considérablement réduits, mais il y a un problème fondamental auquel vous ne pouvez rien faire: la complexité de votre système.
L'idée d'un système que vous développez est toujours complexe, que vous le compreniez ou non.
Même lorsque vous faites encore une autre application CRUD , il y a toujours des cas marginaux, des choses délicates, et de temps en temps quelqu'un demande "Hé, que se passera-t-il si je fais ceci et cela dans ces circonstances?" et vous dites "Hm, c'est une très bonne question.".
Ces cas délicats, cette logique louche, la validation et la gestion des accès - tout cela s'ajoute à votre grande idée.
Très souvent, cette idée est si grande qu'elle ne tient pas dans une seule tête, et ce fait à lui seul pose des problèmes comme une mauvaise communication.
Mais soyons généreux et supposons que cette équipe d'experts de domaine et d'analystes d'affaires communique clairement et produit des exigences cohérentes fines.
Nous devons maintenant les implémenter, pour exprimer cette idée complexe dans notre code. Maintenant que le code est un autre système, beaucoup plus compliqué que l'idée originale que nous avions en tête.
Comment ça? Il fait face à la réalité: les limitations techniques vous obligent à gérer la charge élevée, la cohérence des données et la disponibilité en plus d'implémenter une logique métier réelle.
Comme vous pouvez le voir, la tâche est assez difficile, et maintenant nous avons besoin d'outils appropriés pour y faire face.
Un langage de programmation n'est qu'un autre outil, et comme pour tout autre outil, il ne s'agit pas seulement de sa qualité, il s'agit probablement encore plus de l'outil qui convient au travail. Vous avez peut-être le meilleur tournevis qui existe, mais si vous devez mettre des clous dans du bois, un marteau de merde serait mieux, non?
Aspects techniques
Les langues les plus populaires sont aujourd'hui orientées objet Lorsque quelqu'un fait une introduction à la POO, il utilise généralement des exemples:
Considérez une voiture, qui est un objet du monde réel. Il a diverses propriétés comme la marque, le poids, la couleur, la vitesse maximale, la vitesse actuelle, etc.
Pour refléter cet objet dans notre programme, nous rassemblons ces propriétés dans une classe. Les propriétés peuvent être permanentes ou mutables, qui ensemble forment à la fois l'état actuel de cet objet et certaines limites dans lesquelles il peut varier. Cependant, la combinaison de ces propriétés n'est pas suffisante, car nous devons vérifier que l'état actuel est logique, par exemple la vitesse actuelle ne dépasse pas la vitesse maximale. Pour nous en assurer, nous attachons une certaine logique à cette classe, marquons les propriétés comme privées pour empêcher quiconque de créer un état illégal.
Comme vous pouvez le voir, les objets concernent leur état interne et leur cycle de vie.
Ces trois piliers de la POO sont donc parfaitement logiques dans ce contexte: nous utilisons l'héritage pour réutiliser certaines manipulations d'état, l'encapsulation pour la protection de l'état et le polymorphisme pour traiter de la même manière des objets similaires. La mutabilité par défaut est également logique, car dans ce contexte, un objet immuable ne peut pas avoir de cycle de vie et a toujours un état, ce qui n'est pas le cas le plus courant.
La chose est que lorsque vous regardez une application Web typique de nos jours, elle ne traite pas des objets. Presque tout dans notre code a une durée de vie éternelle ou aucune durée de vie appropriée. Les deux types les plus courants d '"objets" sont une sorte de services comme UserService
, EmployeeRepository
ou certains modèles / entités / DTO ou tout ce que vous les appelez. Les services n'ont aucun état logique en eux, ils meurent et naissent exactement de la même manière, nous recréons simplement le graphique de dépendance avec une nouvelle connexion à la base de données.
Les entités et les modèles n'ont aucun comportement qui leur est attaché, ce ne sont que des paquets de données, leur mutabilité n'aide pas, mais bien au contraire.
Par conséquent, les fonctionnalités clés de la POO ne sont pas vraiment utiles pour développer ce type d'applications.
Ce qui se passe dans une application Web typique, c'est le flux de données: validation, transformation, évaluation, etc. Et il y a un paradigme qui convient parfaitement à ce genre de travail: la programmation fonctionnelle. Et il y a une preuve pour cela: toutes les fonctionnalités modernes dans les langages populaires viennent aujourd'hui de là: async/await
, lambdas et délégués, programmation réactive, unions discriminées (énumérations en rapide ou rouille, à ne pas confondre avec les énumérations en java ou .net ), tuples - tout ce qui vient de FP.
Mais ce ne sont que des miettes, c'est très agréable de les avoir, mais il y en a plus, bien plus.
Avant d'aller plus loin, il y a un point à souligner. Passer à une nouvelle langue, en particulier à un nouveau paradigme, est un investissement pour les développeurs et donc pour les entreprises. Faire des investissements insensés ne vous apportera que des ennuis, mais des investissements raisonnables peuvent être la chose même qui vous maintiendra à flot.
Beaucoup d'entre nous préfèrent les langues à frappe statique. La raison en est simple: le compilateur s'occupe des vérifications fastidieuses comme la transmission des paramètres appropriés aux fonctions, la construction correcte de nos entités, etc. Ces chèques sont gratuits. Maintenant, en ce qui concerne les choses que le compilateur ne peut pas vérifier, nous avons le choix: espérer le meilleur ou faire des tests. Écrire des tests signifie de l'argent, et vous ne payez pas une seule fois par test, vous devez les maintenir. De plus, les gens deviennent bâclés, donc de temps en temps nous obtenons des résultats faussement positifs et faux négatifs. Plus vous devez écrire de tests, plus la qualité moyenne de ces tests est faible. Il y a un autre problème: pour tester quelque chose, vous devez savoir et vous souvenir que cette chose doit être testée, mais plus votre système est gros, plus il est facile de manquer quelque chose.
Cependant le compilateur est seulement aussi bon que le système de type du langage. Si cela ne vous permet pas d'exprimer quelque chose de manière statique, vous devez le faire lors de l'exécution. Ce qui signifie des tests, oui. Cela ne concerne pas seulement le système de types, la syntaxe et les petites fonctionnalités de sucre sont également très importantes, car en fin de compte, nous voulons écrire le moins de code possible, donc si une approche vous oblige à écrire dix fois plus de lignes, eh bien, personne ne va l'utiliser. C'est pourquoi il est important que la langue que vous choisissez ait l'ensemble approprié de fonctionnalités et d'astuces - enfin, une bonne concentration dans l'ensemble. Si ce n'est pas le cas - au lieu d'utiliser ses fonctionnalités pour relever des défis originaux tels que la complexité de votre système et l'évolution des exigences, vous allez également lutter contre le langage. Et tout se résume à de l'argent, puisque vous payez les développeurs pour leur temps. Plus ils ont de problèmes à résoudre, plus ils auront besoin de temps et plus vous aurez besoin de développeurs.
Enfin, nous allons voir du code pour prouver tout cela. Je suis un développeur .NET, donc les échantillons de code vont être en C # et F #, mais l'image générale serait plus ou moins la même dans d'autres langages OOP et FP populaires.
Laissez le codage commencer
Nous allons construire une application web pour gérer les cartes de crédit.
Exigences de base:
- Créer / lire des utilisateurs
- Créer / lire des cartes de crédit
- Activer / désactiver les cartes de crédit
- Fixer une limite quotidienne pour les cartes
- Recharger le solde
- Traiter les paiements (compte tenu du solde, de la date d'expiration de la carte, de l'état actif / désactivé et de la limite quotidienne)
Par souci de simplicité, nous allons utiliser une carte par compte et nous ignorerons l'autorisation. Mais pour le reste, nous allons créer une application compatible avec la validation, la gestion des erreurs, la base de données et l'API Web. Passons donc à notre première tâche: concevoir des cartes de crédit.
Voyons d'abord à quoi cela ressemblerait en C #
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;} }
Mais cela ne suffit pas, nous devons ajouter la validation, et cela se fait généralement dans certains Validator
, comme celui de FluentValidation
.
Les règles sont simples:
- Le numéro de carte est obligatoire et doit être une chaîne de 16 chiffres.
- Le nom est obligatoire et ne doit contenir que des lettres et peut contenir des espaces au milieu.
- Le mois et l'année doivent respecter les limites.
- Les informations de compte doivent être présentes lorsque la carte est active et absentes lorsque la carte est désactivée. Si vous vous demandez pourquoi, c'est simple: lorsque la carte est désactivée, il ne devrait pas être possible de modifier le solde ou la limite quotidienne.
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"); } }
Il y a maintenant plusieurs problèmes avec cette approche:
- La validation est séparée de la déclaration de type, ce qui signifie que pour voir l'image complète de la carte, nous devons naviguer dans le code et recréer cette image dans notre tête. Ce n'est pas un gros problème quand cela ne se produit qu'une seule fois, mais quand nous devons le faire pour chaque entité dans un grand projet, eh bien, cela prend beaucoup de temps.
- Cette validation n'est pas forcée, nous devons garder à l'esprit de l'utiliser partout. Nous pouvons garantir cela avec des tests, mais là encore, vous devez vous en souvenir lorsque vous écrivez des tests.
- Lorsque nous voulons valider le numéro de carte dans d'autres endroits, nous devons recommencer la même chose. Bien sûr, nous pouvons garder l'expression régulière dans un lieu commun, mais nous devons toujours l'appeler dans chaque validateur.
En F #, nous pouvons le faire d'une manière différente:
(**) 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 //
Bien sûr, nous pouvons faire certaines choses d'ici en C #. Nous pouvons créer la classe CardNumber
qui lancera également ValidationException
. Mais cette astuce avec CardAccountInfo
ne peut pas être faite en C # de manière simple.
Autre chose - C # s'appuie fortement sur les exceptions. Il y a plusieurs problèmes avec cela:
- Les exceptions ont une "sémantique". Un moment, vous êtes ici dans cette méthode, un autre - vous vous êtes retrouvé dans un gestionnaire global.
- Ils n'apparaissent pas dans la signature de méthode. Des exceptions telles que
ValidationException
ou InvalidUserOperationException
font partie du contrat, mais vous ne le savez pas avant d'avoir lu l' implémentation . Et c'est un problème majeur, car très souvent, vous devez utiliser du code écrit par quelqu'un d'autre, et au lieu de lire uniquement la signature, vous devez naviguer jusqu'au bas de la pile des appels, ce qui prend beaucoup de temps.
Et c'est ce qui me dérange: chaque fois que j'implémente une nouvelle fonctionnalité, le processus d'implémentation lui-même ne prend pas beaucoup de temps, la majorité va à deux choses:
- Lire le code des autres et trouver des règles de logique métier.
- S'assurer que rien n'est cassé.
Cela peut sembler être le symptôme d'une mauvaise conception de code, mais la même chose se produit même sur des projets décemment écrits.
D'accord, mais nous pouvons essayer d'utiliser la même chose Result
en C #. L'implémentation la plus évidente ressemblerait à ceci:
public class Result<TOk, TError> { public TOk Ok {get;set;} public TError Error {get;set;} }
et c'est une pure ordure, cela ne nous empêche pas de définir à la fois Ok
et Error
et permet à l'erreur d'être complètement ignorée. La bonne version serait quelque chose comme ceci:
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)); } } }
Assez encombrant, non? Et je n'ai même pas implémenté les versions void
pour Map
et MapError
. L'utilisation ressemblerait à ceci:
void Test(Result<int, string> result) { var squareResult = result.Map(x => x * x); }
Pas si mal, hein? Eh bien, imaginez maintenant que vous avez trois résultats et que vous voulez faire quelque chose avec eux quand ils sont tous Ok
. Méchant. Ce n'est donc pas une option.
Version F #:
//
Fondamentalement, vous devez choisir si vous écrivez une quantité raisonnable de code, mais le code est obscur, s'appuie sur des exceptions, des réflexions, des expressions et d'autres «magie», ou vous écrivez beaucoup plus de code, ce qui est difficile à lire, mais il est plus durable et simple. Quand un tel projet devient grand, vous ne pouvez tout simplement pas le combattre, pas dans les langages avec des systèmes de type C #. Prenons un scénario simple: vous avez une entité dans votre base de code pendant un certain temps. Aujourd'hui, vous souhaitez ajouter un nouveau champ obligatoire. Naturellement, vous devez initialiser ce champ partout où cette entité est créée, mais le compilateur ne vous aide pas du tout, car la classe est mutable et null
est une valeur valide. Et les bibliothèques comme AutoMapper
rendent la AutoMapper
encore plus difficile. Cette mutabilité nous permet d'initialiser partiellement des objets en un seul endroit, puis de les pousser ailleurs et de continuer l'initialisation là-bas. C'est une autre source de bugs.
Pendant ce temps, la comparaison des fonctionnalités linguistiques est agréable, mais ce n'est pas l'objet de cet article. Si cela vous intéresse, j'ai couvert ce sujet dans mon précédent article . Mais les fonctionnalités linguistiques elles-mêmes ne devraient pas être une raison pour changer de technologie.
Cela nous amène donc à ces questions:
- Pourquoi avons-nous vraiment besoin de passer de la POO moderne?
- Pourquoi devrions-nous passer à FP?
La réponse à la première question est que l'utilisation de langages POO communs pour les applications modernes vous pose beaucoup de problèmes, car ils ont été conçus pour des usages différents. Il en résulte du temps et de l'argent que vous dépensez pour combattre leur conception ainsi que la complexité de votre application.
Et la deuxième réponse est que les langages FP vous donnent un moyen facile de concevoir vos fonctionnalités afin qu'elles fonctionnent comme une horloge, et si une nouvelle fonctionnalité rompt la logique existante, elle casse le code, vous le savez donc immédiatement.
Mais ces réponses ne suffisent pas. Comme mon ami l'a souligné lors d'une de nos discussions, passer à la PF serait inutile lorsque vous ne connaissez pas les meilleures pratiques. Notre grande industrie a produit des tonnes d'articles, de livres et de tutoriels sur la conception d'applications OOP, et nous avons une expérience de production avec OOP, donc nous savons à quoi s'attendre des différentes approches. Malheureusement, ce n'est pas le cas pour la programmation fonctionnelle, donc même si vous passez à la FP, vos premières tentatives seront probablement maladroites et ne vous apporteront certainement pas le résultat souhaité: développement rapide et indolore de systèmes complexes.
Eh bien, c'est précisément de cela qu'il s'agit. Comme je l'ai dit, nous allons créer une application de type production pour voir la différence.
Comment concevons-nous l'application?
Une grande partie de ces idées que j'ai utilisées dans le processus de conception, j'ai emprunté au grand livre Domain Modeling Made Functional , donc je vous encourage fortement à le lire.
Le code source complet avec commentaires est ici . Naturellement, je ne vais pas mettre tout cela ici, donc je vais juste passer en revue les points clés.
Nous aurons 4 projets principaux: couche métier, couche d'accès aux données, infrastructure et, bien sûr, commun. Chaque solution l'a, non?
Nous commençons par modéliser notre domaine. À ce stade, nous ne savons pas et ne nous soucions pas de la base de données. C'est fait exprès, car ayant une base de données spécifique à l'esprit, nous avons tendance à concevoir notre domaine en fonction de cela, nous apportons cette relation entité-table dans la couche métier, ce qui pose plus tard des problèmes. Vous n'avez besoin d'implémenter le domain -> DAL
mappage domain -> DAL
qu'une seule fois, tandis qu'une mauvaise conception nous gênera constamment jusqu'au moment où nous le corrigerons. Alors, voici ce que nous faisons: nous créons un projet nommé CardManagement
(très créatif, je sais), et <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
immédiatement le paramètre <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
dans le fichier de projet. Pourquoi en avons-nous besoin? Eh bien, nous allons utiliser massivement les unions discriminées, et lorsque vous effectuez une correspondance de modèle, le compilateur nous avertit, si nous ne couvrons pas tous les cas possibles:
let fail result = match result with | Ok v -> printfn "%A" v //
Avec ce paramètre activé, ce code ne compilera tout simplement pas, ce qui est exactement ce dont nous avons besoin, lorsque nous étendons les fonctionnalités existantes et que nous voulons les ajuster partout. La prochaine chose que nous faisons est de créer un module (il compile dans une classe statique) CardDomain
. Dans ce fichier, nous décrivons les types de domaine et rien de plus. Gardez à l'esprit qu'en F #, le code et l'ordre des fichiers sont importants: par défaut, vous ne pouvez utiliser que ce que vous avez déclaré précédemment.
Types de domaine
Nous commençons à définir nos types avec CardNumber
j'ai montré auparavant, bien que nous ayons besoin d'une Error
plus pratique qu'une simple chaîne, nous allons donc utiliser 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"
Ensuite, nous définissons bien sûr Card
qui est le cœur de notre domaine. Nous savons que la carte a des attributs permanents comme le numéro, la date d'expiration et le nom sur la carte, et certaines informations modifiables comme le solde et la limite quotidienne, nous encapsulons donc ces informations modifiables dans un autre type:
type AccountInfo = { HolderId: UserId Balance: Money DailyLimit: DailyLimit } type Card = { CardNumber: CardNumber Name: LetterString HolderId: UserId Expiration: (Month * Year) AccountDetails: CardAccountInfo }
Maintenant, il existe plusieurs types ici, que nous n'avons pas encore déclarés:
De l'argent
Nous pourrions utiliser decimal
(et nous le ferons, mais pas directement), mais decimal
est moins descriptif. En outre, il peut être utilisé pour représenter d'autres choses que l'argent, et nous ne voulons pas qu'il soit mélangé. Nous utilisons donc le type de type [<Struct>] Money = Money of decimal
personnalisé type [<Struct>] Money = Money of decimal
.
Dailylimit
La limite quotidienne peut être fixée à un montant spécifique ou être absente du tout. S'il est présent, il doit être positif. Au lieu d'utiliser decimal
ou Money
nous définissons ce type:
[<Struct>] type DailyLimit = private //
C'est plus descriptif que de laisser entendre que 0M
signifie qu'il n'y a pas de limite, car cela pourrait également signifier que vous ne pouvez pas dépenser d'argent pour cette carte. Le seul problème est que puisque nous avons caché le constructeur, nous ne pouvons pas faire de correspondance de motifs. Mais pas de soucis, nous pouvons utiliser des modèles actifs :
let (|Limit|Unlimited|) limit = match limit with | Limit dec -> Limit dec | Unlimited -> Unlimited
Maintenant, nous pouvons faire correspondre le DailyLimit
partout comme un DU normal.
Chaîne de lettres
Celui-là est simple. Nous utilisons la même technique que dans CardNumber
. Une petite chose cependant: LetterString
ne concerne guère les cartes de crédit, c'est plutôt une chose et nous devrions le déplacer dans Common
projet Common
dans le module CommonTypes
. Le moment venu, nous déplaçons également ValidationError
dans un endroit séparé.
ID utilisateur
Celui-ci est juste un type UserId = System.Guid
alias type UserId = System.Guid
. Nous l'utilisons uniquement à des fins de description.
Mois et année
Celles-ci doivent aussi aller à Common
. Month
va être une union discriminée avec des méthodes pour le convertir vers et depuis unsigned int16
, l' Year
va être comme CardNumber
mais pour uint16
au lieu de chaîne.
Terminons maintenant notre déclaration de types de domaine. Nous avons besoin d' User
avec certaines informations utilisateur et collecte de cartes, nous avons besoin d'opérations d'équilibre pour les recharges et les paiements.
type UserInfo = { Name: LetterString Id: UserId Address: Address } type User = { UserInfo : UserInfo Cards: Card list } [<Struct>] type BalanceChange = //
Bon, nous avons conçu nos types de manière à ce que l'état invalide ne soit pas représentable. Maintenant, chaque fois que nous traitons une instance de l'un de ces types, nous sommes sûrs que les données y sont valides et nous n'avons pas à les valider à nouveau. Nous pouvons maintenant passer à la logique métier!
Logique métier
Nous aurons ici une règle incassable: toute logique métier va être codée en fonctions pures . Une fonction pure est une fonction qui satisfait aux critères suivants:
- La seule chose qu'il fait est de calculer la valeur de sortie. Il n'a aucun effet secondaire.
- Il produit toujours la même sortie pour la même entrée.
Par conséquent, les fonctions pures ne lèvent pas d'exceptions, ne produisent pas de valeurs aléatoires, n'interagissent pas avec le monde extérieur sous quelque forme que ce soit, que ce soit une base de données ou un simple DateTime.Now
. Bien sûr, l'interaction avec la fonction impure rend automatiquement la fonction d'appel impure. Alors, que devons-nous mettre en œuvre?
Voici une liste des exigences que nous avons:
Activer / désactiver la carte
Traiter les paiements
Nous pouvons traiter le paiement si:
- La carte n'est pas expirée
- La carte est active
- Il y a assez d'argent pour le paiement
- Les dépenses d'aujourd'hui n'ont pas dépassé la limite quotidienne.
Recharger le solde
Nous pouvons recharger le solde de la carte active et non expirée.
Fixer une limite quotidienne
L'utilisateur peut définir une limite quotidienne si la carte n'est pas expirée et est active.
Lorsque l'opération ne peut pas être terminée, nous devons renvoyer une erreur, nous devons donc définir OperationNotAllowedError
:
type OperationNotAllowedError = { Operation: string Reason: string } //
Dans ce module avec une logique métier, ce serait le seul type d'erreur que nous renvoyons. Nous ne faisons pas de validation ici, n'interagissons pas avec la base de données - exécutons simplement des opérations si nous pouvons sinon retourner OperationNotAllowedError
.
Le module complet peut être trouvé ici . Je vais énumérer ici le cas le plus délicat ici: processPayment
. Nous devons vérifier l'expiration, le statut actif / désactivé, l'argent dépensé aujourd'hui et le solde actuel. Comme nous ne pouvons pas interagir avec le monde extérieur, nous devons transmettre toutes les informations nécessaires en tant que paramètres. De cette façon, cette logique serait très facile à tester et vous permet de faire des tests basés sur les propriétés .
let processPayment (currentDate: DateTimeOffset) (spentToday: Money) card (paymentAmount: MoneyTransaction) = //
Ce jour spentToday
- nous devrons le calculer à partir de la collection BalanceOperation
nous conserverons dans la base de données. Nous aurons donc besoin d'un module pour cela, qui aura essentiellement 1 fonction publique:
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
Bon Maintenant que nous avons terminé toute l'implémentation de la logique métier, il est temps de penser à la cartographie. Beaucoup de nos types utilisent des unions discriminées, certains de nos types n'ont pas de constructeur public, nous ne pouvons donc pas les exposer tels quels au monde extérieur. Nous devrons nous occuper de la (dé) sérialisation. En plus de cela, en ce moment, nous n'avons qu'un seul contexte borné dans notre application, mais plus tard dans la vie réelle, vous voudriez construire un système plus grand avec plusieurs contextes bornés, et ils doivent interagir les uns avec les autres via des contrats publics, qui devraient être compréhensibles pour tout le monde, y compris les autres langages de programmation.
Nous devons faire la cartographie dans les deux sens: des modèles publics au domaine et vice versa. Alors que le mappage du domaine vers les modèles est assez étroit, l'autre sens a un peu de sens: les modèles peuvent avoir des données non valides, après tout, nous utilisons des types simples qui peuvent être sérialisés en json. Ne vous inquiétez pas, nous devrons construire notre validation dans cette cartographie. Le fait même que nous utilisons différents types pour des données et des données éventuellement invalides, c'est toujours un moyen valide, que le compilateur ne nous oubliera pas d'exécuter la validation.
Voici à quoi ça ressemble:
(**) 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 } }
Le module complet pour les mappages et les validations est ici et le module pour le mappage aux modèles est ici .
À ce stade, nous avons une implémentation pour toute la logique métier, les mappages, la validation, etc., et jusqu'à présent, tout cela est complètement isolé du monde réel: il est entièrement écrit dans des fonctions pures. Maintenant vous vous demandez peut-être, comment allons-nous utiliser exactement cela? Parce que nous devons interagir avec le monde extérieur. Plus encore, lors d'une exécution de workflow, nous devons prendre des décisions en fonction des résultats de ces interactions réelles. La question est donc de savoir comment assembler tout cela? Dans la POO, ils utilisent des conteneurs IoC pour s'en occuper, mais ici, nous ne pouvons pas le faire, car nous n'avons même pas d'objets, nous avons des fonctions statiques.
Nous allons utiliser le Interpreter pattern
pour cela! C'est un peu délicat, principalement parce qu'il n'est pas familier, mais je ferai de mon mieux pour expliquer ce schéma. Parlons d'abord de la composition des fonctions. Par exemple, nous avons une fonction int -> string
. Cela signifie que la fonction attend int
comme paramètre et renvoie une chaîne. Supposons maintenant que nous ayons une autre string -> char
fonction string -> char
. À ce stade, nous pouvons les enchaîner, c'est-à-dire exécuter la première, prendre sa sortie et la transmettre à la deuxième fonction, et il y a même un opérateur pour cela: >>
. Voici comment cela fonctionne:
let intToString (i: int) = i.ToString() let firstCharOrSpace (s: string) = match s with | (null| "") -> ' ' | s -> s.[0] let firstDigitAsChar = intToString >> firstCharOrSpace //
Cependant, nous ne pouvons pas utiliser le chaînage simple dans certains scénarios, par exemple l'activation d'une carte. Voici une séquence d'actions:
- valider le numéro de carte d'entrée. Si c'est valide, alors
- essayez d'obtenir la carte par ce numéro. S'il y en a un
- l'activer.
- enregistrer les résultats. Si c'est ok alors
- carte à modéliser et à retourner.
Les deux premières étapes ont que If it's ok then...
C'est la raison pour laquelle le chaînage direct ne fonctionne pas.
Nous pourrions simplement injecter comme paramètres ces fonctions, comme ceci:
let activateCard getCardAsync saveCardAsync cardNumber = ...
Mais cela pose certains problèmes. Tout d'abord, le nombre de dépendances peut devenir important et la signature de la fonction sera moche. Deuxièmement, nous sommes liés à des effets spécifiques ici: nous devons choisir s'il s'agit d'une Task
ou d'une Async
ou simplement de simples appels de synchronisation. Troisièmement, il est facile de gâcher les choses lorsque vous avez autant de fonctions à passer: par exemple, createUserAsync
et replaceUserAsync
ont la même signature mais des effets différents, donc lorsque vous devez les passer des centaines de fois, vous pouvez faire une erreur avec des symptômes vraiment étranges. Pour ces raisons, nous optons pour un interprète.
L'idée est que nous divisons notre code de composition en 2 parties: arbre d'exécution et interprète pour cet arbre. Chaque nœud de cet arbre est un endroit pour une fonction avec effet que nous voulons injecter, comme getUserFromDatabase
. Ces nœuds sont définis par leur nom, par exemple getCard
, le type de paramètre d'entrée, par exemple CardNumber
et le type de retour, par exemple l' Card option
. Nous ne spécifions pas ici Task
ou Async
, ce n'est pas la partie de l'arborescence, c'est une partie de l'interpréteur . Chaque bord de cet arbre est une série de transformations pures, comme la validation ou l'exécution de fonctions de logique métier. Les bords ont également une entrée, par exemple le numéro de carte de chaîne brute, puis il y a la validation, ce qui peut nous donner une erreur ou un numéro de carte valide. S'il y a une erreur, nous allons interrompre ce bord, sinon, cela nous mènera au nœud suivant: getCard
. Si ce nœud retourne Some card
, nous pouvons passer au bord suivant, qui serait l'activation, etc.
Pour chaque scénario comme activateCard
ou topUp
ou topUp
nous allons construire une arborescence distincte. Lorsque ces arbres sont construits, leurs nœuds sont un peu vides, ils n'ont pas de fonctions réelles, ils ont une place pour ces fonctions. Le but de l'interpréteur est de remplir ces nœuds, aussi simple que cela. L'interpréteur connaît les effets que nous utilisons, par exemple Task
, et il sait quelle fonction réelle mettre dans un nœud donné. Lorsqu'il visite un nœud, il exécute la fonction réelle correspondante, l'attend en cas de Task
ou d' Async
et transmet le résultat au bord suivant. Ce bord peut conduire à un autre nœud, puis c'est à nouveau un travail pour l'interprète, jusqu'à ce que cet interprète atteigne le nœud d'arrêt, le bas de notre récursivité, où nous retournons simplement le résultat de l'exécution entière de notre arbre.
L'arbre entier serait représenté avec une union discriminée, et un nœud ressemblerait à ceci:
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) //
Ce sera toujours un tuple, où le premier élément est une entrée pour votre dépendance, et le dernier élément est une fonction , qui reçoit le résultat de cette dépendance. Cet "espace" entre ces éléments de tuple est l'endroit où votre dépendance s'intégrera, comme dans ces exemples de composition, où vous avez la fonction 'a -> 'b
, 'c -> 'd
et vous devez en mettre un autre 'b -> 'c
entre les deux pour les connecter.
Puisque nous sommes à l'intérieur de notre contexte borné, nous ne devrions pas avoir trop de dépendances, et si nous le faisons - c'est probablement le moment de diviser notre contexte en plus petits.
Voici à quoi cela ressemble, la source complète est ici :
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 .
Conclusion
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.