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
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
Mudanças na camada de Entidades
// 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
using Orleans;
using OrleansBank.Domain;
namespace OrleansBank.Adapters
{
public interface IAccountActor : IGrainWithStringKey, IAccount
{
}
}
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
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();
}
}
}
- 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
- 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.
Conclusões
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?
Comentários
Postar um comentário