Durante o desenvolvimento de software, tomamos decisões que, à primeira vista, parecem restritas à funcionalidade que estamos implementando. Escolhemos onde colocar determinada lógica, quais componentes utilizar, quando reutilizar uma implementação existente e como organizar as responsabilidades.
Muitas dessas escolhas são feitas com um objetivo imediato: resolver o problema e garantir que a funcionalidade apresente o comportamento esperado.
Entretanto, nem sempre suas consequências permanecem limitadas ao componente modificado.
Uma alteração no formato de um dado pode exigir mudanças em diferentes módulos. Uma dependência introduzida por conveniência pode dificultar testes e futuras modificações. Uma abstração criada para evitar duplicação pode acabar vinculando funcionalidades que possuem responsabilidades distintas.
Essas situações revelam uma característica fundamental dos sistemas de software: seus componentes não existem isoladamente, e a maneira como se relacionam influencia a capacidade de evolução da aplicação.
No capítulo 2 de Fundamentos da Arquitetura de Software, Mark Richards e Neal Ford abordam o pensamento arquitetural como uma capacidade que exige ampliar a perspectiva sobre o sistema, considerando aspectos que ultrapassam a implementação imediata.
Essa mudança de perspectiva nos convida a formular perguntas diferentes:
Além de investigar como uma funcionalidade deve ser construída, precisamos compreender quais responsabilidades estão envolvidas, quais dependências serão estabelecidas e como essas escolhas poderão afetar o restante da aplicação.
Mas como reconhecer essas relações? Por que algumas dependências são necessárias enquanto outras podem se tornar problemáticas? E como evitar que a tentativa de construir uma arquitetura organizada introduza complexidade desnecessária?
Para explorar essas questões, começaremos com um exemplo aparentemente simples.
Da implementação à visão sistêmica
Imagine uma aplicação que oferece duas funcionalidades relacionadas ao processamento de documentos XML.
A primeira permite identificar e tratar informações sensíveis presentes nos documentos. A segunda compara dois arquivos e apresenta as diferenças encontradas entre suas estruturas e conteúdos.
Como ambas trabalham com XML, poderíamos considerar a criação de um único mecanismo de processamento compartilhado.
Inicialmente, essa possibilidade parece interessante. Afinal, se as duas funcionalidades precisam interpretar documentos no mesmo formato, por que não reutilizar a mesma implementação?
Entretanto, antes de decidir como organizar esses componentes, precisamos compreender suas responsabilidades.
O mecanismo de sanitização precisa reconhecer informações consideradas sensíveis e aplicar os tratamentos definidos para protegê-las. Dependendo dos requisitos, essas operações podem envolver regras específicas para diferentes elementos e atributos.
Já o mecanismo de comparação precisa identificar divergências entre documentos. Isso pode exigir regras próprias para analisar estruturas, atributos, valores e hierarquias.
Embora os dois mecanismos utilizem o mesmo formato de entrada, eles não necessariamente possuem os mesmos objetivos nem os mesmos motivos para mudar.
Uma alteração nas regras de identificação de informações sensíveis, por exemplo, não deveria exigir modificações na lógica responsável por comparar documentos, a menos que exista uma relação funcional que justifique essa mudança.
Por outro lado, operações comuns de interpretação XML podem ser compartilhadas quando representam responsabilidades realmente equivalentes e possuem contratos adequados.
Percebemos, então, que o formato dos dados não é suficiente para determinar como devemos organizar as funcionalidades.
Precisamos investigar o comportamento esperado, as responsabilidades envolvidas e as relações que serão estabelecidas.
Essa é uma das contribuições da visão sistêmica.
Em vez de observar apenas a funcionalidade que estamos implementando, procuramos compreender como ela participa de um conjunto maior de componentes e como suas decisões podem influenciar outras partes do sistema.
Pensar arquiteturalmente não significa apenas encontrar uma maneira de implementar determinada funcionalidade, mas compreender as consequências da implementação escolhida.
Essa perspectiva nos aproxima de dois conceitos fundamentais: dependência e acoplamento.
Dependências: relações necessárias e relações problemáticas
Uma dependência existe quando determinado componente precisa de outro para cumprir uma responsabilidade.
Em uma aplicação web, por exemplo, um componente de interface pode depender de um caso de uso para executar determinada operação. Esse caso de uso, por sua vez, pode precisar de outros componentes para acessar informações, executar regras ou persistir resultados.
Essas relações fazem parte do funcionamento normal de um sistema.
Portanto, a existência de uma dependência não representa necessariamente um problema arquitetural.
O desafio está em compreender a natureza dessa relação e quais consequências ela poderá produzir quando uma das partes precisar ser modificada.
Para visualizar essa diferença, podemos retornar ao exemplo do processamento XML.
Imagine que o mecanismo de comparação utilize diretamente funções internas do sanitizador apenas porque essas funções já percorrem elementos de um documento.
Essa reutilização pode parecer conveniente inicialmente.
Entretanto, se as funções utilizadas tiverem sido construídas especificamente para atender às regras de sanitização, futuras alterações nessas regras poderão afetar indiretamente o comparador.
Uma mudança que deveria permanecer restrita ao tratamento de informações sensíveis passa a exigir atenção sobre outra funcionalidade.
Nesse cenário, o problema não está necessariamente na reutilização de código, mas na dependência estabelecida entre responsabilidades que possuem motivos diferentes para mudar.
Agora considere outra possibilidade.
Ambos os mecanismos utilizam uma operação responsável exclusivamente por interpretar um documento XML e retornar uma representação previamente definida, sem incorporar regras específicas de sanitização ou comparação.
Se essa responsabilidade for verdadeiramente comum, o compartilhamento pode ser adequado.
A diferença está no significado da dependência.
No primeiro cenário, uma funcionalidade conhece detalhes internos de outra. No segundo, ambas utilizam uma responsabilidade comum, cuja existência pode ser justificada independentemente das regras particulares de cada mecanismo.
Essa distinção é importante porque nem toda semelhança de implementação representa uma responsabilidade compartilhada.
Duas funções podem possuir estruturas parecidas e, ainda assim, existir por razões completamente diferentes.
Da mesma forma, componentes com implementações distintas podem depender legitimamente de um contrato comum.
O objetivo não é eliminar dependências, mas compreender quais são necessárias e quais introduzem relações que podem dificultar a evolução.
Entretanto, como avaliar a intensidade dessas relações e seus possíveis impactos?
É nesse ponto que precisamos compreender o acoplamento.
Acoplamento e propagação de mudanças
O acoplamento está relacionado ao grau de interdependência entre componentes de um sistema.
Quanto mais um componente conhece ou depende dos detalhes de outro, maior pode ser a dificuldade de modificá-los independentemente.
Entretanto, o acoplamento não deve ser avaliado apenas pela quantidade de dependências existentes.
Também precisamos considerar quais informações são compartilhadas, quais detalhes ficam expostos e como alterações podem se propagar entre os componentes.
Imagine um mecanismo responsável por interpretar documentos XML.
Inicialmente, ele retorna uma estrutura específica, contendo informações organizadas de acordo com suas regras internas de processamento.
Outros componentes passam a utilizar diretamente essa estrutura, conhecendo os nomes dos campos, os tipos específicos e as particularidades da representação produzida.
Posteriormente, surge a necessidade de modificar a maneira como os documentos são interpretados.
Se a estrutura interna também for utilizada como contrato entre os componentes, a alteração poderá exigir modificações em todos os consumidores afetados.
Uma decisão aparentemente localizada passa a produzir mudanças em diferentes pontos da aplicação.
Esse comportamento é conhecido como propagação de mudanças.
Dependendo da estrutura do sistema, modificar um componente pode exigir alterações adicionais em outros, mesmo quando suas responsabilidades não mudaram.
Uma alternativa consiste em estabelecer contratos que exponham apenas as informações e operações necessárias aos consumidores.
Nesse cenário, o mecanismo pode modificar determinados detalhes internos sem afetar os componentes que dependem de seu contrato, desde que a compatibilidade seja preservada.
Isso não significa que contratos eliminem todos os impactos de mudança.
Se uma regra funcional ou uma necessidade legítima modificar as informações exigidas pelos consumidores, os contratos também poderão precisar evoluir.
O benefício está em reduzir a propagação de alterações que pertencem apenas aos detalhes de implementação.
A importância da estabilidade
Outro aspecto relevante é a estabilidade dos componentes e de seus contratos.
Imagine um componente utilizado por diversas funcionalidades.
O fato de possuir muitos consumidores não representa automaticamente um problema. Uma biblioteca com responsabilidades bem definidas e contratos estáveis pode ser amplamente reutilizada sem exigir alterações frequentes em seus dependentes.
Entretanto, quando um componente compartilhado modifica constantemente seus contratos, cada alteração pode exigir adaptações em várias partes do sistema.
Isso nos ajuda a compreender que o impacto de uma dependência está relacionado tanto à sua natureza quanto à frequência e ao tipo de mudança esperada.
Um componente pode depender de diferentes serviços e continuar relativamente simples de manter.
Por outro lado, uma única dependência que exponha detalhes instáveis pode introduzir dificuldades significativas.
O problema não é simplesmente quantos componentes dependem uns dos outros, mas quanto precisam conhecer e modificar quando alguma dessas relações evolui.
Essa reflexão também demonstra por que a organização dos arquivos, isoladamente, não define o nível de acoplamento de uma aplicação.
Podemos distribuir o código em diferentes diretórios e ainda manter dependências fortes entre seus componentes.
Da mesma maneira, componentes localizados em um mesmo módulo podem apresentar responsabilidades bem delimitadas e relações simples.
A questão arquitetural está nos limites estabelecidos e nas dependências efetivamente existentes.
Separação de responsabilidades e definição de contratos
Se dependências inadequadas podem ampliar a propagação de mudanças, precisamos compreender como estabelecer limites entre os componentes.
Uma das estratégias consiste em separar responsabilidades de acordo com os comportamentos e motivos de mudança envolvidos.
Essa preocupação está relacionada à coesão.
A coesão expressa o quanto os elementos de um componente estão relacionados à responsabilidade que ele pretende cumprir.
Um componente com responsabilidades bem definidas tende a reunir comportamentos que fazem sentido em conjunto.
Por outro lado, quando um mesmo componente concentra responsabilidades sem relação suficiente, modificações motivadas por uma delas podem afetar as demais.
Retomemos o exemplo dos dois mecanismos XML.
O sanitizador precisa evoluir conforme mudam as regras de identificação e tratamento das informações sensíveis.
O comparador precisa evoluir conforme surgem novos requisitos para identificar e apresentar divergências.
Se reunirmos todas essas regras em um único mecanismo genérico, poderemos criar uma estrutura que precisa ser modificada por motivos diferentes.
Isso não significa que os dois mecanismos devam obrigatoriamente possuir implementações completamente independentes.
Significa que suas responsabilidades precisam ser analisadas antes de decidirmos o que compartilhar.
Uma operação comum para leitura de documentos pode ser reutilizada. Entretanto, as regras específicas de sanitização e comparação devem possuir limites compatíveis com suas necessidades de evolução.
O papel dos contratos
Os contratos ajudam a estabelecer como componentes podem colaborar sem precisar conhecer todos os detalhes de suas implementações.
Um contrato pode definir quais operações estão disponíveis, quais informações são recebidas e quais resultados são produzidos.
Ao depender desse contrato, um componente pode utilizar determinada responsabilidade sem necessariamente conhecer todos os detalhes de seu funcionamento interno.
Mas existe uma distinção importante: criar uma interface não significa automaticamente reduzir o acoplamento.
Se o contrato expuser diretamente detalhes específicos de uma biblioteca, fornecedor ou implementação, os consumidores continuarão dependendo dessas particularidades.
Da mesma forma, uma interface excessivamente genérica pode tornar a comunicação mais difícil de compreender.
Um contrato adequado deve representar as necessidades reais dos componentes envolvidos.
Essa preocupação aparece de maneira particularmente clara no Princípio da Inversão de Dependência.
Inversão de Dependência: protegendo regras contra detalhes externos
Imagine uma aplicação que precisa consultar informações cadastrais por meio de uma API externa.
Uma implementação direta poderia fazer com que o caso de uso responsável pela consulta conhecesse o endereço do serviço, os parâmetros utilizados nas requisições e a estrutura específica dos dados retornados.
Essa abordagem pode funcionar e, dependendo do contexto, ser suficiente.
Entretanto, também estabelece uma dependência direta entre uma responsabilidade da aplicação e os detalhes de comunicação de determinado fornecedor.
Se a API modificar sua estrutura de resposta ou precisar ser substituída, essas mudanças poderão alcançar o componente responsável pelas regras da aplicação.
O Princípio da Inversão de Dependência, ou Dependency Inversion Principle (DIP), propõe uma maneira de organizar essas relações.
Em sua formulação clássica, módulos de alto nível não devem depender diretamente de módulos de baixo nível; ambos devem depender de abstrações. Além disso, as abstrações não devem depender dos detalhes, mas os detalhes devem depender das abstrações.
Para compreender sua aplicação, podemos imaginar que o caso de uso precisa apenas executar uma operação de consulta e receber determinadas informações cadastrais.
Essa necessidade pode ser representada por um contrato independente das particularidades do fornecedor.
A implementação responsável pela comunicação externa passa a atender esse contrato, convertendo os dados recebidos para uma representação compreendida pela aplicação.
Com isso, o caso de uso deixa de depender diretamente dos detalhes específicos da API.
Se o fornecedor precisar ser substituído, uma nova implementação poderá atender ao mesmo contrato, desde que os requisitos da operação permaneçam compatíveis.
Essa organização também pode facilitar testes, permitindo substituir a comunicação externa por uma implementação controlada durante a verificação dos comportamentos.
Entretanto, precisamos observar um ponto importante.
A aplicação continuará dependendo de algum mecanismo que realize efetivamente a consulta. O princípio não elimina essa necessidade.
O que muda é a direção da dependência no código e o conhecimento que os componentes internos possuem sobre os detalhes externos.
Também não devemos confundir inversão de dependência com injeção de dependência. A inversão de dependência orienta o relacionamento entre políticas e detalhes de implementação. A injeção de dependência é uma técnica que pode ser utilizada para fornecer implementações a componentes que necessitam delas. Embora frequentemente utilizadas em conjunto, não representam o mesmo conceito.
Toda dependência precisa de uma abstração?
Não necessariamente.
Criar contratos para cada classe ou função pode aumentar a quantidade de elementos do sistema sem oferecer benefícios proporcionais.
Uma abstração precisa representar uma necessidade compreensível.
Quando existe uma dependência estável, simples e sem riscos relevantes, introduzir uma interface adicional apenas para seguir determinada estrutura pode não melhorar a arquitetura.
Por outro lado, quando uma dependência envolve detalhes externos, necessidade de substituição, isolamento de regras ou testes específicos, uma abstração pode oferecer benefícios concretos.
O importante é compreender o problema que o contrato pretende resolver.
Essa reflexão revela outra dimensão do pensamento arquitetural: até mesmo mecanismos destinados a reduzir o acoplamento podem introduzir complexidade.
Complexidade essencial e complexidade acidental
Ao estudar princípios arquiteturais, podemos ser levados a acreditar que utilizar mais abstrações, contratos e camadas sempre resulta em um sistema melhor organizado.
Entretanto, toda estrutura adicional apresenta custos.
Para compreender essa questão, é útil distinguir complexidade essencial de complexidade acidental.
A complexidade essencial decorre da natureza do problema que precisamos resolver.
Ela está relacionada às regras, restrições e comportamentos necessários para que a aplicação cumpra sua finalidade.
Imagine um mecanismo que compara dois documentos XML.
Dependendo dos requisitos, ele pode precisar considerar a hierarquia dos elementos, os atributos, os valores e diferentes formas de representar informações equivalentes.
Essas preocupações fazem parte do problema de comparação que a aplicação se propõe a resolver. Não podemos simplesmente ignorá-las quando são necessárias para produzir resultados corretos.
Já a complexidade acidental está relacionada às dificuldades introduzidas pela maneira como escolhemos construir a solução.
Considere que, antes mesmo de existir a necessidade de processar outros formatos, decidimos desenvolver uma estrutura genérica com múltiplas estratégias configuráveis, mecanismos de extensão e diversas interfaces para suportar qualquer tipo de documento.
Embora essa arquitetura possa oferecer possibilidades futuras, ela também exigirá componentes adicionais, testes, documentação e esforço de manutenção.
Se essas capacidades não forem necessárias, parte da complexidade introduzida poderá não oferecer benefícios proporcionais.
Isso não significa que qualquer abstração seja um problema. Uma separação pode aumentar a quantidade de componentes e, ao mesmo tempo, reduzir a dificuldade de compreender e modificar o sistema. Da mesma forma, uma implementação com poucos arquivos pode concentrar responsabilidades e apresentar elevado custo de manutenção.
Portanto, simplicidade não deve ser confundida com escrever menos código. Uma solução arquiteturalmente simples é aquela que utiliza a complexidade necessária para atender ao problema, evitando estruturas cujos benefícios não justificam seus custos.
Essa distinção nos conduz ao último ponto de nossa investigação: os riscos das abstrações prematuras.
Abstrações prematuras: quando a reutilização aumenta o acoplamento
A reutilização de código é frequentemente apresentada como uma prática desejável no desenvolvimento de software.
Entretanto, reutilizar uma implementação não significa necessariamente reutilizar uma responsabilidade.
Retomemos, pela última vez, o exemplo das funcionalidades de sanitização e comparação XML.
Imagine que, durante a implementação inicial, percebemos semelhanças entre as operações utilizadas pelas duas ferramentas.
Para evitar duplicação, decidimos criar um mecanismo genérico que concentre o processamento de ambas.
Inicialmente, a solução pode parecer eficiente.
Entretanto, conforme os requisitos evoluem, começam a surgir necessidades específicas.
O sanitizador precisa aplicar novas regras de tratamento sobre determinados elementos. O comparador precisa modificar sua representação de diferenças para considerar estruturas mais complexas.
Como ambos dependem do mecanismo compartilhado, essas necessidades podem exigir a introdução de configurações adicionais, condições especiais e comportamentos que interessam apenas a uma das funcionalidades.
O mecanismo originalmente criado para simplificar a reutilização começa a acumular responsabilidades diferentes.
Uma alteração destinada ao comparador pode exigir novos testes de regressão para o sanitizador, mesmo que suas regras não tenham sido modificadas.
Nesse cenário, a abstração não eliminou a complexidade. Ela deslocou responsabilidades distintas para um componente compartilhado.
Esse é um dos riscos da abstração prematura. Ao generalizar antes de compreender suficientemente os comportamentos envolvidos, podemos estabelecer relações que dificultam a evolução independente.
Semelhança não significa responsabilidade comum
Duas implementações podem apresentar código semelhante sem necessariamente representar o mesmo conceito.
Considere duas funções que percorrem elementos XML.
Uma delas procura informações sensíveis para aplicar tratamentos. A outra identifica diferenças entre estruturas.
Embora ambas realizem percursos sobre documentos, os motivos que levam à sua modificação podem ser completamente diferentes.
Se compartilharmos todo o mecanismo apenas pela semelhança inicial, poderemos acoplar comportamentos que deveriam permanecer separados.
Isso também não significa que a duplicação de código seja sempre preferível. Se identificarmos uma operação verdadeiramente comum, com comportamento consistente e motivos de mudança compartilhados, a reutilização pode reduzir esforço e inconsistências.
O desafio está em distinguir uma semelhança superficial de uma responsabilidade comum.
Não existe uma quantidade universal de implementações que determine o momento correto para criar uma abstração. O contexto, a estabilidade do comportamento, os requisitos e os custos envolvidos precisam orientar essa escolha.
Em determinadas situações, pode ser mais adequado manter implementações separadas até compreender melhor suas necessidades. Em outras, um contrato compartilhado pode ser necessário desde o início.
O pensamento arquitetural nos ajuda justamente a investigar essas possibilidades antes de estabelecer uma relação difícil de modificar.
Pensar arquiteturalmente é compreender consequências
Ao longo deste artigo, partimos de uma situação simples: duas funcionalidades que trabalham com documentos XML e a possibilidade de compartilhar seus mecanismos de processamento.
A partir desse exemplo, investigamos como responsabilidades diferentes podem estabelecer relações que influenciam a capacidade de evolução de um sistema.
Compreendemos que dependências são necessárias para permitir a colaboração entre componentes, mas sua natureza determina parte dos riscos que introduzem.
Também observamos como o acoplamento pode influenciar a propagação de mudanças e por que contratos e limites bem definidos ajudam a proteger responsabilidades contra detalhes desnecessários.
O Princípio da Inversão de Dependência apresentou uma maneira de organizar relações entre regras internas e implementações externas, demonstrando que controlar dependências envolve mais do que distribuir componentes entre camadas.
Entretanto, também percebemos que separar responsabilidades e introduzir abstrações possui custos. Uma estrutura excessivamente genérica pode se tornar tão difícil de manter quanto uma implementação fortemente acoplada.
Essa reflexão nos permite compreender que pensar arquiteturalmente não significa eliminar todas as dependências, criar o maior número possível de interfaces ou aplicar padrões indiscriminadamente.
Significa reconhecer como os componentes se relacionam, compreender quais responsabilidades precisam ser protegidas e investigar as consequências das alternativas disponíveis.
Uma dependência pode ser necessária e adequada. Uma abstração pode facilitar futuras mudanças. Uma implementação direta pode ser suficiente para determinado contexto.
Nenhuma dessas escolhas é automaticamente superior. Sua adequação depende das necessidades do sistema e das consequências que introduzem.
Talvez essa seja a principal mudança de perspectiva proporcionada pelo pensamento arquitetural: deixar de avaliar uma solução apenas por seu funcionamento imediato e começar a considerar como ela poderá influenciar a evolução da aplicação.
A pergunta deixa de ser apenas "essa implementação funciona?" e passa a incluir "quais relações estamos estabelecendo e o que acontecerá quando precisarmos modificá-las?".
Entretanto, essa reflexão também apresenta uma nova dificuldade.
Se diferentes abordagens podem resolver o mesmo problema e cada uma introduz benefícios, limitações e custos, como determinar qual alternativa é mais adequada?
É essa pergunta que nos conduz ao próximo artigo da série: Trade-offs: por que não existe arquitetura perfeita?
Nele, vamos investigar como comparar alternativas arquiteturais, compreender as compensações envolvidas e reconhecer por que uma boa decisão depende mais do contexto do que da sofisticação da solução escolhida.
Referências conceituais
- RICHARDS, Mark; FORD, Neal. Fundamentos da Arquitetura de Software: Uma Abordagem de Engenharia. Referência para pensamento arquitetural, características arquiteturais e análise de trade-offs.
- MARTIN, Robert C. Clean Architecture: A Craftsman's Guide to Software Structure and Design. Referência para separação de responsabilidades, limites arquiteturais e Princípio da Inversão de Dependência.
- BROOKS JR., Frederick P. No Silver Bullet: Essence and Accidents of Software Engineering (1987). Referência para a distinção entre complexidade essencial e acidental.