Lutter contre la complexité du développement logiciel

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.


Les outils que nous avons et ce qu'ils nous donnent


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:


 (*{- First we define a type for CardNumber with private constructor and public factory which receives string and returns `Result<CardNumber, string>`. Normally we would use `ValidationError` instead, but string is good enough for example -}*) 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" (*{- Then in here we express this logic "when card is deactivated, balance and daily limit manipulations aren't available". Note that this is way easier to grasp that reading `RuleFor()` in validators. -}*) type CardAccountInfo = | Active of AccountInfo | Deactivated (*{- And then that's it. The whole set of rules is here, and it's described in a static way. We don't need tests for that, the compiler is our test. And we can't accidentally miss this validation. -}*) type Card = { CardNumber: CardNumber Name: LetterString //-- LetterString is another type with built-in validation HolderId: UserId Expiration: (Month * Year) AccountDetails: CardAccountInfo } 

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 #:


 //-- this type is in standard library, but declaration looks like this: type Result<'ok, 'error> = | Ok of 'ok | Error of 'error //-- and usage: let test res1 res2 res3 = match res1, res2, res3 with | Ok ok1, Ok ok2, Ok ok3 -> printfn "1: %A 2: %A 3: %A" ok1 ok2 ok3 | _ -> printfn "fail" 

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:


  1. Pourquoi avons-nous vraiment besoin de passer de la POO moderne?
  2. 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 //-- warning: Incomplete pattern matches on this expression. //-- For example, the value 'Error' may indicate a case not covered by the pattern(s). 

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 } (*{- Actually we should use here Luhn's algorithm, but I leave it to you as an exercise, so you can see for yourself how easy is updating code to new requirements. -}*) 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:


  1. 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 .


  2. 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 //-- private constructor so it can't be created directly outside of module | Limit of Money | Unlimited with static member ofDecimal dec = if dec > 0m then Money dec |> Limit else Unlimited member this.ToDecimalOption() = match this with | Unlimited -> None | Limit limit -> Some limit.Value 

    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.


  3. 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é.


  4. ID utilisateur


    Celui-ci est juste un type UserId = System.Guid alias type UserId = System.Guid . Nous l'utilisons uniquement à des fins de description.


  5. 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 = //-- another common type with validation for positive amount | Increase of increase: MoneyTransaction | Decrease of decrease: MoneyTransaction with member this.ToDecimal() = match this with | Increase i -> i.Value | Decrease d -> -d.Value [<Struct>] type BalanceOperation = { CardNumber: CardNumber Timestamp: DateTimeOffset BalanceChange: BalanceChange NewBalance: Money } 

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:


    1. La carte n'est pas expirée
    2. La carte est active
    3. Il y a assez d'argent pour le paiement
    4. 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 } //-- and a helper function to wrap it in `Error` which is a case for `Result<'ok,'error> type let operationNotAllowed operation reason = { Operation = operation; Reason = reason } |> Error 

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) = //-- first check for expiration if isCardExpired currentDate card then cardExpiredMessage card.CardNumber |> processPaymentNotAllowed else //-- then active/deactivated match card.AccountDetails with | Deactivated -> cardDeactivatedMessage card.CardNumber |> processPaymentNotAllowed | Active accInfo -> //-- if active then check balance if paymentAmount.Value > accInfo.Balance.Value then sprintf "Insufficent funds on card %s" card.CardNumber.Value |> processPaymentNotAllowed else //-- if balance is ok check limit and money spent today match accInfo.DailyLimit with | Limit limit when limit < spentToday + paymentAmount -> sprintf "Daily limit is exceeded for card %s with daily limit %M. Today was spent %M" card.CardNumber.Value limit.Value spentToday.Value |> processPaymentNotAllowed (*{- We could use here the ultimate wild card case like this: | _ -> but it's dangerous because if a new case appears in `DailyLimit` type, we won't get a compile error here, which would remind us to process this new case in here. So this is a safe way to do the same thing. -}*) | Limit _ | Unlimited -> let newBalance = accInfo.Balance - paymentAmount let updatedCard = { card with AccountDetails = Active { accInfo with Balance = newBalance } } //-- note that we have to return balance operation, //-- so it can be stored to DB later. let balanceOperation = { Timestamp = currentDate CardNumber = card.CardNumber NewBalance = newBalance BalanceChange = Decrease paymentAmount } Ok (updatedCard, balanceOperation) 

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:


 (*{- You can use type aliases to annotate your functions. This is just an example, but sometimes it makes code more readable -}*) type ValidateCreateCardCommand = CreateCardCommandModel -> ValidationResult<Card> let validateCreateCardCommand : ValidateCreateCardCommand = fun cmd -> (*{- that's a computation expression for `Result<>` type. Thanks to this we don't have to chose between short code and strait forward one, like we have to do in C# -}*) 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 //-- And you can chain as many functions as you like let alwaysTrue = intToString >> firstCharOrSpace >> Char.IsDigit 

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>) //-- <- THE NODE | ... //-- ANOTHER NODE 

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 (*{- This bind function allows you to pass a continuation for current node of your expression tree the code is basically a boiler plate, as you can see. -}*) 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 (*{- This is a set of basic functions. Use them in your expression tree builder to represent dependency call -}*) 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 :


 (*{- `program` is the name of our computation expression. In every `let!` binding we unwrap the result of operation, which can be either `Program<'a>` or `Program<Result<'a, Error>>`. What we unwrap would be of type 'a. If, however, an operation returns `Error`, we stop the execution at this very step and return it. The only thing we have to take care of is making sure that type of error is the same in every operation we call -}*) let processPayment (currentDate: DateTimeOffset, payment) = program { (*{- You can see these `expectValidationError` and `expectDataRelatedErrors` functions here. What they do is map different errors into `Error` type, since every execution branch must return the same type, in this case `Result<'a, Error>`. They also help you quickly understand what's going on in every line of code: validation, logic or calling external storage. -}*) 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 //-- we're gonna need this in every entity for error messages member this.EntityId = this.CardNumber.ToString() (*{- we use this Id comparer quotation (F# alternative to C# Expression) for updating entity by id, since for different entities identifier has different name and type -}*) member this.IdComparer = <@ System.Func<_,_> (fun c -> c.CardNumber = this.CardNumber) @> 

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) //-- We need to convert F# quotations to C# expressions //-- which C# mongo db driver understands. let inline idComparer (HasIdComparer id) = id() |> LeafExpressionConverter.QuotationToExpression |> unbox<Expression<Func<_,_>>> 

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> //-- then we have this function to convert nulls to option, //-- therefore we limited this toxic null thing in here. let unsafeNullToOption a = if isNullUnsafe a then None else Some a 

In order to deal with expected exception for duplicate key, we use Active Patterns again:


  //-- First we define a function which checks, whether exception is about duplicate key let private isDuplicateKeyException (ex: Exception) = ex :? MongoWriteException && (ex :?> MongoWriteException).WriteError.Category = ServerErrorCategory.DuplicateKey //-- Then we have to check wrapping exceptions for this let rec private (|DuplicateKey|_|) (ex: Exception) = match ex with | :? MongoWriteException as ex when isDuplicateKeyException ex -> Some ex | :? MongoBulkWriteException as bex when bex.InnerException |> isDuplicateKeyException -> Some (bex.InnerException :?> MongoWriteException) | :? AggregateException as aex when aex.InnerException |> isDuplicateKeyException -> Some (aex.InnerException :?> MongoWriteException) | _ -> None //-- And here's the usage: let inline private executeInsertAsync (func: 'a -> Async<unit>) arg = async { try do! func(arg) return Ok () with | DuplicateKey ex -> return EntityAlreadyExists (arg.GetType().Name, (entityId arg)) |> Error } 

After mapping is implemented we have everything we need to assemble API for our data access layer , which looks like this:


  //-- `MongoDb` is a type alias for `IMongoDatabase` let replaceUserAsync (mongoDb: MongoDb) : ReplaceUserAsync = fun user -> user |> DomainToEntityMapping.mapUserToEntity |> CommandRepository.replaceUserAsync mongoDb let getUserInfoAsync (mongoDb: MongoDb) : GetUserInfoAsync = fun userId -> async { let! userInfo = QueryRepository.getUserInfoAsync mongoDb userId return userInfo |> Option.map EntityToDomainMapping.mapUserInfoEntity } 

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:


 (*{- Those `bindAsync (next >> interpretCardProgram mongoDb)` work pretty simple: we execute async function to the left of this expression, await that operation and pass the result to the next node, after which we interpret that node as well, until we reach the bottom of this recursion: `Stop a` node. -}*) 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.

Source: https://habr.com/ru/post/fr458730/


All Articles