Combatir la complejidad en el desarrollo de software.

¿De qué se trata esto?


Después de trabajar en diferentes proyectos, me di cuenta de que cada uno de ellos tenía algunos problemas comunes, independientemente del dominio, la arquitectura, la convención de código, etc. Esos problemas no fueron desafiantes, solo una rutina tediosa: asegurarse de no perderse nada estúpido y obvio. En lugar de hacer esta rutina a diario, me obsesioné con buscar una solución: algún enfoque de desarrollo o convención de código o lo que sea que me ayude a diseñar un proyecto de una manera que evite que ocurran esos problemas, por lo que puedo concentrarme en cosas interesantes . Ese es el objetivo de este artículo: describir esos problemas y mostrarle esa combinación de herramientas y enfoques que encontré para resolverlos.


Problemas que enfrentamos


Mientras desarrollamos software, enfrentamos muchas dificultades en el camino: requisitos poco claros, falta de comunicación, procesos de desarrollo deficientes, etc.


También nos enfrentamos a algunas dificultades técnicas: el código heredado nos ralentiza, el escalado es complicado, algunas malas decisiones del pasado nos ponen en la boca hoy.


Todos ellos pueden eliminarse si no se eliminan significativamente, pero hay un problema fundamental sobre el que no puede hacer nada: la complejidad de su sistema.


La idea de un sistema que está desarrollando es siempre compleja, lo entienda o no.
Incluso cuando está haciendo otra aplicación CRUD , siempre hay algunos casos extremos, algunas cosas difíciles, y de vez en cuando alguien pregunta "Oye, ¿qué pasará si hago esto y esto bajo estas circunstancias?" y dices "Hm, esa es una muy buena pregunta".


Esos casos difíciles, lógica sombría, validación y administración de acceso, todo eso se suma a su gran idea.
Muy a menudo, esa idea es tan grande que no cabe en una cabeza, y ese hecho solo trae problemas como la falta de comunicación.


Pero seamos generosos y supongamos que este equipo de expertos en dominios y analistas de negocios se comunica con claridad y produce requisitos consistentes.


Ahora tenemos que implementarlos, para expresar esa idea compleja en nuestro código. Ahora ese código es otro sistema, mucho más complicado que la idea original que teníamos en mente.


¿Cómo es eso? Se enfrenta a la realidad: las limitaciones técnicas lo obligan a lidiar con alta carga, consistencia de datos y disponibilidad además de implementar la lógica comercial real.


Como puede ver, la tarea es bastante desafiante, y ahora necesitamos las herramientas adecuadas para enfrentarla.
Un lenguaje de programación es solo otra herramienta, y como con cualquier otra herramienta, no se trata solo de la calidad del mismo, probablemente se trata aún más de la herramienta que se ajusta al trabajo. Es posible que tenga el mejor destornillador que existe, pero si necesita poner algunos clavos en la madera, un martillo horrible sería mejor, ¿verdad?


Aspectos técnicos


Los lenguajes más populares hoy en día están orientados a objetos. Cuando alguien hace una introducción a OOP, generalmente usa ejemplos:
Considere un automóvil, que es un objeto del mundo real. Tiene varias propiedades como marca, peso, color, velocidad máxima, velocidad actual, etc.


Para reflejar este objeto en nuestro programa, reunimos esas propiedades en una clase. Las propiedades pueden ser permanentes o mutables, que juntas forman el estado actual de este objeto y algunos límites en los que puede variar. Sin embargo, combinar esas propiedades no es suficiente, ya que tenemos que verificar que el estado actual tenga sentido, por ejemplo, la velocidad actual no excede la velocidad máxima. Para asegurarnos de eso, adjuntamos algo de lógica a esta clase, marque las propiedades como privadas para evitar que alguien cree un estado ilegal.
Como puede ver, los objetos tratan sobre su estado interno y ciclo de vida.


Entonces, estos tres pilares de la POO tienen mucho sentido en este contexto: utilizamos la herencia para reutilizar ciertas manipulaciones de estado, encapsulación para protección del estado y polimorfismo para tratar objetos similares de la misma manera. La mutabilidad como valor predeterminado también tiene sentido, ya que en este contexto el objeto inmutable no puede tener un ciclo de vida y siempre tiene un estado, que no es el caso más común.


La cosa es que cuando miras una aplicación web típica de estos días, no trata con objetos. Casi todo en nuestro código tiene una vida eterna o no tiene una vida útil adecuada. Los dos tipos más comunes de "objetos" son algún tipo de servicios como UserService , EmployeeRepository o algunos modelos / entidades / DTO o como los llame. Los servicios no tienen un estado lógico dentro de ellos, mueren y nacen de nuevo exactamente igual, simplemente recreamos el gráfico de dependencia con una nueva conexión de base de datos.


Las entidades y los modelos no tienen ningún comportamiento asociado, son simplemente conjuntos de datos, su mutabilidad no ayuda, sino todo lo contrario.


Por lo tanto, las características clave de OOP no son realmente útiles para desarrollar este tipo de aplicaciones.


Lo que sucede en una aplicación web típica es el flujo de datos: validación, transformación, evaluación, etc. Y hay un paradigma que encaja perfectamente para ese tipo de trabajo: programación funcional. Y hay una prueba de ello: todas las características modernas en los idiomas populares de hoy provienen de allí: async/await , lambdas y delegados, programación reactiva, uniones discriminadas (enumeraciones rápidas u oxidadas, que no deben confundirse con enumeraciones en Java o .net ), tuplas: todo eso es de FP.


Sin embargo, esos son solo migajas, es muy agradable tenerlos, pero hay más, mucho más.


Antes de profundizar, hay que hacer un punto. Cambiar a un nuevo idioma, especialmente un nuevo paradigma, es una inversión para los desarrolladores y, por lo tanto, para los negocios. Hacer inversiones tontas no le dará más que problemas, pero las inversiones razonables pueden ser lo que lo mantendrá a flote.


Herramientas que tenemos y lo que nos dan


Muchos de nosotros preferimos idiomas con tipeo estático. La razón de esto es simple: el compilador se encarga de verificaciones tediosas como pasar parámetros adecuados a las funciones, construir nuestras entidades correctamente, etc. Estos cheques son gratuitos. Ahora, en cuanto a las cosas que el compilador no puede verificar, tenemos una opción: esperar lo mejor o hacer algunas pruebas. Escribir exámenes significa dinero, y no paga solo una vez por examen, debe mantenerlos. Además, las personas se vuelven descuidadas, por lo que de vez en cuando obtenemos resultados falsos positivos y falsos negativos. Cuantas más pruebas tenga que escribir, menor será la calidad promedio de esas pruebas. Hay otro problema: para probar algo, debes saber y recordar que esa cosa debe probarse, pero cuanto más grande sea tu sistema, más fácil será perder algo.


Sin embargo, el compilador es tan bueno como el sistema de tipos del lenguaje. Si no le permite expresar algo de forma estática, debe hacerlo en tiempo de ejecución. Lo que significa pruebas, sí. Sin embargo, no solo se trata del sistema de tipos, la sintaxis y las características pequeñas de azúcar también son muy importantes, porque al final del día queremos escribir el menor código posible, por lo que si algún enfoque requiere que escriba diez veces más líneas, bueno, Nadie lo va a usar. Es por eso que es importante que el idioma que elija tenga el conjunto adecuado de características y trucos, bueno, enfoque correcto en general. Si no es así, en lugar de usar sus funciones para combatir desafíos originales como la complejidad de su sistema y los requisitos cambiantes, también estará luchando contra el idioma. Y todo se reduce a dinero, ya que paga a los desarrolladores por su tiempo. Mientras más problemas tengan que resolver, más tiempo necesitarán y más desarrolladores necesitarán.


Finalmente estamos a punto de ver un código para probar todo eso. Soy un desarrollador de .NET, por lo que las muestras de código estarán en C # y F #, pero la imagen general se vería más o menos igual en otros lenguajes populares de OOP y FP.


Que comience la codificación


Vamos a construir una aplicación web para administrar tarjetas de crédito.


Requerimientos básicos:


  • Crear / leer usuarios
  • Crear / leer tarjetas de crédito
  • Activar / Desactivar tarjetas de crédito
  • Establecer límite diario para tarjetas
  • Saldo de recarga
  • Procesar pagos (considerando saldo, fecha de vencimiento de la tarjeta, estado activo / desactivado y límite diario)

En aras de la simplicidad, utilizaremos una tarjeta por cuenta y omitiremos la autorización. Pero por lo demás, crearemos una aplicación capaz con validación, manejo de errores, base de datos y API web. Así que vamos a nuestra primera tarea: diseñar tarjetas de crédito.


Primero, veamos cómo se vería 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;} } 

Pero eso no es suficiente, tenemos que agregar validación, y comúnmente se está haciendo en algún Validator , como el de FluentValidation .


Las reglas son simples:


  • El número de tarjeta es obligatorio y debe ser una cadena de 16 dígitos.
  • El nombre es obligatorio y debe contener solo letras y puede contener espacios en el medio.
  • Mes y año tienen que satisfacer los límites.
  • La información de la cuenta debe estar presente cuando la tarjeta está activa y ausente cuando la tarjeta está desactivada. Si se pregunta por qué, es simple: cuando la tarjeta está desactivada, no debería ser posible cambiar el saldo o el límite diario.

 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"); } } 

Ahora hay varios problemas con este enfoque:


  • La validación está separada de la declaración de tipo, lo que significa que para ver la imagen completa de qué tarjeta es realmente tenemos que navegar a través del código y recrear esta imagen en nuestra cabeza. No es un gran problema cuando ocurre solo una vez, pero cuando tenemos que hacer eso para cada entidad en un gran proyecto, bueno, lleva mucho tiempo.
  • Esta validación no es forzada, debemos tenerla en cuenta para usarla en todas partes. Podemos asegurar esto con las pruebas, pero, de nuevo, debe recordarlo cuando escriba las pruebas.
  • Cuando queremos validar el número de tarjeta en otros lugares, tenemos que hacer lo mismo de nuevo. Claro, podemos mantener la expresión regular en un lugar común, pero aún así tenemos que llamarlo en cada validador.

En F # podemos hacerlo de una manera diferente:


 (*{- 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 } 

Por supuesto, algunas cosas de aquí podemos hacer en C #. Podemos crear la clase CardNumber que arrojará ValidationException allí también. Pero ese truco con CardAccountInfo no se puede hacer en C # de manera fácil.
Otra cosa: C # depende en gran medida de las excepciones. Hay varios problemas con eso:


  • Las excepciones tienen semántica "ir a". En un momento estás aquí con este método, otro: terminaste en un controlador global.
  • No aparecen en la firma del método. Excepciones como ValidationException o InvalidUserOperationException son parte del contrato, pero no lo sabrá hasta que lea la implementación . Y es un problema importante, porque a menudo tienes que usar código escrito por otra persona, y en lugar de leer solo la firma, tienes que navegar hasta el final de la pila de llamadas, lo que lleva mucho tiempo.

Y esto es lo que me molesta: cada vez que implemento una nueva característica, el proceso de implementación en sí no toma mucho tiempo, la mayoría se dirige a dos cosas:


  • Lectura del código de otras personas y descifrar las reglas de lógica de negocios.
  • Asegurándose de que nada esté roto.

Puede sonar como un síntoma de un mal diseño de código, pero es lo mismo que sucede incluso en proyectos decentemente escritos.
De acuerdo, pero podemos intentar usar el mismo Result en C #. La implementación más obvia se vería así:


 public class Result<TOk, TError> { public TOk Ok {get;set;} public TError Error {get;set;} } 

y es pura basura, no nos impide establecer Ok y Error y permite que el error se ignore por completo. La versión adecuada sería algo como esto:


 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)); } } } 

Bastante engorroso, ¿verdad? Y ni siquiera implementé las versiones void para Map y MapError . El uso se vería así:


 void Test(Result<int, string> result) { var squareResult = result.Map(x => x * x); } 

No tan mal, ¿eh? Bueno, ahora imagina que tienes tres resultados y quieres hacer algo con ellos cuando todos estén Ok . Desagradable Entonces esa no es una opción.
Versión 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" 

Básicamente, tiene que elegir si escribe una cantidad razonable de código, pero el código es oscuro, se basa en excepciones, reflejos, expresiones y otra "magia", o escribe mucho más código, que es difícil de leer, pero es más duradero Y directo. Cuando un proyecto así crece, simplemente no puedes luchar contra él, no en lenguajes con sistemas tipo C #. Consideremos un escenario simple: tiene alguna entidad en su base de código por un tiempo. Hoy desea agregar un nuevo campo obligatorio. Naturalmente, necesita inicializar este campo en todas partes donde se crea esta entidad, pero el compilador no lo ayuda en absoluto, ya que la clase es mutable y null es un valor válido. Y las bibliotecas como AutoMapper hacen aún más difícil. Esta mutabilidad nos permite inicializar parcialmente los objetos en un lugar, luego empujarlo a otro lugar y continuar la inicialización allí. Esa es otra fuente de errores.


Mientras tanto, la comparación de características del lenguaje es agradable, sin embargo, no es de lo que trata este artículo. Si le interesa, cubrí ese tema en mi artículo anterior . Pero las características del lenguaje en sí mismas no deberían ser una razón para cambiar de tecnología.


Eso nos lleva a estas preguntas:


  1. ¿Por qué realmente necesitamos cambiar de la OOP moderna?
  2. ¿Por qué deberíamos cambiar a FP?

La respuesta a la primera pregunta es que el uso de lenguajes OOP comunes para aplicaciones modernas le genera muchos problemas, ya que fueron diseñados para diferentes propósitos. Resulta en tiempo y dinero que gasta para combatir su diseño junto con la complejidad de su aplicación.


Y la segunda respuesta es que los lenguajes FP le brindan una manera fácil de diseñar sus funciones para que funcionen como un reloj, y si una nueva función rompe la lógica existente, se rompe el código, de ahí que lo sepa de inmediato.




Sin embargo, esas respuestas no son suficientes. Como señaló mi amigo durante una de nuestras discusiones, cambiar a FP sería inútil cuando no conoces las mejores prácticas. Nuestra gran industria produjo toneladas de artículos, libros y tutoriales sobre el diseño de aplicaciones OOP, y tenemos experiencia en producción con OOP, por lo que sabemos qué esperar de los diferentes enfoques. Desafortunadamente, no es el caso de la programación funcional, por lo que incluso si cambia a FP, sus primeros intentos probablemente sean incómodos y ciertamente no le brindarán el resultado deseado: desarrollo rápido e indoloro de sistemas complejos.


Bueno, eso es precisamente de lo que trata este artículo. Como dije, construiremos una aplicación similar a la producción para ver la diferencia.


¿Cómo diseñamos la aplicación?


Muchas de estas ideas que utilicé en el proceso de diseño lo tomé prestado del gran libro Domain Modeling Made Functional , por lo que le recomiendo que lo lea.


El código fuente completo con comentarios está aquí . Naturalmente, no voy a poner todo aquí, así que solo caminaré por los puntos clave.


Tendremos 4 proyectos principales: capa empresarial, capa de acceso a datos, infraestructura y, por supuesto, común. Cada solución lo tiene, ¿verdad?


Comenzamos modelando nuestro dominio. En este punto no sabemos y no nos importa la base de datos. Se hace a propósito, porque teniendo en cuenta una base de datos específica, tendemos a diseñar nuestro dominio de acuerdo con él, traemos esta relación entidad-tabla en la capa empresarial, lo que luego trae problemas. Solo necesita implementar el domain -> DAL mapeo domain -> DAL una vez, mientras que un diseño incorrecto nos molestará constantemente hasta el punto en que lo arreglemos. Entonces, esto es lo que hacemos: creamos un proyecto llamado CardManagement (muy creativo, lo sé) e inmediatamente <TreatWarningsAsErrors>true</TreatWarningsAsErrors> la configuración <TreatWarningsAsErrors>true</TreatWarningsAsErrors> en el archivo del proyecto. ¿Por qué necesitamos esto? Bueno, usaremos uniones discriminadas en gran medida, y cuando haces coincidencia de patrones, el compilador nos da una advertencia, si no cubrimos todos los casos posibles:


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

Con esta configuración activada, este código simplemente no se compilará, que es exactamente lo que necesitamos, cuando ampliamos la funcionalidad existente y queremos que se ajuste en todas partes. Lo siguiente que hacemos es crear un módulo (se compila en una clase estática) CardDomain . En este archivo describimos los tipos de dominio y nada más. Tenga en cuenta que en F #, el código y el orden de los archivos son importantes: de manera predeterminada, solo puede usar lo que declaró anteriormente.


Tipos de dominio


Comenzamos a definir nuestros tipos con CardNumber que mostré antes, aunque necesitaremos un Error más práctico que solo una cadena, por lo que usaremos 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" 

Luego, por supuesto, definimos la Card que es el corazón de nuestro dominio. Sabemos que la tarjeta tiene algunos atributos permanentes como número, fecha de vencimiento y nombre en la tarjeta, y cierta información modificable como saldo y límite diario, por lo que encapsulamos esa información modificable en otro tipo:


 type AccountInfo = { HolderId: UserId Balance: Money DailyLimit: DailyLimit } type Card = { CardNumber: CardNumber Name: LetterString HolderId: UserId Expiration: (Month * Year) AccountDetails: CardAccountInfo } 

Ahora, hay varios tipos aquí, que aún no hemos declarado:


  1. Dinero


    Podríamos usar decimal (y lo haremos, pero no directamente), pero decimal es menos descriptivo. Además, puede usarse para representar otras cosas además del dinero, y no queremos que se mezcle. Entonces usamos el tipo de type [<Struct>] Money = Money of decimal personalizado type [<Struct>] Money = Money of decimal .


  2. Límite diario


    El límite diario puede establecerse en una cantidad específica o estar ausente. Si está presente, debe ser positivo. En lugar de usar decimal o Money , definimos este tipo:


     [<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 

    Es más descriptivo que simplemente implicar que 0M significa que no hay límite, ya que también podría significar que no puede gastar dinero en esta tarjeta. El único problema es que, como hemos ocultado el constructor, no podemos hacer coincidencias de patrones. Pero no se preocupe, podemos usar patrones activos :


     let (|Limit|Unlimited|) limit = match limit with | Limit dec -> Limit dec | Unlimited -> Unlimited 

    Ahora podemos igualar el patrón DailyLimit todas partes como un DU normal.


  3. Cadena de letras


    Ese es simple. Usamos la misma técnica que en CardNumber . Sin embargo, una pequeña cosa: LetterString no se trata de tarjetas de crédito, es algo más y deberíamos moverlo en Common proyecto Common en el módulo CommonTypes . Llega el momento de mover ValidationError a un lugar separado también.


  4. ID de usuario


    Ese es solo un type UserId = System.Guid alias type UserId = System.Guid . Lo usamos solo con fines descriptivos.


  5. Mes y año


    Esos tienen que ir a Common también. Month será una unión discriminada con métodos para convertirlo desde y hacia unsigned int16 , Year será como CardNumber pero para uint16 lugar de string.



Ahora terminemos nuestra declaración de tipos de dominio. Necesitamos User con cierta información de usuario y recopilación de tarjetas, necesitamos operaciones de saldo para recargas y pagos.


  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 } 

Bien, diseñamos nuestros tipos de manera que el estado inválido no sea representable. Ahora, cuando tratamos con instancias de cualquiera de estos tipos, estamos seguros de que los datos allí son válidos y no tenemos que validarlos nuevamente. ¡Ahora podemos proceder a la lógica de negocios!


Lógica de negocios


Tendremos una regla inquebrantable aquí: toda la lógica de negocios se codificará en funciones puras . Una función pura es una función que satisface los siguientes criterios:


  • Lo único que hace es calcular el valor de salida. No tiene efectos secundarios en absoluto.
  • Siempre produce la misma salida para la misma entrada.

Por lo tanto, las funciones puras no arrojan excepciones, no producen valores aleatorios, no interactúan con el mundo exterior de ninguna forma, ya sea una base de datos o un simple DateTime.Now . Por supuesto, interactuar con la función impura automáticamente hace que la función de llamada sea impura. Entonces, ¿qué implementaremos?


Aquí hay una lista de requisitos que tenemos:


  • Activar / desactivar tarjeta


  • Procesar pagos


    Podemos procesar el pago si:


    1. La tarjeta no ha caducado
    2. La tarjeta esta activa
    3. Hay suficiente dinero para el pago.
    4. Los gastos de hoy no han excedido el límite diario.

  • Saldo de recarga


    Podemos recargar el saldo de la tarjeta activa y no vencida.


  • Establecer límite diario


    El usuario puede establecer un límite diario si la tarjeta no ha caducado y está activa.



Cuando no se puede completar la operación, tenemos que devolver un error, por lo que debemos definir 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 

En este módulo con lógica de negocios, ese sería el único tipo de error que devolveremos. Aquí no hacemos validación, no interactuamos con la base de datos, solo ejecutamos operaciones si de lo contrario podemos devolver OperationNotAllowedError .


El módulo completo se puede encontrar aquí . Aquí enumeraré el caso más complicado: processPayment . Tenemos que verificar la caducidad, el estado activo / desactivado, el dinero gastado hoy y el saldo actual. Como no podemos interactuar con el mundo exterior, tenemos que pasar toda la información necesaria como parámetros. De esta forma, esta lógica sería muy fácil de probar y le permite realizar pruebas basadas en propiedades .


 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) 

Esto spentToday : tendremos que calcularlo a partir de la colección BalanceOperation que mantendremos en la base de datos. Entonces necesitaremos un módulo para eso, que básicamente tendrá 1 función pública:


  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 

Bueno Ahora que hemos terminado con toda la implementación de la lógica de negocios, es hora de pensar en el mapeo. Muchos de nuestros tipos usan uniones discriminadas, algunos de nuestros tipos no tienen un constructor público, por lo que no podemos exponerlos como es al mundo exterior. Tendremos que lidiar con la (des) serialización. Además de eso, en este momento solo tenemos un contexto limitado en nuestra aplicación, pero más adelante en la vida real querrás construir un sistema más grande con múltiples contextos limitados, y tienen que interactuar entre sí a través de contratos públicos, lo que debería ser comprensible para todos, incluidos otros lenguajes de programación.


Tenemos que hacer un mapeo en ambos sentidos: de modelos públicos a dominio y viceversa. Si bien el mapeo del dominio a los modelos es bastante sencillo, la otra dirección es un poco complicada: los modelos pueden tener datos no válidos, después de todo, usamos tipos simples que se pueden serializar a json. No se preocupe, tendremos que construir nuestra validación en esa asignación. El hecho mismo de que usemos diferentes tipos de datos y datos posiblemente no válidos, eso siempre es válido, significa que ese compilador no nos permitirá olvidar ejecutar la validación.


Así es como se ve:


 (*{- 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 } } 

El módulo completo para mapeos y validaciones está aquí y el módulo para mapeo a modelos está aquí .


En este punto, tenemos implementación para toda la lógica de negocios, mapeos, validación, etc., y hasta ahora todo esto está completamente aislado del mundo real: está escrito en funciones puras por completo. Ahora quizás te estés preguntando, ¿cómo exactamente vamos a hacer uso de esto? Porque tenemos que interactuar con el mundo exterior. Más que eso, durante la ejecución de un flujo de trabajo, tenemos que tomar algunas decisiones basadas en el resultado de esas interacciones del mundo real. Entonces, la pregunta es ¿cómo ensamblamos todo esto? En OOP usan contenedores IoC para encargarse de eso, pero aquí no podemos hacer eso, ya que ni siquiera tenemos objetos, tenemos funciones estáticas.


Vamos a utilizar el Interpreter pattern para eso! Es un poco complicado, principalmente porque no es familiar, pero haré todo lo posible para explicar este patrón. Primero, hablemos sobre la composición de funciones. Por ejemplo, tenemos una función int -> string . Esto significa que la función espera int como parámetro y devuelve una cadena. Ahora digamos que tenemos otra string -> char función string -> char . En este punto podemos encadenarlos, es decir, ejecutar el primero, tomar su salida y alimentarlo a la segunda función, e incluso hay un operador para eso: >> . Así es como funciona:


 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 

Sin embargo, no podemos usar el encadenamiento simple en algunos escenarios, por ejemplo, activar la tarjeta. Aquí hay una secuencia de acciones:


  • validar el número de tarjeta de entrada. Si es válido, entonces
  • intente obtener la tarjeta por este número. Si hay uno
  • activarlo
  • guardar resultados Si esta bien entonces
  • mapa para modelar y regresar.

Los primeros dos pasos tienen que If it's ok then... Esa es la razón por la cual el encadenamiento directo no funciona.


Podríamos simplemente inyectar como parámetros esas funciones, así:


 let activateCard getCardAsync saveCardAsync cardNumber = ... 

Pero hay ciertos problemas con eso. Primero, el número de dependencias puede crecer y la firma de la función se verá fea. En segundo lugar, estamos vinculados a efectos específicos aquí: tenemos que elegir si es una Task o Async o simplemente llamadas de sincronización simples. Tercero, es fácil estropear las cosas cuando tiene que pasar tantas funciones: por ejemplo, createUserAsync y replaceUserAsync tienen la misma firma pero diferentes efectos, por lo que cuando tiene que pasarlas cientos de veces puede cometer un error con síntomas realmente extraños. Por esas razones, vamos por intérprete.


La idea es que dividimos nuestro código de composición en 2 partes: árbol de ejecución e intérprete para ese árbol. Cada nodo en este árbol es un lugar para una función con efecto que queremos inyectar, como getUserFromDatabase . Esos nodos se definen por nombre, por ejemplo, getCard , tipo de parámetro de entrada, por ejemplo, número de CardNumber y tipo de retorno, por ejemplo, Card option . No especificamos aquí Task o Async , esa no es la parte del árbol, es parte del intérprete . Cada borde de este árbol es una serie de transformaciones puras, como la validación o la ejecución de funciones de lógica de negocios. Los bordes también tienen alguna entrada, por ejemplo, número de tarjeta de cadena sin procesar, luego hay validación, que puede darnos un error o un número de tarjeta válido. Si hay un error, vamos a interrumpir esa ventaja, si no, nos lleva al siguiente nodo: getCard . Si este nodo devuelve Some card , podemos continuar al siguiente borde, que sería la activación, y así sucesivamente.


Para cada escenario como topUp o topUp o topUp , construiremos un árbol separado. Cuando se construyen esos árboles, sus nodos están en blanco, no tienen funciones reales, tienen un lugar para esas funciones. El objetivo del intérprete es llenar esos nodos, así de simple. El intérprete conoce los efectos que usamos, por ejemplo, Task , y sabe qué función real poner en un nodo dado. Cuando visita un nodo, ejecuta la función real correspondiente, lo espera en caso de Task o Async , y pasa el resultado al siguiente borde. Ese borde puede conducir a otro nodo, y luego es un trabajo para el intérprete nuevamente, hasta que este intérprete llegue al nodo de detención, la parte inferior de nuestra recursión, donde simplemente devolvemos el resultado de la ejecución completa de nuestro árbol.


Todo el árbol se representaría con una unión discriminada, y un nodo se vería así:


  type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) //-- <- THE NODE | ... //-- ANOTHER NODE 

Siempre será una tupla, donde el primer elemento es una entrada para su dependencia, y el último elemento es una función , que recibe el resultado de esa dependencia. Ese "espacio" entre esos elementos de tupla es donde encajará su dependencia, como en esos ejemplos de composición, donde tiene la función 'a -> 'b , 'c -> 'd y necesita poner otra 'b -> 'c en el medio para conectarlos.


Dado que estamos dentro de nuestro contexto acotado, no deberíamos tener demasiadas dependencias, y si lo hacemos, probablemente sea un momento para dividir nuestro contexto en otros más pequeños.


Esto es lo que parece, la fuente completa está aquí :


  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 .


Conclusión


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/458730/


All Articles