Uma proposta de Clean Architecure com Modelo de Atores

Hoje eu trago uma proposta de artigo que é um exercício onde juntei dois conceitos bem legais no desenvolvimento de software: a arquitetura no estilo Clean Architecture e o modelo de atores! Eu pensei em escrever esse artigo baseado num estudo de como utilizar esses dois assuntos que eu fiz e que está disponível no meu GitHub: https://github.com/fabiogouw/OrleansBank 

Minha proposta é que este seja o primeiro artigo de uma série onde eu vou evoluindo essa aplicação de exemplo, melhorando o código e resolvendo problemas que vão surgindo, sejam eles técnicos ou mesmo conceituais.

Benefícios e premissas

Antes de seguirmos com este exercício, faz sentido relembrarmos os benefícios e premissas do clean architecture e do modelo de atores. Também vale citar que não são conceitos que se excluem, são coisas bem diferentes!

Por que Clean Architecture?

Os modelos de arquitetura como o Clean Architecture, do Uncle Bob, o Hexagonal, do Alistair Cockburn ou o Onion do Jeffrey Palermo seguem todos uma ideia de separação de responsabilidades. Todos esses desenhos pregam uma arquitetura desenhada de forma concêntrica, onde as camadas mais internas isolam regras de negócio e as mais externas representam tecnologias de hospedagem e persistência, como banco de dados. Se seu negócio mudar, você irá alterar as regras de dentro das classes de entidades e casos de uso; se seu banco de dados mudar, então você irá alterar os adaptadores que fazem a interação com essa plataforma.

Esse isolamento faz com que o sistema se torne mais estável e essa maior estabilidade na manutenção permite que você durma melhor de noite.

Para entender melhor o Clean Architecture, nada melhor que um post do próprio criador do termo: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Por que modelo de atores?

O modelo de atores é um paradigma na programação que trata os sistemas como a composição de milhares de objetos que se comunicam entre si. Esses atores formam a base desse modelo no qual o framework Orleans, da Microsoft, tomou como inspiração para a sua criação. Aqui temos um framework que permite a distribuição de objetos em várias máquinas, utilizando o conceito de location transparency, o que traz uma maior facilidade para lidar com cargas altas de processamento de forma resiliente. Além disso, como estamos lidando com uma arquitetura stateful, o estado do sistema se faz presente na camada de aplicação, o que diminui a carga de trabalho no banco de dados. Por último, o modelo de atores permite um isolamento de recursos que permite eliminar problemas de problemas de programação concorrente.

Para mais detalhes, eu participei de uma live há algum tempo onde explico com mais profundidade o que é o modelo de atores.

Clean architecture sabor baunilha

Nosso exemplo se baseia numa aplicação bancária de contas correntes, onde ocorrem débitos e créditos. Nossa funcionalidade principal aqui é a transferência de recursos entre contas, onde ocorre um débito numa conta A e o crédito desse valor numa conta B. Essa função é exposta como uma API Restful construída em cima da plataforma do ASP.NET.


O diagrama de classes acima mostra um exemplo bem comum da implementação do Clean Architecture. Note a separação em camadas, onde temos as regras de negócio nas entidades (camada vermelha) e nos casos de uso (camada amarela). Objetos nessas camadas não conhecem as implementações concretas que chamam bancos de dados, eles trabalham apenas com contratos (interfaces).

Mas onde está esse código que chama stored procedures ou grava mensagens em filas? Esse código está nas camadas de adaptadores (verde) e na camada de framework (azul), que são as que concentram os detalhes técnológicos. Os objetos nessas camadas implementam os contratos que foram definidos nas camadas mais internas e por isso conseguimos fazer esse isolamento de responsabilidades. Objetos mais externos possuem dependência nos objetos mais internos (e nunca no caminho contrário).

A seta em vermelho mostra como é o fluxo de execução da aplicação, que vem desde a API externa (gerenciada pelo framework do ASP.NET); passando pela classe de controller que faz a cola da nossa aplicação com o framework utilizado; depois chegando na implementação do caso de uso, onde obtemos os objetos que representam as contas correntes e onde também temos algumas regras específicas do processo de transferência, como a validação se o valor a ser transferido é positivo; e por último nos objetos de negócio, as entidades, que possuem regras bem específicas (como não permitir que uma conta fique com saldo negativo).

Mas como adaptamos esse modelo de arquitetura com o modelo de atores, onde os objetos são distribuídos em várias máquinas? Será que podemos considerar que um ator é uma entidade? É o que vamos ver agora na próxima seção.

Adaptações para comportar o modelo de atores

Uma das premissas do Clean Architecture é deixar as classes de negócio, mais internas, o mais independentes de framework possível. Isso ajuda a evitar que qualquer mudança nesses frameworks (por exemplo, uma nova versão) impacte no código a ponto de ser necessário alterá-lo.

Partindo dessa premissa, eu considerei neste exercício que um grain (que é o nome dado ao ator no framework Orleans) deve morar na camada de adaptadores, exatamente para evitar que uma entidade referencie esse framework. Entretanto, as regras de negócio da entidade (por exemplo, só permitir débito se houver saldo em conta) ainda devem permanecer na camada mais interna, para não perder esse conceito importante de isolamento.

A classe de caso de uso não deve então referenciar o grain. Mas ainda assim precisamos que o caso de uso controle as chamadas aos métodos de débito e crédito da conta. Como fazer essas chamadas sem quebrar a regra de dependência do Clean Architecture?  A solução é usar abstrações para que não tenhamos dependências diretas!

Abaixo está o diagrama de classes alterado para considerar o modelo de atores (mais especificamente, o framework Orleans) com o Clean Architecture. Vejam como o fluxo da aplicação (em vermelho) é bem diferente do cenário que temos o uso direto das entidades pelos casos de uso, pois esse fluxo acaba "saindo" da camada de casos de uso, indo para a camada de adaptadores (onde ficam os objetos que referenciam os grains), passando pelo framework Orleans que efetivamente chama o grain (ator virtual do Orleans), que então referencia e chama o objeto de entidade.  

Mudanças na camada de Entidades

O racional aqui é abstrair o uso da classe de entidade (Account) pela classe de caso de uso (TransferMoneyUseCaseImpl), e fazemos isso criando uma nova interface chamada IAccount. Essa classe possui os métodos que fazem o crédito, débito e consulta de saldo. A classe TransferMoneyUseCaseImpl conhece a interface IAccount, e o que vamos fazer aqui é que o grain implemente essa interface. Tanto faz para o caso de uso se ele está chamando os métodos da classe Account ou do grain, pois isso está abstraído com essa interface.
// IAccount.cs
namespace OrleansBank.Domain
{
    public interface IAccount
    {
        Task<bool> MakeCredit(string uniqueId, double amount);
        Task<bool> MakeDebit(string uniqueId, double amount);
        Task<double> GetBalance();
    }
}

// Account.cs
namespace OrleansBank.Domain
{
    public class Account : IAccount
    {
        private double _balance = 10;
        private List<Transaction> _transactions = new List<Transaction>();

        public Task<bool> MakeDebit(string uniqueId, double amount)
        {
            if(_balance < amount)
            {
                return Task.FromResult(false);
            }
            _balance -= amount;
            _transactions.Add(new Transaction()
            {
                Id = uniqueId,
                Amount = amount,
                DateTime = DateTime.Now
            });
            return Task.FromResult(true);
        }

        public Task<bool> MakeCredit(string uniqueId, double amount)
        {
            _balance += amount;
            _transactions.Add(new Transaction()
            {
                Id = uniqueId,
                Amount = amount,
                DateTime = DateTime.Now
            });
            return Task.FromResult(true);
        }

        public Task<double> GetBalance()
        {
            return Task.FromResult(_balance);
        }
    }
}

Criando os grains na camada de Adaptadores

Por questões de design do Orleans, precisamos que todos os grains implementem também uma interface que define a chave que é usada para a criação dos objetos. Assim, o framework consegue criar as várias instâncias que representam cada uma das contas. Aqui no nosso exemplo, nossa chave será simplesmente uma string, e por isso vamos usar a interface IGrainWithStringKey.

Mas não podemos esquecer dos métodos da interface IAccount, por isso vamos "misturar" as duas interfaces em uma nova, chamada IAccountGrain.
using Orleans;
using OrleansBank.Domain;

namespace OrleansBank.Adapters
{
    public interface IAccountActor : IGrainWithStringKey, IAccount
    {
    }
}
A partir dessa nova interface é que a classe AccountGrain será criada. E o detalhe aqui é que AccountGrain é que efetivamente chamará a classe Account. É como se para cada grain criado no cluster do Orleans, tenhamos uma classe Account associada. Toda chamada que o grain receber, ele irá direcionar os parâmetros para o respectivo método na sua instância da classe Account.

Uma funcionalidade que o Orleans possui é o controle de persistência de estado dos grains. Todo grain possui um estado interno e quando usamos a persistência, o framework gerencia a gravação e recuperação do estado quando ocorre a ativação e desativação dos objetos (por exemplo,  se configurarmos o SQL Server como repositório, o Orleans vai usar tabelas no banco de dados que armazenação os dados desses objetos). Um grain pode ser tipado, neste caso, ele possui uma propriedade chamada State, que é exatamente o objeto que ele considera como estado interno. 
using Orleans;
using Orleans.Providers;
using OrleansBank.Domain;

namespace OrleansBank.Adapters
{
    [StorageProvider(ProviderName = "Accounts")]
    public class AccountGrain : Grain<Account>, IAccountActor
    {
        public async Task<bool> MakeCredit(string uniqueId, double amount)
        {
            var result = await State.MakeCredit(uniqueId, amount);
            await WriteStateAsync();
            return result;
        }

        public async Task<bool> MakeDebit(string uniqueId, double amount)
        {
            var result = await State.MakeDebit(uniqueId, amount);
            await WriteStateAsync();
            return result;
        }

        public Task<double> GetBalance()
        {
            return State.GetBalance();
        }
    }
}

Uma nova implementação do repositório para retornar as referências aos grains

Mas como a classe TransferMoneyUseCaseImpl sabe que temos que usar a referência ao grain ao invés da classe Account? Para isso, nós criamos uma nova implementação de IAccountRepository, que é o objeto responsável por criar novas instâncias de "alguma coisa" que implementa IAccount.

Essa nova classe recebe como parâmetro, no seu construtor, um objeto que implementa IGrainFactory, responsável por obter as referências aos grains pelo framework do Orleans.
using Orleans;
using OrleansBank.Domain;
using OrleansBank.UseCases.Ports.Out;

namespace OrleansBank.Adapters
{
    public class OrleansAccountRepository : IAccountRepository
    {
        private readonly IGrainFactory _factory;

        public OrleansAccountRepository(IGrainFactory factory)
        {
            _factory = factory;
        }

        public IAccount Get(string id)
        {
            IAccount account = _factory.GetGrain<IAccountActor>(id);
            return account;
        }

        public void Save(IAccount account)
        {
            throw new NotImplementedException();
        }
    }
}
Veja que o método Get retorna uma referência ao grain:
  • Recebendo o id (string) como parâmetro, que é  o identificador da conta, sendo que fica de responsabilidade do Orleans criar uma instância para essa conta.
  • Pede ao Orleans que retorne um objeto que implementa IAccountActor, mas como essa mesma interface também implementa IAccount, podemos simplesmente converter a referência desse objeto.

Detalhes internos do Orleans

Agora, se você olhar novamente no diagrama, existem duas classes que foram colocadas e que não mostrei o código aqui. São as classes OrleansCodeGenAccountActorReference e OrleansCodeGenAccountActorMethodInvoker. Elas são geradas automaticamente pelo Orleans.
  • OrleansCodeGenAccountActorReference é o objeto que referencia um grain no Orleans (como as chamadas são virtualmente remotas, não é o grain que é retornado pelo framework, mas sim uma classe proxy que faz o direcionamento de chamadas internamente.
  • Internamente, o Orleans possui uma fila de chamadas que ele recebe. Cada item dessa fila é direcionado para uma classe que efetivmente chama o grain, e essa classe é o OrleansCodeGenAccountActorMethodInvoker.
Mas não se preocupe, não é necessário conhecer o funcionamento dessas classes para usar o Orleans,  ele abstrai essa necessidade para nós.

Conclusões

Com isso, fechamos todo o caminho para utilizar o Orleans com o Clean Architecture. Depois de terminar esse exemplo, eu pensei no que eu gostei e o que eu não gostei do resultado final.

O que ficou bom?

  • Não há acoplamento direto dos projetos das camadas internas (de negócio) com o próprio framework Microsoft Orleans. Com isso respeitamos a regra de dependência do Clean Architecture.
  • Os objetos de caso de uso manipulam os grains do Orleans através de interface, sem conhecer a implementação concreta. Abstraímos e isolamos a complexidade do software!
  • Acredito que o desenho tende a forçar que os métodos das entidades sejam mais significativos e melhor modelados, não temos simples métodos do tipo get e set (o que daria problema de desempenho, já que todas as chamadas aos métodos são virtualmente chamadas remotas na rede).

O que não ficou legal?

  • Temos bastante código extra para viabilizar essa arquitetura, basicamente na camada de adaptadores.
  • Vários aspectos de consistência e resiliência não foram considerados aqui, como o que acontece se der um erro logo após acontecer um débito na primeira conta?
  • Há um acoplamento conceitual na implementação do caso de uso na forma com que ele lida com as entidades do domínio, pois na prática são objetos remotos. É indireto, implícito, mas ele existe. Se a aplicação tivesse sido construída de uma forma tradicional (aplicação stateless carregando e salvando objetos no banco de dados) para depois ser evoluída com o uso do Orleans, teríamos que alterar o comportamento das classes de caso de uso. Esse foi o principal ponto que não gostei da implementação.

Pensamentos para próximas evoluções do exemplo

  • Implementar um controle de consistência na solução, por exemplo, estornando o débito na primeira conta caso haja algum problema no processo de transferência (será que o caso de uso também não deveria ser um ator, implementando uma máquina de estado para o controle de uma saga)?
  • Fazer com que as chamadas dos objetos de entidade sejam idempotentes, permitindo que seja possível alguma estratégia de retentativa de execução. Talvez implementando a parte de persistência do Orleans de forma personalizada.
  • Esse código excessivo que ficou na camada de adaptadores pode ser gerado automaticamente. Quem sabe não escrevemos uma extensão do Orleans que gere todos as classes de grains e interfaces necessárias a partir das entidades?
Por hoje é isso. O que achou dessa implementação? Discorda de alguma abordagem? Quer me ajudar a resolver os problemas que apareceram aqui? Comente aqui ou vamos discutir o código no GitHub!

[]'s

Comentários

Postagens mais visitadas deste blog

Trocando configurações padrão do Live TIM

Testes automatizados em sistemas autenticados com certificados digitais, usando Selenium e PhantomJS