Por Trás das Páginas: Independência Arquitetural!
Uma boa arquitetura faz com que o sistema seja fácil de mudar, de todas as formas necessárias, ao deixar as opções abertas. - Robert C. Martin
Não é novidade que a tecnologia está em constante evolução, onde as necessidades dos negócios mudam rapidamente e os sistemas precisam se adaptar com a mesma velocidade, a independência arquitetural emerge como um pilar fundamental para qualquer aplicação bem-sucedida. Mas o que exatamente significa a independência arquitetural?
Independência arquitetural refere-se à capacidade de um sistema ser flexível, adaptável e, acima de tudo, resistente às mudanças, sem que isso cause rupturas ou necessite de grandes reformulações. É o desafio de projetar sistemas de forma que suas diferentes partes possam evoluir independentemente umas das outras, permitindo que mudanças em uma área não afetem desproporcionalmente outras áreas. Em essência, é sobre criar uma fundação sólida que possa suportar as inevitáveis tempestades de mudanças que vêm com o tempo.
Neste artigo, vamos mergulhar profundamente no conceito de independência arquitetural, explorando sua importância e os princípios que a sustentam. Além de uma discussão teórica, também ilustraremos com exemplos práticos em código, para que você possa visualizar e entender melhor como aplicar esses conceitos em seus próprios projetos. Me baseei no capítulo do livro Arquitetura Limpa de Uncle Bob, Independência. Espero que gostem!
Se gostar do conteúdo, por favor, compartilhe e deixe seu like no post! Isso me ajuda e incentiva a continuar a trazer conteúdos em forma de texto também!😄
Mergulhando no Significado
Para começar nossa jornada, vamos fazer uma pausa e refletir sobre a palavra "independência". Segundo o dicionário, "independência" é definida como a "qualidade ou estado de ser independente". Mas o que isso realmente significa?
A raiz da palavra "independente" vem do latim independens, onde "in-" significa "não" e "dependens" significa "pendente" ou "dependente". Portanto, em sua essência, ser independente é não estar pendente ou atrelado a algo, é ter autonomia.
Agora, imagine um pássaro voando alto no céu. Ele não está vinculado a uma única árvore ou a um único pedaço de terra. Ele tem a liberdade de voar para onde quiser, explorar novos territórios e adaptar-se a diferentes ambientes. Isso ilustra um pouco do significado da independência - a liberdade de movimento, a capacidade de se adaptar e a resistência para enfrentar desafios sem estar atrelado a limitações.
Transferindo essa analogia para o mundo da arquitetura de software, podemos pensar na independência arquitetural como a capacidade de um sistema de se mover livremente, sem estar restrito por decisões passadas ou por tecnologias obsoletas. É a habilidade de um sistema de se adaptar a novos requisitos, integrar-se a novas tecnologias e evoluir sem quebrar. Em um ambiente corporativo, onde os sistemas são complexos e interconectados, essa independência é crucial. Assim como o pássaro que voa livremente, um sistema com independência arquitetural pode se adaptar, crescer e evoluir sem estar atrelado a restrições.
Mas por que essa independência é tão vital? Pense nisso: em um mundo empresarial em constante mudança, onde os requisitos mudam, as tecnologias evoluem e os negócios precisam se adaptar rapidamente, estar atrelado a uma arquitetura rígida é um convite ao desperdício. Desperdício de tempo, de recursos e, o mais importante, de oportunidades. Afinal, em um mercado competitivo, quem não se adapta, fica para trás.
E é aqui que entra a arquitetura limpa. Embora não vamos nos aprofundar em sua definição (já que você, leitor, já está familiarizado com ela), é essencial destacar seu papel crucial em promover a independência arquitetural. A arquitetura limpa não é apenas um conjunto de práticas ou um padrão a ser seguido. Mas vai além disso. É sobre construir sistemas que não apenas atendam às necessidades atuais, mas que também estejam preparados para o futuro. É sobre evitar o desperdício, seja de tempo, dinheiro ou esforço.
Mas seria legal entender a diferença entre desacoplamento e independência, pois são assuntos relacionados e que andam juntos.
Independência vs. Desacoplamento
Quando falamos sobre arquitetura de software, frequentemente nos deparamos com os termos "independência" e "desacoplamento". Embora possam parecer sinônimos à primeira vista, eles têm nuances distintas e desempenham papéis diferentes no design de sistemas robustos e escaláveis. Quero que fique claro, por isso vou recorrer a analogias e ilustrações.
Vamos começar com uma analogia do mundo corporativo: imagine uma grande empresa, a "Empresa A", que decide contratar um fornecedor, "Fornecedor X", para fornecer uma solução específica. Eles estabelecem um contrato comercial detalhado, definindo claramente os termos da parceria, as entregas esperadas, os prazos e as penalidades por não cumprimento. Este contrato é uma representação clara da relação entre a "Empresa A" e o "Fornecedor X".
Agora, a beleza deste arranjo é que, embora a "Empresa A" tenha estabelecido um contrato com o "Fornecedor X", nada neste contrato impede que a "Empresa A" também contrate outros fornecedores, digamos "Fornecedor Y" ou "Fornecedor Z", para propor soluções diferentes ou complementares. Cada fornecedor tem seu próprio contrato, atendendo a necessidades específicas da "Empresa A".
Agora, imagine que um dia, por qualquer motivo, o "Fornecedor X" decide não respeitar mais os termos do contrato. Pode ser tentador pensar que isso causaria um grande transtorno para a "Empresa A". No entanto, devido à maneira como os contratos foram estabelecidos, a "Empresa A" pode continuar suas operações sem perder o ritmo. O contrato com o "Fornecedor X" é independente dos contratos com "Fornecedor Y" ou "Fornecedor Z". Se um fornecedor falhar, os outros não são afetados. Cada contrato é como uma peça independente em um quebra-cabeça maior.
Esta é a verdadeira essência da independência. Assim como a "Empresa A" pode continuar a fazer negócios independentemente do desempenho ou decisões de um único fornecedor, na arquitetura de software, sistemas ou serviços podem operar independentemente uns dos outros. Eles podem interagir, podem se comunicar, mas no final do dia, cada um tem a capacidade de existir e operar independentemente dos outros.
Agora, imagine um trem composto por vários vagões. Cada vagão é conectado ao próximo por um engate. Esse engate permite que os vagões se movam juntos como uma unidade, mas também permite que sejam facilmente separados quando necessário. Este é o desacoplamento. O engate representa o baixo acoplamento entre os vagões, permitindo flexibilidade e modularidade. No mundo do software, isso se traduz em camadas ou componentes de um serviço que interagem entre si, mas que são projetados de forma a minimizar as dependências diretas.
Agora, transferindo essas analogias para o mundo da arquitetura de software, podemos ver como a independência e o desacoplamento se manifestam e por que são tão cruciais.
No contexto de micro-serviços, a independência é evidente quando temos dois serviços que se comunicam entre si, mas que podem existir e funcionar sem o outro. Por exemplo, imagine um serviço de e-commerce e um serviço de notificações. O serviço de e-commerce pode solicitar ao serviço de notificações que envie um e-mail ao cliente após uma compra bem-sucedida. No entanto, se o serviço de notificações estiver inativo ou enfrentando problemas, o serviço de e-commerce ainda pode processar pedidos e operar normalmente. Ele é independente do serviço de notificações.
Por outro lado, o desacoplamento que estamos discutindo é mais a nível interno. Dentro do serviço de e-commerce, por exemplo, podemos ter várias camadas - UI, lógica de negócios, acesso a dados, etc. O desacoplamento garante que essas camadas interajam entre si de forma flexível. Se a camada de acesso a dados for modificada para mudar de um banco de dados relacional para um NoSQL, isso não deve afetar diretamente a lógica de negócios ou a UI. Assim como os vagões de um trem podem ser desconectados e reconectados sem afetar o funcionamento do trem como um todo, as camadas de um serviço devem ser desacopladas para permitir flexibilidade e manutenibilidade.
Em resumo, enquanto a independência se refere à capacidade de diferentes serviços operarem sem depender uns dos outros, o desacoplamento se refere à modularidade e flexibilidade dentro de um serviço individual.
Entender a diferença entre baixo desacoplamento e independência é crucial para qualquer arquiteto ou desenvolvedor de software. Embora ambos os conceitos visem criar sistemas mais flexíveis e resilientes, eles operam em diferentes níveis e contextos.
Desacoplando os Casos de Uso
Casos de uso são, em essência, descrições detalhadas de como um sistema deve se comportar em resposta a uma determinada solicitação de um ator externo. Eles são a espinha dorsal de muitas arquiteturas de software, fornecendo uma estrutura clara para a funcionalidade do sistema. Mas, além de sua função primária de definir comportamentos, os casos de uso desempenham um papel crucial na promoção da independência e desacoplamento em sistemas complexos.
Quando pensamos em sistemas, muitas vezes visualizamos camadas horizontais, como a interface do usuário (UI), regras de negócio e o banco de dados. No entanto, os casos de uso oferecem uma perspectiva diferente, uma visão vertical que corta essas camadas. Cada caso de uso interage com diferentes partes dessas camadas, dependendo de sua função específica. Por exemplo, um caso de uso para adicionar um pedido pode interagir com uma parte da UI, algumas regras de negócio específicas e uma parte do banco de dados.
Não ficou claro o que eu quis dizer acima vamos novamente explicar com uma analogia.
Entendendo a Visão Vertical com uma Analogia
Pode parecer confuso, mas vamos tentar utilizar uma analogia. Essas camadas são como os andares de um edifício, onde cada andar tem uma função específica. Por exemplo:
O térreo, ou a camada mais visível, é a Interface do Usuário (UI). É aqui que os usuários interagem diretamente com o sistema, inserindo informações ou solicitando ações.
Acima disso, temos a camada de regras de negócio. Esta é a essência do sistema, onde a lógica e as operações principais acontecem. É como o coração e o cérebro do edifício, garantindo que tudo funcione conforme o esperado.
Finalmente, temos o subsolo, ou a camada do banco de dados. Esta camada é responsável por armazenar e recuperar informações, garantindo que os dados sejam mantidos de forma segura e eficiente.
Note que eu abstrai bastante coisa das camadas, mas fiz isso para tentar simplificar a explicação.
Agora, enquanto essas camadas horizontais são essenciais para entender a estrutura e a organização de um sistema, elas não nos dão uma imagem completa de como o sistema realmente funciona em ação.
É aqui que os casos de uso entram em cena, oferecendo uma perspectiva vertical. Imagine um elevador que viaja de um andar para outro em nosso edifício. Esse elevador representa um caso de uso específico, como "adicionar um pedido" ou "atualizar um perfil de usuário". À medida que o elevador se move, ele interage com diferentes partes de cada andar, dependendo do que precisa fazer.
Por exemplo, ao adicionar um pedido, o elevador pode começar no térreo, coletando informações do usuário na UI. Em seguida, ele se move para a camada de regras de negócio para processar e validar o pedido. Finalmente, ele desce ao subsolo para salvar esse pedido no banco de dados. Durante essa jornada, o elevador (ou caso de uso) interage apenas com as partes específicas de cada camada que são relevantes para sua função.
Em resumo, enquanto as camadas horizontais nos dão uma visão da estrutura do sistema, os casos de uso nos fornecem insights sobre o fluxo e a funcionalidade do sistema. Eles nos mostram como diferentes partes do sistema colaboram e interagem para realizar tarefas específicas, garantindo que o sistema como um todo funcione de maneira coesa e eficiente.
Ao dividir o sistema em casos de uso verticais, garantimos que cada funcionalidade seja tratada como uma entidade independente. Isso tem várias vantagens. Primeiro, facilita a manutenção. Se um caso de uso específico precisa ser alterado ou atualizado, isso pode ser feito sem perturbar os outros casos de uso. Segundo, promove uma melhor organização do código, pois cada caso de uso pode ser desenvolvido, testado e implantado de forma independente.
No entanto, para que essa abordagem seja eficaz e não resulte em um sistema altamente acoplado, é essencial adotar algumas práticas e princípios de design. Já comentei sobre eles no artigo, A Essência da Arquitetura, mas para que não fique no vácuo vou citar: Inversão de Dependência e Interfaces como contratos.
Para finalizar, essa abordagem vertical também facilita a escalabilidade. À medida que o sistema cresce e novos casos de uso são adicionados, eles podem ser facilmente integrados sem a necessidade de reestruturar ou reescrever partes significativas do código existente.
Mas vamos mergulhar ainda mais no assunto e ver isso em ação.
A Independência em Ação
Se você desacoplar os elementos do sistema que mudam por razões diferentes, poderá continuar a adicionar novos casos de uso sem interferir com os antigos. Esta é a essência da independência arquitetural. Ao manter os casos de uso separados e independentes, garantimos que o sistema possa evoluir e adaptar-se às mudanças sem grandes dores de cabeça.
Talvez não consiga ter visualizado isso claramente, por isso vou tentar ajudar com uma analogia. Mas se você já entendeu, desconsidere e siga para o próximo tópico.
A Cidade em Expansão
Imagine uma cidade planejada, onde cada bairro foi projetado para ter uma função específica: um bairro residencial, um bairro comercial, um bairro industrial e assim por diante. Cada bairro foi construído com infraestrutura adequada para atender às suas necessidades específicas, como escolas no bairro residencial, lojas no comercial e fábricas no industrial.
À medida que a cidade cresce e se desenvolve, surgem necessidades para novos bairros ou para expandir os existentes. No entanto, graças ao planejamento inicial, a cidade pode adicionar esses novos bairros ou expandir os antigos sem causar caos ou interrupções significativas. Por exemplo, se um novo bairro residencial é necessário devido ao aumento da população, ele pode ser adicionado sem afetar o funcionamento do bairro comercial ou industrial.
Isso é possível porque cada bairro opera de forma independente, atendendo às suas próprias necessidades e desafios. Eles estão "desacoplados" uns dos outros. Assim, mesmo que o bairro comercial veja um boom de novas lojas e empresas, isso não interfere na tranquilidade do bairro residencial ou no funcionamento do bairro industrial.
Esta cidade é como um sistema de software bem projetado. Ao manter diferentes partes do sistema (casos de uso) independentes e desacopladas, a cidade (sistema) pode crescer, evoluir e adaptar-se às mudanças sem grandes complicações. E assim como os bairros da cidade, os casos de uso no software podem ser adicionados ou modificados sem perturbar os já existentes.
Ok, falamos bastante sobre essa parte da independência arquitetural. Mas vamos conversar sobre os facilitadores que permitem com que o sistema progrida de forma saúdavel com a ajuda de diagramas e um pouco de código. Antes gostaria de tocar em um assunto importante, complexidade!
A Complexidade Crescente e a Dificuldade de Evolução dos Sistemas
À medida que um sistema cresce e se desenvolve, inevitavelmente se enfrenta desafios crescentes. O que começou como um projeto simples e direto pode se transformar em uma teia complexa de funcionalidades e interdependências. Mas por que muitos sistemas, que inicialmente mostravam tanto potencial, encontram dificuldades para evoluir quando as demandas e a complexidade aumentam?
A Natureza Evolutiva dos Requisitos
Em muitos projetos, os requisitos iniciais são relativamente simples. No entanto, à medida que o sistema é usado e o negócio cresce, novos requisitos surgem e os antigos mudam. Esta evolução contínua pode levar a um acúmulo de funcionalidades, muitas das quais podem não ser totalmente compatíveis ou podem se sobrepor de maneiras inesperadas.
A Falta de Visão Arquitetural
Muitos sistemas começam sem uma visão arquitetural clara. No início, quando o sistema é pequeno, isso pode não ser um problema. No entanto, à medida que o sistema cresce, a falta de uma estrutura arquitetural sólida pode levar a decisões ad hoc que, a longo prazo, tornam o sistema rígido e difícil de modificar.
Casos de Uso Mal Definidos
Os casos de uso são essenciais para entender o que um sistema deve fazer. Se eles são mal definidos, seja na fase de entendimento do negócio ou na implementação, isso pode levar a uma série de problemas. Um caso de uso mal definido pode resultar em funcionalidades que não atendem às necessidades reais dos usuários ou que são implementadas de forma ineficiente. Além disso, pode causar confusão e conflitos de interesses entre os stakeholders, já que diferentes partes interessadas podem ter diferentes interpretações de um caso de uso mal definido.
A Armadilha da Complexidade
À medida que os sistemas crescem, eles tendem a se tornar mais complexos. Esta complexidade pode tornar-se uma barreira para futuras mudanças. Muitos programadores e arquitetos, ao enfrentarem essa crescente complexidade, podem achar difícil manter o ritmo inicial. O que antes era fácil de implementar agora requer uma consideração cuidadosa das interdependências e potenciais efeitos colaterais.
Conflitos de Interesse
Quando os casos de uso são mal definidos ou mal implementados, isso pode levar a conflitos de interesse entre os stakeholders. Por exemplo, o que a equipe de vendas vê como uma funcionalidade essencial pode ser visto pela equipe de desenvolvimento como um desvio do objetivo principal do sistema. Esses conflitos podem retardar o desenvolvimento e levar a soluções de compromisso que não atendem totalmente às necessidades de ninguém.
A Importância de Manter as Regras de Negócio Fora dos Casos de Uso
Por que manter as regras de negócio fora dos casos de uso?
Flexibilidade: As regras de negócio definem a lógica central de um sistema. Se essas regras estiverem acopladas aos casos de uso, qualquer mudança nas regras pode exigir uma revisão dos casos de uso, tornando o sistema menos flexível.
Testabilidade: Ao manter as regras de negócio separadas, podemos testá-las de forma isolada, garantindo que elas funcionem como esperado sem a necessidade de envolver os casos de uso.
Clareza: Casos de uso devem descrever fluxos de trabalho de alto nível. Misturar regras de negócio com esses fluxos pode tornar os casos de uso confusos e menos legíveis.
Reutilização: Regras de negócio separadas podem ser reutilizadas em diferentes casos de uso ou até mesmo em diferentes sistemas.
Vamos utilizar um exemplo simples, mas que demonstra na prática esse grande perigo:
class CreateUserUseCase {
constructor(private userRepository: UserRepository) {}
execute(user: User) {
if (user.age < 18) {
throw new Error("User must be at least 18 years old.");
}
this.userRepository.save(user);
}
}
A regra de negócio (verificar a idade do usuário) está acoplada ao caso de uso. Isso torna o caso de uso menos flexível e mais difícil de testar. Gostaria de destacar mais algumas coisas sobre a classe CreateUserUseCase:
Acoplamento com a Implementação do Repositório: O UseCase está diretamente acoplado à implementação do
UserRepository
. Isso significa que qualquer mudança no repositório pode afetar o UseCase. Seria mais apropriado depender de uma interface ou abstração do repositório.
Vamos tentar novamente e fornecer e separar melhor as responsabilidades:
interface IUserRepository {
save(user: User): void;
// Outros métodos relacionados ao repositório, se necessário.
}
class CreateUserUseCase {
constructor(private userRepository: IUserRepository, private userValidator: UserValidator) {}
execute(user: User) {
this.userValidator.validate(user);
this.userRepository.save(user);
}
}
class UserValidator {
validate(user: User) {
if (user.age < 18) {
throw new Error("User must be at least 18 years old.");
}
}
}
Muito melhor! Não temos acoplamento direto entre classes. E o Caso de Uso faz apenas o necessário, coordena as chamadas sem se preocupar em aplicar regras de negócio! É crucial que os casos de uso respeitem limites claros. Eles devem coordenar o fluxo de um sistema sem se preocupar com os detalhes das camadas internas ou externas. Isso garante que os casos de uso permaneçam focados em sua principal responsabilidade: descrever o fluxo de trabalho do sistema.
Além disso, ao evitar sobrecarregar um caso de uso com responsabilidades que poderiam pertencer a outros casos de uso independentes, garantimos que cada caso de uso seja pequeno, gerenciável e fácil de entender. Isso facilita a manutenção e evolução do sistema à medida que ele cresce e muda.
A imagem abaixo tenta deixar isso o mais claro possível:
Fica claro com esse pequeno exemplo que a separação clara de responsabilidades e a manutenção das regras de negócio fora dos casos de uso são fundamentais para criar sistemas flexíveis, testáveis e fáceis de manter.
A Intenção Básica do Sistema
A intenção básica do sistema refere-se ao propósito fundamental ou à razão pela qual o sistema existe. É a visão ampla e geral do que o sistema deve realizar. Por exemplo, um sistema de e-commerce tem a intenção básica de permitir que os usuários comprem e vendam produtos online. Esta intenção não se aprofunda nos detalhes de como os usuários se registram, como os pagamentos são processados ou como os produtos são listados. Em vez disso, foca no objetivo geral do sistema.
Como a Intenção do Sistema Guia a Arquitetura
A intenção básica do sistema serve como uma bússola para a arquitetura do software. Ela ajuda os arquitetos e programadores a manterem o foco no objetivo geral do sistema, garantindo que todas as decisões arquitetônicas e de design estejam alinhadas com essa visão.
Por exemplo, se a intenção básica de um sistema é processar grandes volumes de dados em tempo real, a arquitetura pode ser orientada para tecnologias e abordagens que suportem processamento de alta velocidade e escalabilidade. Por outro lado, se a intenção é fornecer uma experiência de usuário rica e interativa, a arquitetura pode priorizar frameworks e ferramentas que suportem interfaces de usuário avançadas e interativas. Tudo fica ainda mais claro quando separamos frameworks que são detalhes de regras criticas de negócio!
Entendimento Completo e Detalhado
Ter uma compreensão clara tanto da intenção básica do sistema quanto dos casos de uso específicos é essencial para criar uma arquitetura robusta e resiliente. O entendimento completo permite que os arquitetos vejam a "imagem completa", garantindo que o sistema como um todo atenda às necessidades do negócio. Ao mesmo tempo, mergulhar nos detalhes dos casos de uso garante que cada funcionalidade seja implementada de forma eficaz e que todas as nuances sejam consideradas. Vamos explorar isso melhor:
Entendimento Completo:
Visão Macro: Esta é a visão de 10.000 pés de altitude. É sobre entender o panorama geral do sistema. O que o sistema deve fazer em termos gerais? Quais são os principais objetivos e metas? Qual é a intenção básica do sistema?
Benefícios:
Visão Holística: Permite que os arquitetos e desenvolvedores vejam toda a "imagem completa" do sistema. Isso garante que todas as partes do sistema trabalhem em harmonia e que não haja conflitos entre diferentes componentes ou funcionalidades.
Alinhamento com o Negócio: Garantir que o sistema como um todo atenda às necessidades e objetivos do negócio. Isso é crucial para o retorno do investimento e para garantir que o software atenda às expectativas dos stakeholders.
Entendimento Detalhado:
Visão Micro: Uma vez que a visão macro esteja clara, é hora de mergulhar nos detalhes. Como cada funcionalidade específica funcionará? Quais são os casos de uso específicos e como eles se encaixam no panorama geral?
Benefícios:
Implementação Eficiente: Ao entender cada nuance e detalhe dos casos de uso, os desenvolvedores podem implementar funcionalidades de forma mais eficaz, garantindo que o software funcione conforme o esperado em todos os cenários.
Prevenção de Erros: Mergulhar nos detalhes ajuda a identificar e prevenir possíveis problemas ou conflitos. Isso pode evitar erros caros no futuro e garantir que o software seja robusto e resiliente.
Flexibilidade e Escalabilidade: Ao entender detalhadamente cada caso de uso, os arquitetos podem projetar o sistema de forma que ele possa ser facilmente adaptado ou expandido no futuro, conforme as necessidades do negócio mudam.
Veja o diagrama de uma visão um pouco mais detalhada:
A imagem acima reforça novamente o componente que é um dos responsáveis por trazer a independência arquitetural para o sistema. O Caso de Uso! Este está no núcleo do sistema. Ele não se preocupa com detalhes externos, como como os dados são apresentados na UI ou como são armazenados no banco de dados. Em vez disso, ele se concentra em coordenar o fluxo de ações necessárias para realizar uma tarefa específica - neste caso, criar um usuário. Ele faz isso consultando as Entidades para verificar as regras de negócio e, em seguida, instruindo os Adaptadores de Interface a persistir os dados.
Ok, mas nem tudo são flores na arquitetura de software. E sem algum momento um caso de uso precisar de operações que se assemelham com outra operação que outro Use Case já faz no sistema?
Abordagens para Casos de Uso com Funções Semelhantes
Quando desenvolvemos sistemas complexos, é comum encontrar situações em que um Caso de Uso precisa realizar operações que parecem se sobrepor ou se assemelhar a operações de outro Caso de Uso. Isso pode levar a questionamentos sobre como lidar com essa sobreposição de responsabilidades e evitar a duplicação de lógica.
Uma das abordagens mais comuns para lidar com essa situação é a utilização de Serviços de Domínio. Estes são classes ou componentes que encapsulam lógicas de negócio específicas que não pertencem naturalmente a uma entidade ou valor específico. Eles operam em um nível de abstração semelhante ao das entidades, mas lidam com operações que transcendem uma única entidade.
Por exemplo, imagine um e-commerce onde temos um Caso de Uso para "Finalizar Compra" e outro para "Aplicar Desconto". Ambos os Casos de Uso podem precisar de uma lógica para calcular o total do carrinho. Em vez de duplicar essa lógica em ambos os Casos de Uso, podemos criar um Serviço de Domínio chamado "CalculadoraDeTotal" que é responsável por essa operação. Assim, ambos os Casos de Uso podem invocar esse serviço, garantindo que a lógica seja mantida em um único lugar e possa ser reutilizada.
Outra técnica é o uso do Pattern Strategy. Se diferentes Casos de Uso precisam realizar uma operação de maneira ligeiramente diferente, podemos definir uma interface comum para essa operação e implementar diferentes "estratégias" para cada variação. Isso permite que os Casos de Uso sejam flexíveis em termos de como realizam certas operações, sem se acoplar a uma implementação específica. Vamos ver isso em código:
Preste atenção ao serviço de domínio para calcular o total e a entidade atuando juntos:
// Definição da entidade Cart e do enum CalculationStrategyType
class Cart {
items: CartItem[] = [];
private _calculationStrategyType: CalculationStrategyType = CalculationStrategyType.Regular;
set calculationStrategyType(value: CalculationStrategyType) {
if (!Object.values(CalculationStrategyType).includes(value)) {
throw new Error('Invalid calculation strategy type');
}
this._calculationStrategyType = value;
}
get calculationStrategyType(): CalculationStrategyType {
return this._calculationStrategyType;
}
addItem(item: CartItem) {
this.items.push(item);
}
}
enum CalculationStrategyType {
Discounted = 'Discounted',
Regular = 'Regular'
}
// Definição da interface e das classes de estratégia
interface CalculationStrategyInterface {
calculate(cart: Cart): number;
}
class DiscountedPriceCalculationService implements CalculationStrategyInterface {
calculate(cart: Cart): number {
console.log('Calculando preço com desconto...');
return cart.items.length * 90;
}
}
class RegularPriceCalculationService implements CalculationStrategyInterface {
calculate(cart: Cart): number {
console.log('Calculando preço regular...');
return cart.items.length * 100;
}
}
// Interface para o TotalCalculatorService
interface ITotalCalculatorService {
calculateTotal(cart: Cart): number;
}
class TotalCalculatorService implements ITotalCalculatorService {
constructor(
private discountedPriceCalculationService: CalculationStrategyInterface,
private regularPriceCalculationService: CalculationStrategyInterface
) {}
calculateTotal(cart: Cart): number {
let strategy: CalculationStrategyInterface;
switch (cart.calculationStrategyType) {
case CalculationStrategyType.Discounted:
strategy = this.discountedPriceCalculationService;
break;
case CalculationStrategyType.Regular:
default:
strategy = this.regularPriceCalculationService;
break;
}
return strategy.calculate(cart);
}
}
interface UseCase<T> {
execute(input: T): void;
}
class FinalizePurchaseUseCase implements UseCase<Cart> {
constructor(private totalCalculator: ITotalCalculatorService) {}
execute(cart: Cart) {
const total = this.totalCalculator.calculateTotal(cart);
console.log('Finalizando compra com total:', total);
}
}
class ApplyDiscountUseCase implements UseCase<Cart> {
constructor(private totalCalculator: ITotalCalculatorService) {}
execute(cart: Cart) {
cart.calculationStrategyType = CalculationStrategyType.Discounted;
const discountedTotal = this.totalCalculator.calculateTotal(cart);
console.log('Aplicando desconto com total:', discountedTotal);
}
}
// Simulação de uso
class CartItem {} // Classe fictícia para representar um item do carrinho
const cart = new Cart();
cart.addItem(new CartItem());
cart.addItem(new CartItem());
const discountedService = new DiscountedPriceCalculationService();
const regularService = new RegularPriceCalculationService();
const totalCalculator = new TotalCalculatorService(discountedService, regularService);
const finalizePurchase = new FinalizePurchaseUseCase(totalCalculator);
const applyDiscount = new ApplyDiscountUseCase(totalCalculator);
console.log('--- Finalize Purchase ---');
finalizePurchase.execute(cart);
console.log('--- Apply Discount ---');
applyDiscount.execute(cart);
O código acima é funcional e serve para fins didáticos apenas! Mas veja que TotalCalculatorService
atua como um facilitador, um serviço que centraliza estratégias de descontos. Mas quem determina quais os tipos de descontos são permitidos ainda é a entidade! Vamos conversar rapidamente sobre a entidade Cart
:
class Cart {
items: CartItem[] = [];
private _calculationStrategyType: CalculationStrategyType = CalculationStrategyType.Regular; // Inicialização com valor padrão
set calculationStrategyType(value: CalculationStrategyType) {
if (!Object.values(CalculationStrategyType).includes(value)) {
throw new Error('Invalid calculation strategy type');
}
this._calculationStrategyType = value;
}
get calculationStrategyType(): CalculationStrategyType {
return this._calculationStrategyType;
}
// Simulando um item no carrinho para fins de teste
addItem(item: CartItem) {
this.items.push(item);
}
}
enum CalculationStrategyType {
Discounted = 'Discounted',
Regular = 'Regular'
}
Regras de Negócio Críticas nas Entidades
A entidade Cart
é responsável por manter a integridade e a validade dos dados relacionados ao carrinho de compras. Uma das principais responsabilidades é garantir que qualquer estratégia de cálculo aplicada ao carrinho seja válida. Isso é evidente no setter calculationStrategyType
, onde a entidade verifica se o tipo de estratégia fornecido é válido antes de aceitá-lo. Se um tipo inválido for fornecido, a entidade lançará um erro, protegendo assim a integridade dos dados.
Alinhamento com as Necessidades do Negócio
Os stakeholders, que são os principais interessados no sistema, têm requisitos específicos sobre quais descontos podem ser aplicados e em quais circunstâncias. Ao centralizar essa regra na entidade Cart
, garantimos que esses requisitos sejam cumpridos de forma consistente em todo o sistema. Isso significa que, independentemente de como ou onde o carrinho é usado, as regras de negócio permanecerão consistentes e alinhadas com as expectativas dos stakeholders.
Direcionando o Fluxo de Operações
As regras de negócio críticas, como as que determinam a estratégia de cálculo a ser usada, não são apenas passivas. Elas ativamente determinam se o fluxo de operações no sistema vai prosseguir ou não. Por exemplo, se um determinado desconto só pode ser aplicado a carrinhos com mais de 10 itens, é a entidade Cart
que determina se esse critério foi atendido. Ao fazer isso, a entidade não apenas protege a integridade dos dados, mas também garante que o sistema opere de acordo com as regras de negócio definidas.
Além disso, é essencial manter uma comunicação clara entre as equipes e revisar regularmente a arquitetura e suas regras. À medida que o sistema cresce e evolui, novos Casos de Uso podem ser introduzidos e a lógica existente pode precisar ser refatorada ou reorganizada. A revisão contínua garante que a arquitetura permaneça limpa, coesa e alinhada com os objetivos do negócio.
Novamente tentei deixar isso o mais claro possível em um diagrama:
Ok, mas como esse exemplo se encaixa perfeitamente no cenário de independencia arquitetural? Vamos conversar sobre isso:
Casos de Uso Desacoplados:
Os casos de uso, como
FinalizePurchaseUseCase
eApplyDiscountUseCase
, são responsáveis por orquestrar a lógica de negócios de alto nível. Eles não estão diretamente ligados a detalhes de implementação específicos, como a forma exata de calcular um desconto. Em vez disso, eles dependem de abstrações (interfaces) para realizar suas tarefas.Isso significa que se a lógica de cálculo de desconto mudar no futuro, ou se novos tipos de descontos forem introduzidos, os casos de uso não precisarão ser modificados. Eles permanecem desacoplados dos detalhes de implementação.
Interfaces como Contratos:
A interface
CalculationStrategyInterface
serve como um contrato que qualquer serviço de cálculo deve seguir. Isso garante que os casos de uso possam interagir com qualquer implementação de cálculo sem precisar saber os detalhes específicos dessa implementação.Interfaces promovem a inversão de dependência. Em vez de os casos de uso dependerem de implementações concretas, eles dependem de abstrações. Isso permite que diferentes implementações sejam facilmente intercambiadas sem afetar os casos de uso.
Extensibilidade através do Padrão de Estratégia:
O padrão de estratégia, implementado através das classes
DiscountedPriceCalculationService
,RegularPriceCalculationService
, e potencialmente muitas outras no futuro, permite que novas estratégias de cálculo sejam adicionadas sem modificar o código existente.Isso é um exemplo do Princípio do Aberto/Fechado (Open/Closed Principle) em ação. O sistema é aberto para extensão (podemos adicionar novas estratégias de cálculo) mas fechado para modificação (não precisamos alterar o código existente para adicionar uma nova estratégia).
Centralização da Lógica de Negócios nas Entidades:
A entidade
Cart
é responsável por manter seu próprio estado e garantir que ele permaneça consistente. Por exemplo, ela valida o tipo de estratégia de cálculo definido para garantir que seja um valor permitido.Isso garante que a lógica de negócios crítica esteja contida dentro das entidades, tornando o sistema mais robusto e reduzindo a chance de erros.
O que podemos aprender? A independência arquitetural é alcançada através do uso cuidadoso de abstrações, padrões de design e princípios de design sólidos. Ao fazer isso, construímos sistemas que são mais fáceis de manter, estender e adaptar às mudanças nas necessidades de negócios.
Conclusão
A independência arquitetural é, sem dúvida, uma das características importantes quando se trata de design de software. Ao longo de nossa discussão, mergulhamos profundamente em sua essência e nas nuances que a tornam tão crucial para a construção de sistemas.
Ao garantir essa independência, abrimos portas para uma série de benefícios. A flexibilidade é um deles. Em um mundo em constante mudança, onde novas ferramentas e técnicas emergem quase diariamente, sistemas que são rigidamente acoplados a tecnologias ou métodos específicos se tornam obsoletos rapidamente. No entanto, com uma arquitetura independente, podemos adaptar e evoluir, substituindo ou atualizando partes do sistema conforme necessário, sem a necessidade de uma revisão completa.
A testabilidade é outra área que se beneficia enormemente. Em sistemas altamente acoplados, testar um componente muitas vezes significa testar muitos outros que estão intrinsecamente ligados a ele. Além de poder ser difícil criar os mocks. Mas, com independência, podemos focar nossos testes exatamente onde eles são necessários, garantindo que cada parte do sistema funcione como deveria, independentemente das outras.
Além disso, a clareza e a compreensibilidade do sistema são aprimoradas. Em vez de um emaranhado complexo de dependências, temos uma série de componentes bem definidos que interagem de maneiras específicas e previsíveis. Isso não apenas facilita a manutenção, mas também torna o processo de integração de novos desenvolvedores ao projeto muito mais suave.
Mas talvez o aspecto mais crítico da independência arquitetural seja a maneira como ela alinha o design do software com as necessidades e realidades do negócio. Em muitas organizações, as regras de negócios e os requisitos mudam com uma frequência surpreendente. Um sistema que é fortemente acoplado a uma determinada configuração ou conjunto de regras é, por natureza, resistente a essas mudanças. Em contraste, uma arquitetura independente permite que o sistema se adapte e evolua junto com o negócio, garantindo que continue a fornecer valor mesmo diante de mudanças.
Fico por aqui, até o próximo post! 😄👨🏻💻