Disclaimer
Este texto foi inicialmente concebido pela IA Generativa em função da transcrição de uma live do Dev Eficiente. Se preferir acompanhar por vídeo, é só dar o play.
Introdução
Quando falamos sobre arquitetura em camadas no desenvolvimento de software, muitas vezes nos perdemos em discussões sobre qual arquitetura específica seguir - Clean Architecture, Arquitetura Hexagonal ou qualquer outra que remeta a camadas de cebolas :). Mas e se eu te dissesse que todas essas arquiteturas deste tipo se resumem a uma ideia básica: indireção. Neste post, vamos explorar como usar indireções de forma pragmática para proteger nosso software de mudanças indesejadas.
O Princípio Básico: Indireção
A ideia central de qualquer arquitetura em camadas é criar indireções para se isolar de situações, tecnologias ou códigos aos quais você não quer ficar acoplado. Isso não é algo novo ou exclusivo da Clean Architecture - é um princípio fundamental da vida.
Um exemplo cotidiano: imagine que você precisa tirar um passaporte. Você tem duas opções: entender todos os meandros do processo na embaixada ou contratar um despachante. O despachante é sua camada de indireção - você só conversa com ele, e ele sabe como tudo funciona. Se algo mudar no processo, quem se adapta é o despachante, não você.
Os Princípios da Arquitetura em Camadas
A Regra da Dependência
O primeiro princípio fundamental é que a camada externa(mais específica) sempre aponta para a camada interna (mais transversal). A camada interna nunca deve conhecer diretamente a camada externa. Isso não é exclusivo da Clean Architecture - qualquer arquitetura em camadas segue esse princípio.
Importante notar que não existe obrigatoriedade de que uma camada só fale com a imediatamente abaixo. Por exemplo, sua camada de adaptadores pode falar diretamente com a camada de entidades, pulando a camada de casos de uso quando necessário.
A Inversão de Dependência
Quando a camada interna precisa se comunicar com a externa, usamos o princípio da inversão de dependência. Criamos uma interface(qualquer mecanismo de polimorfismo) na camada interna e fazemos a camada externa implementá-la.
As Características Desejáveis
De acordo com o post sobre Arquitetura Limpa de Bob Martin, ao seguir esses princípios, seu software deveria apresentar as seguintes características:
- Independente de Framework: Trocar de framework não deveria afetar sua lógica de negócio
- Testável: Você consegue testar sua lógica isoladamente
- Independente de UI: A entrada pode vir via web, console, job, gRPC, etc.
- Independente de Banco de Dados: Não apenas trocar de PostgreSQL para MySQL, mas poder mudar de relacional para NoSQL
- Independente de Agentes Externos: Sua lógica não muda quando um agente externo muda
Aplicando na Prática
Vamos analisar um código de uma biblioteca virtual, especificamente a criação de um exemplar de livro. Começamos com um código completamente acoplado ao framework Spring:
Passo 1: Criando o Primeiro Caso de Uso
O primeiro passo é criar uma indireção para começar a isolar a lógica do framework. Criamos um caso de uso para o novo exemplar, mas inicialmente ele ainda referencia classes do Spring como ResponseEntity
e EntityManager
.
@Service
public class NovoExemplarCasoDeUso {
@Autowired
private EntityManager manager;
@Autowired
private LivroRepository livroRepository;
@Autowired
private ExemplarRepository exemplarRepository;
public ResponseEntity<?> executa(String isbn, NovoExemplarRequest request) {
// Lógica ainda acoplada ao framework
// ResponseEntity é do Spring
// EntityManager é da JPA
// Repositories estão acoplados ao Spring Data
}
}
Passo 2: Removendo Acoplamentos Diretos
Começamos removendo o acoplamento com ResponseEntity
. O caso de uso agora retorna um Optional<Exemplar>
- se conseguiu criar, retorna o exemplar; se não, retorna empty. O controller adapta esse retorno para a resposta HTTP apropriada. Uma outra possibilidade seria retornar um tipo inspirado na abstração Either.
// No caso de uso - sem ResponseEntity
public Optional<Exemplar> executa(String isbn, NovoExemplarRequest request) {
Optional<Livro> possivelLivro = livroRepository.findByIsbn(isbn);
if (possivelLivro.isEmpty()) {
return Optional.empty();
}
Livro livro = possivelLivro.get();
Exemplar novoExemplar = new Exemplar(livro, request.getTipo());
exemplarRepository.save(novoExemplar);
return Optional.of(novoExemplar);
}
// No controller - adaptando o retorno
@PostMapping("/livros/{isbn}/exemplares")
public ResponseEntity<?> novo(@PathVariable String isbn,
@RequestBody NovoExemplarRequest request) {
Optional<Exemplar> possivelExemplar = casoDeUso.executa(isbn, request);
//adaptando para a saída http adequada
return possivelExemplar
.map(exemplar -> ResponseEntity.ok(Map.of("id", exemplar.getId())))
.orElse(ResponseEntity.notFound().build());
}
Passo 3: Inversão de Dependência para Parâmetros
Perceba que ainda passamos uma referência para a classe que mapeia os dados do payload da request, criando uma dependência da camada mais interna com a mais externa.
Para remover este acoplamento, vamos inverter a dependência. Criamos uma interface InformacaoNovoExemplar
na camada de caso de uso. A classe de request da camada de adaptadores implementa essa interface (invertendo a dependência). Agora o caso de uso só conhece a interface, mantendo-se desacoplado.
// Interface na camada de caso de uso
public interface InformacaoNovoExemplar {
TipoExemplar getTipo();
}
// Caso de uso agora recebe a interface
public Optional<Exemplar> executa(String isbn, InformacaoNovoExemplar informacao) {
// ... lógica usando informacao.getTipo()
}
// Request na camada de adaptadores implementa a interface
public class NovoExemplarRequest implements InformacaoNovoExemplar {
private TipoExemplar tipo;
@Override
public TipoExemplar getTipo() {
return tipo;
}
}
// Controller continua passando o request normalmente
@PostMapping("/livros/{isbn}/exemplares")
public ResponseEntity<?> novo(@PathVariable String isbn,
@RequestBody NovoExemplarRequest request) {
// O request é passado, mas o caso de uso só enxerga como InformacaoNovoExemplar
Optional<Exemplar> possivelExemplar = casoDeUso.executa(isbn, request);
// ...
}
Uma decisão importante foi sobre qual método da classe de request a gente deveria expor na interface. Só extraímos o método que constrói o objeto do tipo da entidade, que era o único necessário.
Passo 4: Limpeza Final
Por fim, até a anotação @Service
pode ser substituída por um estereótipo customizado @CasoDeUso
, deixando o código completamente livre de referências diretas ao framework.
// Criando nosso próprio estereótipo
@Component
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface CasoDeUso {
}
// Caso de uso final, livre de referências ao framework
// @Valid referência a especificação, achei ok. Mas pode ser removida também
@CasoDeUso
public class NovoExemplarCasoDeUso {
private LivroRepository livroRepository;
private ExemplarRepository exemplarRepository;
public NovoExemplarCasoDeUso(LivroRepository livroRepository,
ExemplarRepository exemplarRepository) {
this.todosOsLivros = livroRepository;
this.todosOsExemplares = exemplarRepository;
}
@Valid
public Optional<Exemplar> executa(@NotBlank String isbn,
@Valid InformacaoNovoExemplar informacao) {
Optional<Livro> possivelLivro = livroRepository.findByIsbn(isbn);
return possivelLivro.map(livro -> {
Exemplar novoExemplar = new Exemplar(livro, informacao.getTipo());
return exemplarRepository.save(novoExemplar);
});
}
}
O Trade-off das Entidades
Um ponto importante: mantemos as entidades com metadados da JPA como @Entity
, @Id
, etc.
Criar mais uma camada de indireção aqui (ter uma entidade de domínio e outra classe que mapearia a ideia de entidade do banco de dados) pareceu exagero, pelo na visão deste que escreve. É importante avaliar se o benefício compensa a complexidade adicional.
@Entity
public class Exemplar {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private Livro livro;
@Enumerated(EnumType.STRING)
private TipoExemplar tipo;
public Exemplar(Livro livro, TipoExemplar tipo) {
Assert.notNull(livro, "Livro não pode ser nulo");
Assert.notNull(tipo, "Tipo não pode ser nulo");
this.livro = livro;
this.tipo = tipo;
}
// getters necessários...
}
Note que ainda temos anotações JPA e usamos Assert
do Spring. Criar uma versão "pura" dessa entidade seria criar indireção desnecessária na maioria dos casos.
O argumento que geralmente é utilizado é que você quer ter liberdade de trabalhar com sua entidade de domínio livremente, sem nenhuma influência de tecnologias extras. Se por algum acaso isso estiver limitando de fato o progresso do seu projeto, basta que você dê o próximo passo e separe o código da entidade de domínio e o código de uma classe que mapeia tal entidade para uma entidade de banco de dados.
Um Exemplo de Influência da Tecnologia na Entidade
Além das dependências explícitas (imports, anotações), existem dependências implícitas. Por exemplo, quando o Spring Data JPA chama o método save()
, internamente ele acessa o getter do atributo marcado com @Id
. Essas dependências são mais difíceis de identificar e isolar.
Integrando com CDD (Cognitive-Driven Development)
Mesmo seguindo uma arquitetura em camadas, você pode aplicar métricas do CDD para controlar a complexidade. Por exemplo, estabelecendo limites de pontos de acoplamento contextual para seus casos de uso. Se um caso de uso ultrapassa o limite estabelecido, é hora de reorganizar ou dividir a lógica.
Exemplo de Cálculo de Pontos
Vamos aplicar uma métrica simples ao nosso caso de uso:
- Acoplamento com classes/interfaces do projeto: 1 ponto cada
- Passagem de função como argumento: 1 ponto
- Branch de código: 1 ponto
- Limite estabelecido: 7 pontos
@CasoDeUso
public class NovoExemplarCasoDeUso {
private final LivroRepository livroRepository; // 1 ponto
private final ExemplarRepository exemplarRepository; // 1 ponto
public Optional<Exemplar> executa(String isbn,
InformacaoNovoExemplar informacao) { // 1 ponto
Optional<Livro> possivelLivro = livroRepository.findByIsbn(isbn); // 1 ponto
return possivelLivro.map(livro -> { // 1 ponto (função como argumento)
Exemplar novoExemplar = new Exemplar(livro, informacao.getTipo()); // 1 ponto
return exemplarRepository.save(novoExemplar);
});
}
}
// Total: 6 pontos (dentro do limite de 7)
Se esse caso de uso crescer e ultrapassar o limite, seria hora de reorganizar ou extrair parte da lógica.
Conclusão
Arquitetura em camadas pode ser simplificada em criar indireções estratégicas. Você vai criando essas indireções e generalizando-as na medida que precisa do nível de abstração. Dominando os conceitos básicos de orientação a objetos, modelagem e polimorfismo, você consegue implementar qualquer tipo de arquitetura em camadas, pois todas falam da mesma coisa: criar isolamento através de indireções bem definidas.
O mais importante não é seguir uma arquitetura específica ao pé da letra, mas entender quando e onde criar indireções que tragam benefícios reais para a evolução do seu software.
Dev+ Eficiente
Se você gostou deste conteúdo, conheça a Jornada Dev + Eficiente, nosso treinamento focado em fazer com que você se torne uma pessoa cada vez mais capaz de entregar software que gera valor na ponta final, com máximo de qualidade e eficiência.
Acesse https://deveficiente.com/oferta-20-por-cento para conhecer tudo que oferecemos.
Top comments (0)