O que é isso?
Depois de trabalhar em diferentes projetos, notei que cada um deles apresentava alguns problemas comuns, independentemente do domínio, arquitetura, convenção de código e assim por diante. Esses problemas não eram desafiadores, apenas uma rotina tediosa: garantir que você não perdesse nada estúpido e óbvio. Em vez de fazer essa rotina diariamente, fiquei obcecado em procurar uma solução: alguma abordagem de desenvolvimento ou convenção de código ou qualquer outra coisa que me ajude a criar um projeto de forma a impedir que esses problemas aconteçam, para que eu possa me concentrar em coisas interessantes . Esse é o objetivo deste artigo: descrever esses problemas e mostrar a você a mistura de ferramentas e abordagens que encontrei para resolvê-los.
Problemas que enfrentamos
Ao desenvolver software, enfrentamos muitas dificuldades ao longo do caminho: requisitos pouco claros, falta de comunicação, processo de desenvolvimento ruim e assim por diante.
Também enfrentamos algumas dificuldades técnicas: o código legado nos atrasa, a escalabilidade é complicada, algumas decisões ruins do passado nos chocam hoje.
Todos eles podem ser, se não eliminados, e reduzidos significativamente, mas há um problema fundamental sobre o qual você não pode fazer nada: a complexidade do seu sistema.
A idéia de um sistema que você está desenvolvendo é sempre complexa, entenda ou não.
Mesmo quando você está fazendo outro aplicativo CRUD , sempre existem alguns casos extremos, algumas coisas complicadas e, de tempos em tempos, alguém pergunta: "Ei, o que vai acontecer se eu fizer isso e isso nessas circunstâncias?" e você diz "Hum, essa é uma pergunta muito boa".
Esses casos complicados, lógica obscura, validação e gerenciamento de acesso - tudo isso contribui para a sua grande ideia.
Muitas vezes, essa ideia é tão grande que não cabe em uma só cabeça, e esse fato por si só traz problemas como falta de comunicação.
Mas sejamos generosos e suponhamos que essa equipe de especialistas em domínio e analistas de negócios se comunique claramente e produza requisitos consistentes.
Agora temos que implementá-los, para expressar essa ideia complexa em nosso código. Agora esse código é outro sistema, muito mais complicado do que a ideia original que tínhamos em mente.
Como assim? Enfrenta a realidade: as limitações técnicas obrigam você a lidar com alta carga, consistência e disponibilidade de dados, além de implementar a lógica de negócios real.
Como você pode ver, a tarefa é bastante desafiadora e agora precisamos de ferramentas adequadas para lidar com ela.
Uma linguagem de programação é apenas mais uma ferramenta e, como todas as outras ferramentas, não se trata apenas da qualidade dela, provavelmente é ainda mais sobre a ferramenta adequada ao trabalho. Você pode ter a melhor chave de fenda que existe, mas se você precisar colocar alguns pregos na madeira, um martelo de baixa qualidade seria melhor, certo?
Aspectos técnicos
Hoje, a maioria das línguas populares é orientada a objetos. Quando alguém faz uma introdução ao POO, geralmente usa exemplos:
Considere um carro, que é um objeto do mundo real. Possui várias propriedades como marca, peso, cor, velocidade máxima, velocidade atual e assim por diante.
Para refletir esse objeto em nosso programa, reunimos essas propriedades em uma classe. As propriedades podem ser permanentes ou mutáveis, que juntas formam o estado atual desse objeto e alguns limites nos quais ele pode variar. No entanto, combinar essas propriedades não é suficiente, pois temos que verificar se o estado atual faz sentido, por exemplo, a velocidade atual não excede a velocidade máxima. Para garantir que anexemos alguma lógica a essa classe, marque as propriedades como privadas para impedir que alguém crie estado ilegal.
Como você pode ver, os objetos são sobre seu estado interno e ciclo de vida.
Portanto, esses três pilares da OOP fazem todo sentido neste contexto: usamos herança para reutilizar certas manipulações de estado, encapsulamento para proteção de estado e polimorfismo para tratar objetos semelhantes da mesma maneira. A mutabilidade como padrão também faz sentido, pois nesse contexto o objeto imutável não pode ter um ciclo de vida e sempre possui um estado, o que não é o caso mais comum.
O problema é que, quando você olha para um aplicativo típico da Web atualmente, ele não lida com objetos. Quase tudo em nosso código tem uma vida eterna ou nenhuma vida adequada. Dois tipos mais comuns de "objetos" são algum tipo de serviço, como UserService
, EmployeeRepository
ou alguns modelos / entidades / DTOs ou o que você chamar. Os serviços não têm um estado lógico dentro deles, eles morrem e nascem de novo exatamente da mesma forma, apenas recriamos o gráfico de dependência com uma nova conexão com o banco de dados.
Entidades e modelos não têm nenhum comportamento associado a eles, são apenas pacotes de dados, sua mutabilidade não ajuda, mas muito pelo contrário.
Portanto, os principais recursos do OOP não são realmente úteis para o desenvolvimento desse tipo de aplicativo.
O que acontece em um aplicativo Web típico é o fluxo de dados: validação, transformação, avaliação e assim por diante. E há um paradigma que se encaixa perfeitamente para esse tipo de trabalho: programação funcional. E há uma prova disso: todos os recursos modernos das linguagens populares de hoje vêm de lá: async/await
, lambdas e delegados, programação reativa, uniões discriminadas (enums em rápido ou enferrujado, para não serem confundidas com enums em java ou .net ), tuplas - tudo o que é do FP.
No entanto, essas são apenas migalhas, é muito bom tê-las, mas há mais, muito mais.
Antes de me aprofundar, há um ponto a ser feito. Mudar para um novo idioma, especialmente um novo paradigma, é um investimento para desenvolvedores e, portanto, para os negócios. Fazer investimentos tolos não lhe dará nada além de problemas, mas investimentos razoáveis podem ser exatamente o que o manterá à tona.
Muitos de nós preferimos idiomas com digitação estática. A razão disso é simples: o compilador cuida de verificações tediosas, como passar parâmetros apropriados para funções, construir nossas entidades corretamente e assim por diante. Essas verificações são gratuitas. Agora, quanto às coisas que o compilador não pode verificar, temos uma escolha: esperar o melhor ou fazer alguns testes. Escrever testes significa dinheiro, e você não paga apenas uma vez por teste; você deve mantê-los. Além disso, as pessoas ficam desleixadas e, de vez em quando, obtemos resultados falso-positivos e falso-negativos. Quanto mais testes você tiver que escrever, menor será a qualidade média desses testes. Há outro problema: para testar algo, você precisa saber e lembrar que esse item deve ser testado, mas quanto maior o seu sistema, mais fácil é perder algo.
No entanto, o compilador é tão bom quanto o sistema de tipos da linguagem. Se não permitir que você expresse algo de maneira estática, é necessário fazer isso em tempo de execução. O que significa testes, sim. Porém, não se trata apenas de sistema de tipos, os recursos de sintaxe e pequenos açúcares também são muito importantes, porque no final do dia queremos escrever o mínimo de código possível, por isso, se alguma abordagem exigir que você escreva dez vezes mais linhas, ninguém vai usá-lo. É por isso que é importante que o idioma escolhido tenha o conjunto adequado de recursos e truques - bem, foco correto em geral. Caso contrário, em vez de usar seus recursos para enfrentar desafios originais, como a complexidade do seu sistema e requisitos variáveis, você também estará lutando contra o idioma. E tudo se resume a dinheiro, já que você paga aos desenvolvedores pelo tempo gasto. Quanto mais problemas eles tiverem que resolver, mais tempo eles precisarão e mais desenvolvedores serão necessários.
Finalmente, estamos prestes a ver algum código para provar tudo isso. Sou um desenvolvedor .NET, então os exemplos de código serão em C # e F #, mas a imagem geral pareceria mais ou menos a mesma em outras linguagens populares de OOP e FP.
Que comece a codificação
Vamos criar um aplicativo da web para gerenciar cartões de crédito.
Requisitos básicos:
- Criar / ler usuários
- Criar / ler cartões de crédito
- Ativar / desativar cartões de crédito
- Definir limite diário para cartões
- Saldo de recarga
- Processar pagamentos (considerando saldo, data de validade do cartão, estado ativo / desativado e limite diário)
Por uma questão de simplicidade, usaremos um cartão por conta e pularemos a autorização. Mas, de resto, vamos criar aplicativos capazes de validação, manipulação de erros, banco de dados e API da web. Então, vamos à nossa primeira tarefa: criar cartões de crédito.
Primeiro, vamos ver como seria em 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;} }
Mas isso não basta, precisamos adicionar a validação e, geralmente, isso está sendo feito em algum Validator
, como o do FluentValidation
.
As regras são simples:
- O número do cartão é obrigatório e deve ser uma sequência de 16 dígitos.
- O nome é obrigatório e deve conter apenas letras e pode conter espaços no meio.
- Mês e ano precisam satisfazer os limites.
- As informações da conta devem estar presentes quando o cartão está ativo e ausentes quando o cartão está desativado. Se você está se perguntando por que, é simples: quando o cartão é desativado, não deve ser possível alterar o saldo ou o limite diário.
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"); } }
Agora, existem vários problemas com essa abordagem:
- A validação é separada da declaração de tipo, o que significa que, para ver a imagem completa do cartão , precisamos navegar pelo código e recriar essa imagem em nossa cabeça. Não é um grande problema quando isso acontece apenas uma vez, mas quando precisamos fazer isso para todas as entidades de um grande projeto, bem, isso consome muito tempo.
- Essa validação não é forçada, temos que ter em mente para usá-la em qualquer lugar. Podemos garantir isso com testes, mas, novamente, você deve se lembrar disso ao escrever testes.
- Quando queremos validar o número do cartão em outros lugares, precisamos fazer a mesma coisa novamente. Claro, podemos manter o regex em um local comum, mas ainda assim precisamos chamá-lo em todos os validadores.
No F #, podemos fazer isso de uma maneira diferente:
(**) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create str = match str with | (null|"") -> Error "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else Error "Card number must be a 16 digits string" (**) type CardAccountInfo = | Active of AccountInfo | Deactivated (**) type Card = { CardNumber: CardNumber Name: LetterString //
É claro que algumas coisas daqui podemos fazer em C #. Podemos criar a classe CardNumber
que também lançará ValidationException
. Mas esse truque com CardAccountInfo
não pode ser feito em C # de maneira fácil.
Outra coisa - o C # depende muito de exceções. Existem vários problemas com isso:
- As exceções têm semântica "ir para". Um momento em que você está aqui neste método, outro - você acabou em algum manipulador global.
- Eles não aparecem na assinatura do método. Exceções como
ValidationException
ou InvalidUserOperationException
fazem parte do contrato, mas você não sabe disso até ler a implementação . E esse é um grande problema, porque muitas vezes você precisa usar o código escrito por outra pessoa e, em vez de ler apenas a assinatura, precisa navegar até o final da pilha de chamadas, o que leva muito tempo.
E é isso que me incomoda: sempre que eu implemento algum novo recurso, o processo de implementação em si não leva muito tempo, a maioria vai para duas coisas:
- Lendo o código de outras pessoas e descobrindo regras de lógica de negócios.
- Certificando-se de que nada está quebrado.
Pode parecer um sintoma de um mau design de código, mas o mesmo acontece em projetos escritos decentemente.
Ok, mas podemos tentar usar a mesma coisa Result
em C #. A implementação mais óbvia seria assim:
public class Result<TOk, TError> { public TOk Ok {get;set;} public TError Error {get;set;} }
e é um lixo puro, não nos impede de definir Ok
e Error
e permite que o erro seja completamente ignorado. A versão adequada seria algo como isto:
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)); } } }
Muito pesado, certo? E nem implementei as versões void
do Map
e MapError
. O uso ficaria assim:
void Test(Result<int, string> result) { var squareResult = result.Map(x => x * x); }
Não é tão ruim assim? Bem, agora imagine que você tem três resultados e deseja fazer algo com eles quando todos estiverem Ok
. Desagradável. Então isso dificilmente é uma opção.
Versão F #:
//
Basicamente, você deve escolher se escreve uma quantidade razoável de código, mas o código é obscuro, depende de exceções, reflexões, expressões e outras "mágicas" ou escreve muito mais código, difícil de ler, mas é mais durável e direto. Quando esse projeto cresce, você simplesmente não pode combatê-lo, não em idiomas com sistemas do tipo C #. Vamos considerar um cenário simples: você tem alguma entidade em sua base de código por um tempo. Hoje você deseja adicionar um novo campo obrigatório. Naturalmente, você precisa inicializar esse campo em qualquer lugar em que essa entidade for criada, mas o compilador não ajuda em nada, pois a classe é mutável e null
é um valor válido. E bibliotecas como o AutoMapper
tornam ainda mais difícil. Essa mutabilidade nos permite inicializar parcialmente os objetos em um lugar, depois empurrá-lo para outro lugar e continuar a inicialização lá. Essa é outra fonte de erros.
Enquanto isso, a comparação de recursos de idiomas é boa, mas não é sobre o que este artigo trata. Se você estiver interessado, eu cobri esse tópico no meu artigo anterior . Mas os próprios recursos da linguagem não devem ser um motivo para mudar a tecnologia.
Então, isso nos leva a estas perguntas:
- Por que realmente precisamos mudar do OOP moderno?
- Por que devemos mudar para o FP?
A resposta à primeira pergunta é que o uso de linguagens comuns de POO para aplicativos modernos oferece muitos problemas, porque eles foram projetados para finalidades diferentes. Isso resulta em tempo e dinheiro gasto para combater o design deles, além da complexidade do aplicativo.
E a segunda resposta é que as linguagens FP fornecem uma maneira fácil de projetar seus recursos para que funcionem como um relógio, e se um novo recurso quebra a lógica existente, ele quebra o código, portanto você sabe disso imediatamente.
No entanto, essas respostas não são suficientes. Como meu amigo apontou durante uma de nossas discussões, mudar para FP seria inútil quando você não conhece as melhores práticas. Nossa grande indústria produziu toneladas de artigos, livros e tutoriais sobre o design de aplicativos OOP, e temos experiência em produção com OOP, portanto sabemos o que esperar de diferentes abordagens. Infelizmente, não é o caso da programação funcional, portanto, mesmo se você mudar para o FP, suas primeiras tentativas provavelmente serão estranhas e certamente não trarão o resultado desejado: desenvolvimento rápido e indolor de sistemas complexos.
Bem, é exatamente disso que trata este artigo. Como eu disse, vamos criar aplicativos de produção para ver a diferença.
Como projetamos aplicativos?
Muitas dessas idéias que usei no processo de design foram emprestadas do grande livro Domain Modeling Made Functional , por isso recomendo fortemente que você a leia.
O código fonte completo com comentários está aqui . Naturalmente, eu não vou colocar tudo isso aqui, então vou abordar os principais pontos.
Teremos quatro projetos principais: camada de negócios, camada de acesso a dados, infraestrutura e, é claro, comuns. Toda solução tem, certo?
Começamos com a modelagem do nosso domínio. Neste ponto, não sabemos e não ligamos para o banco de dados. É feito de propósito, porque, tendo em mente um banco de dados específico, tendemos a projetar nosso domínio de acordo com ele, trazemos essa relação de entidade-tabela na camada de negócios, o que mais tarde traz problemas. Você só precisa implementar o domain -> DAL
mapeamento domain -> DAL
uma vez, enquanto o design errado nos incomoda constantemente até o ponto em que o corrigimos. Então, eis o que fazemos: criamos um projeto chamado CardManagement
(muito criativo, eu sei) e imediatamente <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
a configuração <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
no arquivo do projeto. Por que precisamos disso? Bem, usaremos fortemente uniões discriminadas e, quando você faz a correspondência de padrões, o compilador nos dá um aviso, se não cobrirmos todos os casos possíveis:
let fail result = match result with | Ok v -> printfn "%A" v //
Com essa configuração ativada, esse código não será compilado, exatamente o que precisamos, quando estendemos a funcionalidade existente e queremos que ela seja ajustada em qualquer lugar. A próxima coisa que fazemos é criar o módulo (compila em uma classe estática) CardDomain
. Neste arquivo, descrevemos tipos de domínio e nada mais. Lembre-se de que no F #, a ordem dos códigos e arquivos é importante: por padrão, você pode usar apenas o que declarou anteriormente.
Tipos de domínio
Começamos a definir nossos tipos com o CardNumber
que mostrei antes, embora precisemos de um Error
mais prático do que apenas uma string, portanto usaremos ValidationError
.
type ValidationError = { FieldPath: string Message: string } let validationError field message = { FieldPath = field; Message = message } (**) let private cardNumberRegex = new Regex("^[0-9]{16}$", RegexOptions.Compiled) type CardNumber = private CardNumber of string with member this.Value = match this with CardNumber s -> s static member create fieldName str = match str with | (null|"") -> validationError fieldName "card number can't be empty" | str -> if cardNumberRegex.IsMatch(str) then CardNumber str |> Ok else validationError fieldName "Card number must be a 16 digits string"
Então, é claro, definimos Card
que é o coração do nosso domínio. Sabemos que o cartão possui alguns atributos permanentes, como número, data de validade e nome no cartão, além de algumas informações alteráveis, como saldo e limite diário. Portanto, encapsulamos essas informações alteráveis em outro tipo:
type AccountInfo = { HolderId: UserId Balance: Money DailyLimit: DailyLimit } type Card = { CardNumber: CardNumber Name: LetterString HolderId: UserId Expiration: (Month * Year) AccountDetails: CardAccountInfo }
Agora, existem vários tipos aqui, que ainda não declaramos:
Dinheiro
Poderíamos usar decimal
(e usaremos, mas não diretamente), mas decimal
é menos descritivo. Além disso, pode ser usado para representar outras coisas além de dinheiro, e não queremos que isso seja confundido. Portanto, usamos o tipo de type [<Struct>] Money = Money of decimal
personalizado type [<Struct>] Money = Money of decimal
.
Dailylimit
O limite diário pode ser definido para uma quantidade específica ou estar ausente. Se estiver presente, deve ser positivo. Em vez de usar decimal
ou Money
, definimos este tipo:
[<Struct>] type DailyLimit = private //
É mais descritivo do que apenas sugerir que 0M
significa que não há limite, pois também pode significar que você não pode gastar dinheiro com este cartão. O único problema é que, como ocultamos o construtor, não podemos fazer a correspondência de padrões. Mas não se preocupe, podemos usar Padrões Ativos :
let (|Limit|Unlimited|) limit = match limit with | Limit dec -> Limit dec | Unlimited -> Unlimited
Agora podemos padronizar a correspondência do DailyLimit
todos os lugares como um DU regular.
Letterstring
Essa é simples. Usamos a mesma técnica que no CardNumber
. Porém, uma coisinha: LetterString
dificilmente se trata de cartões de crédito, é uma coisa bastante, e devemos movê-lo no projeto Common
no módulo CommonTypes
. Chegou a hora de movermos o ValidationError
para outro local também.
ID do usuário
Esse é apenas um type UserId = System.Guid
alias type UserId = System.Guid
. Nós o usamos apenas para descrição.
Mês e Ano
Aqueles também têm que ir ao Common
. Month
será uma união discriminada com métodos para convertê-lo de e para unsigned int16
; Year
será como CardNumber
mas para uint16
vez de string.
Agora vamos terminar nossa declaração de tipos de domínio. Precisamos de User
com algumas informações do usuário e coleta de cartões, precisamos de operações de saldo para recargas e pagamentos.
type UserInfo = { Name: LetterString Id: UserId Address: Address } type User = { UserInfo : UserInfo Cards: Card list } [<Struct>] type BalanceChange = //
Bom, projetamos nossos tipos de forma que o estado inválido não seja representável. Agora, sempre que lidamos com instâncias de qualquer um desses tipos, temos certeza de que os dados são válidos e não precisamos validá-los novamente. Agora podemos avançar para a lógica de negócios!
Lógica de negócios
Teremos uma regra inquebrável aqui: toda a lógica de negócios será codificada em funções puras . Uma função pura é uma função que satisfaz os seguintes critérios:
- A única coisa que faz é calcular o valor de saída. Não tem efeitos colaterais.
- Sempre produz a mesma saída para a mesma entrada.
Portanto, funções puras não lançam exceções, não produzem valores aleatórios, não interagem com o mundo exterior de qualquer forma, seja banco de dados ou um simples DateTime.Now
. É claro que interagir com a função impura automaticamente torna a função de chamada impura. Então, o que devemos implementar?
Aqui está uma lista de requisitos que temos:
Ativar / desativar cartão
Processar pagamentos
Podemos processar o pagamento se:
- O cartão não expirou
- O cartão está ativo
- Há dinheiro suficiente para o pagamento
- Os gastos de hoje não excederam o limite diário.
Saldo de recarga
Podemos completar o saldo do cartão ativo e não expirado.
Definir limite diário
O usuário pode definir o limite diário se o cartão não tiver expirado e estiver ativo.
Quando a operação não pode ser concluída, temos que retornar um erro; portanto, precisamos definir OperationNotAllowedError
:
type OperationNotAllowedError = { Operation: string Reason: string } //
Neste módulo com lógica de negócios, esse seria o único tipo de erro que retornamos. Não fazemos validação aqui, não interagimos com o banco de dados - basta executar operações se pudermos retornar o OperationNotAllowedError
.
O módulo completo pode ser encontrado aqui . Vou listar aqui o caso mais complicado aqui: processPayment
. Temos que verificar a validade, status ativo / desativado, dinheiro gasto hoje e saldo atual. Como não podemos interagir com o mundo exterior, temos que passar todas as informações necessárias como parâmetros. Dessa forma, essa lógica seria muito fácil de testar e permitirá que você faça testes baseados em propriedades .
let processPayment (currentDate: DateTimeOffset) (spentToday: Money) card (paymentAmount: MoneyTransaction) = //
spentToday
- teremos que calculá-lo a partir da coleção BalanceOperation
que manteremos no banco de dados. Então, precisaremos de um módulo para isso, que basicamente terá 1 função 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
Bom Agora que terminamos toda a implementação da lógica de negócios, chegou a hora de pensar no mapeamento. Muitos de nossos tipos usam uniões discriminadas, alguns não têm construtor público, portanto não podemos expô-los como estão para o mundo exterior. Precisamos lidar com a (des) serialização. Além disso, no momento, temos apenas um contexto limitado em nosso aplicativo, mas, mais tarde, na vida real, você deseja construir um sistema maior com múltiplos contextos limitados, e eles precisam interagir entre si por meio de contratos públicos, o que deve ser compreensível. para todos, incluindo outras linguagens de programação.
Temos que fazer o mapeamento bidirecional: dos modelos públicos ao domínio e vice-versa. Embora o mapeamento do domínio para os modelos seja bastante direto, a outra direção tem um pouco de dificuldade: os modelos podem ter dados inválidos, afinal usamos tipos simples que podem ser serializados para json. Não se preocupe, teremos que construir nossa validação nesse mapeamento. O fato de usarmos tipos diferentes para dados e dados possivelmente inválidos, sempre válidos, significa que o compilador não nos deixa esquecer de executar a validação.
Aqui está o que parece:
(**) type ValidateCreateCardCommand = CreateCardCommandModel -> ValidationResult<Card> let validateCreateCardCommand : ValidateCreateCardCommand = fun cmd -> (**) result { let! name = LetterString.create "name" cmd.Name let! number = CardNumber.create "cardNumber" cmd.CardNumber let! month = Month.create "expirationMonth" cmd.ExpirationMonth let! year = Year.create "expirationYear" cmd.ExpirationYear return { Card.CardNumber = number Name = name HolderId = cmd.UserId Expiration = month,year AccountDetails = AccountInfo.Default cmd.UserId |> Active } }
O módulo completo para mapeamentos e validações está aqui e o módulo para mapeamento para modelos está aqui .
Neste ponto, temos implementação para toda a lógica de negócios, mapeamentos, validação e assim por diante, e até agora tudo isso está completamente isolado do mundo real: está escrito inteiramente em funções puras. Agora você deve estar se perguntando, como exatamente vamos fazer uso disso? Porque nós temos que interagir com o mundo exterior. Mais do que isso, durante a execução do fluxo de trabalho, precisamos tomar algumas decisões com base no resultado dessas interações no mundo real. Então a questão é como montamos tudo isso? No OOP, eles usam contêineres IoC para cuidar disso, mas aqui não podemos fazer isso, já que nem temos objetos, temos funções estáticas.
Nós vamos usar o Interpreter pattern
para isso! É um pouco complicado, principalmente porque não é familiar, mas farei o possível para explicar esse padrão. Primeiro, vamos falar sobre a composição da função. Por exemplo, temos uma função int -> string
. Isso significa que a função espera int
como parâmetro e retorna string. Agora vamos dizer que temos outra função string -> char
. Nesse ponto, podemos encadeá-los, ou seja, executar o primeiro, pegar sua saída e alimentá-la para a segunda função, e há até um operador para isso: >>
. Veja como funciona:
let intToString (i: int) = i.ToString() let firstCharOrSpace (s: string) = match s with | (null| "") -> ' ' | s -> s.[0] let firstDigitAsChar = intToString >> firstCharOrSpace //
No entanto, não podemos usar encadeamento simples em alguns cenários, por exemplo, ativar o cartão. Aqui está uma sequência de ações:
- valide o número do cartão de entrada. Se é válido, então
- tente obter um cartão com esse número. Se houver um
- ativá-lo.
- salvar resultados. Se estiver tudo bem, então
- mapear para modelar e retornar.
Os dois primeiros passos têm que, se If it's ok then...
Essa é a razão pela qual o encadeamento direto não está funcionando.
Poderíamos simplesmente injetar como parâmetros essas funções, assim:
let activateCard getCardAsync saveCardAsync cardNumber = ...
Mas há certos problemas com isso. Primeiro, o número de dependências pode aumentar e a assinatura da função ficará feia. Segundo, estamos vinculados a efeitos específicos aqui: temos que escolher se é uma Task
ou Async
ou apenas chamadas de sincronização simples. Terceiro, é fácil atrapalhar as coisas quando você tem tantas funções para passar: por exemplo, createUserAsync
e replaceUserAsync
têm a mesma assinatura, mas com efeitos diferentes; portanto, quando você precisa passá-las centenas de vezes, pode cometer um erro com sintomas realmente estranhos. Por esses motivos, procuramos intérpretes.
A idéia é que dividimos nosso código de composição em 2 partes: árvore de execução e intérprete para essa árvore. Cada nó nesta árvore é um local para uma função com efeito que queremos injetar, como getUserFromDatabase
. Esses nós são definidos pelo nome, por exemplo, getCard
, tipo de parâmetro de entrada, por exemplo, CardNumber
e tipo de retorno, por exemplo, Card option
. Não especificamos aqui Task
ou Async
, isso não faz parte da árvore, faz parte do intérprete . Cada borda desta árvore é uma série de transformações puras, como validação ou execução de função da lógica de negócios. As bordas também têm alguma entrada, por exemplo, número do cartão de sequência não processada, e há validação, que pode nos dar um erro ou um número de cartão válido. Se houver um erro, interromperemos essa borda; caso contrário, ela nos levará ao próximo nó: getCard
. Se esse nó retornar Some card
, podemos continuar até a próxima borda, que seria a ativação e assim por diante.
Para todos os cenários como activateCard
topUp
ou o topUp
, construiremos uma árvore separada. Quando essas árvores são construídas, seus nós ficam meio vazios, eles não têm funções reais, eles têm um lugar para essas funções. O objetivo do intérprete é preencher esses nós, simples assim. O intérprete conhece os efeitos que usamos, por exemplo, Task
, e sabe qual função real colocar em um determinado nó. Quando visita um nó, ele executa a função real correspondente, aguarda-o no caso de Task
ou Async
e passa o resultado para a próxima borda. Essa borda pode levar a outro nó e, em seguida, é um trabalho para intérprete novamente, até que esse intérprete atinja o nó de parada, a parte inferior de nossa recursão, onde apenas retornamos o resultado de toda a execução de nossa árvore.
A árvore inteira seria representada com união discriminada e um nó ficaria assim:
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) //
Sempre será uma tupla, onde o primeiro elemento é uma entrada para sua dependência e o último elemento é uma função , que recebe o resultado dessa dependência. Esse "espaço" entre esses elementos da tupla é onde sua dependência se encaixa, como nos exemplos de composição, onde você tem a função 'a -> 'b
, 'c -> 'd
e precisa colocar outro 'b -> 'c
entre eles para conectá-los.
Como estamos dentro do nosso contexto limitado, não devemos ter muitas dependências e, se tivermos - provavelmente é hora de dividir nosso contexto em outras menores.
Aqui está o que parece, a fonte completa está aqui :
type Program<'a> = | GetCard of CardNumber * (Card option -> Program<'a>) | GetCardWithAccountInfo of CardNumber * ((Card*AccountInfo) option -> Program<'a>) | CreateCard of (Card*AccountInfo) * (Result<unit, DataRelatedError> -> Program<'a>) | ReplaceCard of Card * (Result<unit, DataRelatedError> -> Program<'a>) | GetUser of UserId * (User option -> Program<'a>) | CreateUser of UserInfo * (Result<unit, DataRelatedError> -> Program<'a>) | GetBalanceOperations of (CardNumber * DateTimeOffset * DateTimeOffset) * (BalanceOperation list -> Program<'a>) | SaveBalanceOperation of BalanceOperation * (Result<unit, DataRelatedError> -> Program<'a>) | Stop of 'a (**) let rec bind f instruction = match instruction with | GetCard (x, next) -> GetCard (x, (next >> bind f)) | GetCardWithAccountInfo (x, next) -> GetCardWithAccountInfo (x, (next >> bind f)) | CreateCard (x, next) -> CreateCard (x, (next >> bind f)) | ReplaceCard (x, next) -> ReplaceCard (x, (next >> bind f)) | GetUser (x, next) -> GetUser (x,(next >> bind f)) | CreateUser (x, next) -> CreateUser (x,(next >> bind f)) | GetBalanceOperations (x, next) -> GetBalanceOperations (x,(next >> bind f)) | SaveBalanceOperation (x, next) -> SaveBalanceOperation (x,(next >> bind f)) | Stop x -> fx (**) let stop x = Stop x let getCardByNumber number = GetCard (number, stop) let getCardWithAccountInfo number = GetCardWithAccountInfo (number, stop) let createNewCard (card, acc) = CreateCard ((card, acc), stop) let replaceCard card = ReplaceCard (card, stop) let getUserById id = GetUser (id, stop) let createNewUser user = CreateUser (user, stop) let getBalanceOperations (number, fromDate, toDate) = GetBalanceOperations ((number, fromDate, toDate), stop) let saveBalanceOperation op = SaveBalanceOperation (op, stop)
With a help of computation expressions , we now have a very easy way to build our workflows without having to care about implementation of real-world interactions. We do that in CardWorkflow module :
(**) let processPayment (currentDate: DateTimeOffset, payment) = program { (**) let! cmd = validateProcessPaymentCommand payment |> expectValidationError let! card = tryGetCard cmd.CardNumber let today = currentDate.Date |> DateTimeOffset let tomorrow = currentDate.Date.AddDays 1. |> DateTimeOffset let! operations = getBalanceOperations (cmd.CardNumber, today, tomorrow) let spentToday = BalanceOperation.spentAtDate currentDate cmd.CardNumber operations let! (card, op) = CardActions.processPayment currentDate spentToday card cmd.PaymentAmount |> expectOperationNotAllowedError do! saveBalanceOperation op |> expectDataRelatedErrorProgram do! replaceCard card |> expectDataRelatedErrorProgram return card |> toCardInfoModel |> Ok }
This module is the last thing we need to implement in business layer. Also, I've done some refactoring: I moved errors and common types to Common project . About time we moved on to implementing data access layer.
Data access layer
The design of entities in this layer may depend on our database or framework we use to interact with it. Therefore domain layer doesn't know anything about these entities, which means we have to take care of mapping to and from domain models in here. Which is quite convenient for consumers of our DAL API. For this application I've chosen MongoDB, not because it's a best choice for this kind of task, but because there're many examples of using SQL DBs already and I wanted to add something different. We are gonna use C# driver.
For the most part it's gonna be pretty strait forward, the only tricky moment is with Card
. When it's active it has an AccountInfo
inside, when it's not it doesn't. So we have to split it in two documents: CardEntity
and CardAccountInfoEntity
, so that deactivating card doesn't erase information about balance and daily limit.
Other than that we just gonna use primitive types instead of discriminated unions and types with built-in validation.
There're also few things we need to take care of, since we are using C# library:
- Convert
null
s to Option<'a>
- Catch expected exceptions and convert them to our errors and wrap it in
Result<_,_>
We start with CardDomainEntities module , where we define our entities:
[<CLIMutable>] type CardEntity = { [<BsonId>] CardNumber: string Name: string IsActive: bool ExpirationMonth: uint16 ExpirationYear: uint16 UserId: UserId } with //
Those fields EntityId
and IdComparer
we are gonna use with a help of SRTP . We'll define functions that will retrieve them from any type that has those fields define, without forcing every entity to implement some interface:
let inline (|HasEntityId|) x = fun () -> (^a : (member EntityId: string) x) let inline entityId (HasEntityId f) = f() let inline (|HasIdComparer|) x = fun () -> (^a : (member IdComparer: Quotations.Expr<Func< ^a, bool>>) x) //
As for null
and Option
thing, since we use record types, F# compiler doesn't allow using null
value, neither for assigning nor for comparison. At the same time record types are just another CLR types, so technically we can and will get a null
value, thanks to C# and design of this library. We can solve this in 2 ways: use AllowNullLiteral
attribute, or use Unchecked.defaultof<'a>
. I went for the second choice since this null
situation should be localized as much as possible:
let isNullUnsafe (arg: 'a when 'a: not struct) = arg = Unchecked.defaultof<'a> //
In order to deal with expected exception for duplicate key, we use Active Patterns again:
//
After mapping is implemented we have everything we need to assemble API for our data access layer , which looks like this:
//
The last moment I mention is when we do mapping Entity -> Domain
, we have to instantiate types with built-in validation, so there can be validation errors. In this case we won't use Result<_,_>
because if we've got invalid data in DB, it's a bug, not something we expect. So we just throw an exception. Other than that nothing really interesting is happening in here. The full source code of data access layer you'll find here .
Composition, logging and all the rest
As you remember, we're not gonna use DI framework, we went for interpreter pattern. If you want to know why, here's some reasons:
- IoC container operates in runtime. So until you run your program you can't know that all the dependencies are satisfied.
- It's a powerful tool which is very easy to abuse: you can do property injection, use lazy dependencies, and sometimes even some business logic can find it's way in dependency registering/resolving (yeah, I've witnessed it). All of that makes code maintaining extremely hard.
That means we need a place for that functionality. We could place it on a top level in our Web Api, but in my opinion it's not a best choice: right now we are dealing with only 1 bounded context, but if there's more, this global place with all the interpreters for each context will become cumbersome. Besides, there's single responsibility rule, and web api project should be responsible for web, right? So we create CardManagement.Infrastructure project .
Here we will do several things:
- Composing our functionality
- App configuration
- Logging
If we had more than 1 context, app configuration and log configuration should be moved to global infrastructure project, and the only thing happening in this project would be assembling API for our bounded context, but in our case this separation is not necessary.
Let's get down to composition. We've built execution trees in our domain layer, now we have to interpret them. Every node in that tree represents some dependency call, in our case a call to database. If we had a need to interact with 3rd party api, that would be in here also. So our interpreter has to know how to handle every node in that tree, which is verified in compile time, thanks to <TreatWarningsAsErrors>
setting. Here's what it looks like:
(**) let rec private interpretCardProgram mongoDb prog = match prog with | GetCard (cardNumber, next) -> cardNumber |> getCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetCardWithAccountInfo (number, next) -> number |> getCardWithAccInfoAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | CreateCard ((card,acc), next) -> (card, acc) |> createCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | ReplaceCard (card, next) -> card |> replaceCardAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetUser (id, next) -> getUserAsync mongoDb id |> bindAsync (next >> interpretCardProgram mongoDb) | CreateUser (user, next) -> user |> createUserAsync mongoDb |> bindAsync (next >> interpretCardProgram mongoDb) | GetBalanceOperations (request, next) -> getBalanceOperationsAsync mongoDb request |> bindAsync (next >> interpretCardProgram mongoDb) | SaveBalanceOperation (op, next) -> saveBalanceOperationAsync mongoDb op |> bindAsync (next >> interpretCardProgram mongoDb) | Stop a -> async.Return a let interpret prog = try let interpret = interpretCardProgram (getMongoDb()) interpret prog with | failure -> Bug failure |> Error |> async.Return
Note that this interpreter is the place where we have this async
thing. We can do another interpreter with Task
or just a plain sync version of it. Now you're probably wondering, how we can cover this with unit-test, since familiar mock libraries ain't gonna help us. Well, it's easy: you have to make another interpreter. Here's what it can look like:
type SaveResult = Result<unit, DataRelatedError> type TestInterpreterConfig = { GetCard: Card option GetCardWithAccountInfo: (Card*AccountInfo) option CreateCard: SaveResult ReplaceCard: SaveResult GetUser: User option CreateUser: SaveResult GetBalanceOperations: BalanceOperation list SaveBalanceOperation: SaveResult } let defaultConfig = { GetCard = Some card GetUser = Some user GetCardWithAccountInfo = (card, accountInfo) |> Some CreateCard = Ok() GetBalanceOperations = balanceOperations SaveBalanceOperation = Ok() ReplaceCard = Ok() CreateUser = Ok() } let testInject a = fun _ -> a let rec interpretCardProgram config (prog: Program<'a>) = match prog with | GetCard (cardNumber, next) -> cardNumber |> testInject config.GetCard |> (next >> interpretCardProgram config) | GetCardWithAccountInfo (number, next) -> number |> testInject config.GetCardWithAccountInfo |> (next >> interpretCardProgram config) | CreateCard ((card,acc), next) -> (card, acc) |> testInject config.CreateCard |> (next >> interpretCardProgram config) | ReplaceCard (card, next) -> card |> testInject config.ReplaceCard |> (next >> interpretCardProgram config) | GetUser (id, next) -> id |> testInject config.GetUser |> (next >> interpretCardProgram config) | CreateUser (user, next) -> user |> testInject config.CreateUser |> (next >> interpretCardProgram config) | GetBalanceOperations (request, next) -> testInject config.GetBalanceOperations request |> (next >> interpretCardProgram config) | SaveBalanceOperation (op, next) -> testInject config.SaveBalanceOperation op |> (next >> interpretCardProgram config) | Stop a -> a
We've created TestInterpreterConfig
which holds desired results of every operation we want to inject. You can easily change that config for every given test and then just run interpreter. This interpreter is sync, since there's no reason to bother with Task
or Async
.
There's nothing really tricky about the logging, but you can find it in this module . The approach is that we wrap the function in logging: we log function name, parameters and log result. If result is ok, it's info, if error it's a warning and if it's a Bug
then it's an error. That's pretty much it.
One last thing is to make a facade, since we don't want to expose raw interpreter calls. Here's the whole thing:
let createUser arg = arg |> (CardWorkflow.createUser >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createUser") let createCard arg = arg |> (CardWorkflow.createCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.createCard") let activateCard arg = arg |> (CardWorkflow.activateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.activateCard") let deactivateCard arg = arg |> (CardWorkflow.deactivateCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.deactivateCard") let processPayment arg = arg |> (CardWorkflow.processPayment >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.processPayment") let topUp arg = arg |> (CardWorkflow.topUp >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.topUp") let setDailyLimit arg = arg |> (CardWorkflow.setDailyLimit >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.setDailyLimit") let getCard arg = arg |> (CardWorkflow.getCard >> CardProgramInterpreter.interpret |> logifyResultAsync "CardApi.getCard") let getUser arg = arg |> (CardWorkflow.getUser >> CardProgramInterpreter.interpretSimple |> logifyResultAsync "CardApi.getUser")
All the dependencies here are injected, logging is taken care of, no exceptions is thrown — that's it. For web api I used Giraffe framework. Web project is here .
Conclusão
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.