Arquitetura empresarial
Separar operações da aplicação, regras do domínio e persistência, reconhecendo padrões e os seus custos.
Terminar a partida 7 exige validar o estado, calcular a pontuação final e guardar o resultado. Trocar Swing por uma API não deve mudar essas regras; trocar ficheiros por SQL não deve mudar a operação. MVC organiza a apresentação. As operações da aplicação, o domínio e a persistência precisam também de contratos próprios.
Camadas e dependências
Podemos distinguir apresentação, operações da aplicação, domínio e persistência. Uma camada é um grupo de responsabilidades, não necessariamente uma pasta ou um processo diferente.
A apresentação recebe o pedido. Uma operação da aplicação coordena o caso de uso. O domínio valida a mudança. Um adaptador persiste o resultado. Os detalhes de SQL ou ficheiros não precisam de entrar nas entidades do domínio.
No caso terminar a partida 7, com entidade, serviço e repositório, a entidade protege a regra:
class Partida {
enum Estado { EM_CURSO, TERMINADA }
private Estado estado = Estado.EM_CURSO;
private int pontos;
Partida(int pontos) { this.pontos = pontos; }
void terminar() {
if (estado != Estado.EM_CURSO) {
throw new IllegalStateException("partida ja terminada");
}
if (pontos < 0) pontos = 0;
estado = Estado.TERMINADA;
}
boolean terminada() { return estado == Estado.TERMINADA; }
int getPontos() { return pontos; }
}
O serviço coordena procura, operação e persistência, distinguindo ausência de falha:
class TerminarPartida {
private final Partidas partidas;
TerminarPartida(Partidas partidas) { this.partidas = partidas; }
String terminar(long id) {
Partida partida = partidas.procurarPorId(id)
.orElseThrow(() -> new IllegalArgumentException("partida inexistente"));
partida.terminar();
partidas.guardar(partida);
return "partida " + id + ": " + partida.getPontos() + " pontos";
}
}
Compara percursos da mesma operação:
| Procura | Terminar? | Apresentação |
|---|---|---|
| Partida 7 em curso, com 120 pontos | Sim, termina e pede guardar. | partida 7: 120 pontos, se a escrita terminar. |
| Consulta válida sem partida 7 | Não, não há entidade. | Exceção de ausência. |
| Falha de acesso ao armazenamento | Não, a procura falhou. | Erro de acesso, nunca ausência confirmada. |
| Partida 7 já terminada | Não, a regra rejeita. | Exceção de estado, sem nova escrita. |
A ausência lança IllegalArgumentException; a tentativa de terminar de novo lança IllegalStateException sem chamar guardar, e são falhas distintas de uma consulta que não conseguiu aceder ao armazenamento.
Em memória, HashMap<Long, Partida> pode servir de fake; em SQL, o mapper traduz linhas em entidades válidas. Os excertos mostram contratos, não uma implementação completa da persistência, e os erros de acesso precisam de ser traduzidos ou declarados conforme a implementação escolhida.
chamadas em execução: Apresentacao -> TerminarPartida -> Partida, Partidas
dependências de código: TerminarPartida --> Partidas; PartidasMemoria --> Partidas; nada no domínio importa SQL
Uma Identity Map no âmbito da operação devolve a mesma instância nas duas leituras da partida 7, evitando dois objetos divergentes; não torna as escritas atómicas nem impede outra operação de alterar a mesma linha, pois isso pertence à transação.
Uma falha depois da alteração em memória e antes de guardar exige uma política; não basta dividir o código em camadas para tornar a operação atómica, por isso é preciso definir o que fica confirmado e o que pode ser repetido.
Transaction Script e Domain Model
Transaction Script organiza uma operação como um procedimento que recebe dados, aplica regras e escreve o resultado. Para operações simples e independentes, pode ser claro e direto. Se as mesmas regras surgem em muitos procedimentos, a duplicação torna mudanças mais difíceis.
Domain Model coloca estado e comportamento em objetos que representam conceitos do domínio. Uma Partida oferece terminar e protege o seu estado, em vez de qualquer cliente alterar um campo público. É útil quando as regras e relações precisam de evoluir em conjunto.
A diferença não é usar classes ou funções: um Transaction Script também pode ser um método de uma classe. Pergunta onde ficam as regras e quem protege os invariantes.
Para uma pontuação lida e mostrada sem regras adicionais, um procedimento pequeno pode chegar. Para uma partida com estados, penalizações e condições de encerramento, um modelo com operações próprias pode tornar o contrato mais claro.
Service Layer
Uma Service Layer define as operações da aplicação, como terminarPartida(id). Coordena o trabalho e pode definir a fronteira transacional. É um ponto de entrada para diferentes interfaces, como uma janela e uma API.
Não precisa de absorver toda a lógica do domínio. Se um serviço calcula manualmente tudo através de getters e setters, as entidades podem ter perdido a responsabilidade pelos seus invariantes.
O serviço pode delegar a validação em Partida, usar um repositório e devolver um resultado adequado à apresentação. Assim, trocar Swing por outra interface não exige reescrever o caso de uso.
Active Record e Data Mapper
Active Record combina um objeto que representa uma linha com operações de persistência e, por vezes, regras desse registo. Um método resultado.guardar() faz o objeto conhecer a forma de guardar. Pode simplificar operações centradas em dados, mas liga o objeto à persistência.
Data Mapper coloca a tradução entre objetos e armazenamento num componente separado. O domínio não precisa de conhecer tabelas nem SQL. O mapper lê valores, constrói objetos válidos e escreve a representação correspondente.
Escolhe pela complexidade e pela independência de que o domínio precisa. Criar uma classe chamada Mapper que só passa chamadas à entidade não separa a persistência se a entidade continua a emitir SQL.
Repository
Um Repository oferece acesso a objetos do domínio com uma interface semelhante a uma coleção. Pode ter procurarPorId e guardar. Esconde a tradução entre consultas e objetos, normalmente usando mappers por baixo.
Este excerto mostra um contrato que não expõe SQL:
interface Partidas {
java.util.Optional<Partida> procurarPorId(long id);
void guardar(Partida partida);
}
O tipo Partida pertence ao domínio. Uma implementação em memória serve para testes e uma implementação SQL trata o armazenamento. Optional.empty() representa ausência; uma falha de ligação não deve fingir que a partida não existe.
Não acrescentes um método genérico que recebe strings SQL ao contrato para contornar a fronteira. Se uma nova operação precisa de encontrar partidas em curso, acrescenta uma consulta com esse significado e decide a sua semântica.
Unit of Work e Identity Map
Uma Unit of Work acompanha objetos novos, alterados e removidos durante uma operação e coordena a escrita dessas mudanças. Ajuda a aplicar um conjunto coerente de alterações. Não substitui as garantias transacionais nem resolve sozinha acessos concorrentes.
Uma Identity Map conserva uma instância por identidade dentro de um âmbito. Se carregares duas vezes a partida 7 na mesma operação, evita obter dois objetos independentes que possam divergir. O âmbito deve ser definido, por exemplo uma operação, não um mapa global que cresce sem limite.
Identidade de uma entidade, como o identificador da partida, difere da igualdade de um valor, como duas posições com as mesmas coordenadas. Esta distinção orienta a forma de atualizar e persistir objetos.
DTO
Um Data Transfer Object, DTO, transporta os dados necessários numa fronteira. Pode reunir identificador e pontuação sem permitir que a interface altere diretamente a entidade. O DTO não tem de conter todas as operações do domínio.
Lazy Load
Lazy Load adia o carregamento até os dados serem necessários. Pode evitar trabalho, mas torna um acesso aparentemente simples numa operação de I/O. Se cada linha de uma lista dispara outra consulta, surge o problema de N+1 consultas. A ausência de uma consulta no construtor não significa ausência de custo.
Seguir um caso de uso
Para terminar a partida 7, a apresentação pede terminar(7) ao serviço, o que mostra onde vive cada regra e que padrão guarda cada parte. O serviço obtém a entidade através de Partidas, trata a ausência com erro explícito, pede partida.terminar(), persiste com guardar e devolve o texto com identificador e pontuação mostrado no excerto. Uma API poderia devolver um DTO com esses campos em vez do texto. Nos dois casos, a apresentação não altera os campos internos da entidade.
Testa separadamente a regra de encerramento e a coordenação. Num teste do serviço, uma implementação em memória pode confirmar qual partida foi alterada. Um teste de integração da persistência verifica que o registo realmente sobrevive a uma nova leitura. Uma verificação de chamadas a um mock não prova essa durabilidade.
Cada padrão responde a uma dificuldade diferente. Explica primeiro a regra e a fronteira que precisas, e só depois escolhe o nome do padrão que descreve a solução.
Exercícios
Quem protege o encerramento?
Terminar uma partida exige que esteja em curso e calcula uma penalização própria do seu estado. Num Domain Model, onde colocarias esta regra e o que deixarias ao serviço?
Pista
Mais uma pista
A entidade pode oferecer terminar(), em vez de setters
para estado e pontuação.
Solução
Partida.terminar() valida o estado e aplica a penalização preservando
invariantes. O serviço obtém a partida, chama a operação e persiste-a. A
apresentação pede o caso de uso e mostra o resultado.
Confere a tua resposta:
- A entidade Partida protege a transição e calcula a regra.
- O serviço coordena procura, operação e persistência.
- A apresentação recebe o resultado sem alterar campos internos.
Erros frequentes
Não coloques toda a regra num serviço que altera livremente campos da entidade.
O que prova que foi guardado?
Um teste verifica uma chamada a
repositorio.guardar(partida) num mock. Qual afirmação fica
demonstrada por esse teste?
Pista
Mais uma pista
Solução
O teste prova o pedido do serviço ao repositório. Para provar a persistência, um teste de integração deve guardar no adaptador real e voltar a ler num âmbito novo adequado.
- Os dados sobrevivem ao reinício da aplicação.. O mock não escreveu num armazenamento real.
- O serviço pediu ao colaborador que guardasse essa partida.. Essa é a interação observada.
- O SQL tem a tabela e os parâmetros corretos.. O adaptador SQL não foi exercitado.
Erros frequentes
Uma chamada verificada não demonstra o efeito de I/O de uma implementação que não correu.
Ausência não é falha de acesso
procurarPorId devolve Optional.empty quando não encontra a partida. Uma implementação captura qualquer erro de ligação e devolve também empty. Explica que distinção perdeu.
Pista
Mais uma pista
A apresentação pode dizer incorretamente que a partida não existe.
Solução
A implementação perdeu a distinção entre ausência de dados e impossibilidade de verificar os dados. Propaga um erro de acesso adequado ou usa um resultado explícito que preserve esse caso. Optional.empty continua a significar ausência após uma consulta válida.
Confere a tua resposta:
- Distingue um registo inexistente de uma falha de armazenamento.
- Não apresenta indisponibilidade como ausência confirmada.
- Propõe erro ou resultado explícito segundo o contrato.
Erros frequentes
Capturar todos os erros e devolver um valor normal pode esconder falhas e provocar decisões incorretas.
Duas cópias da mesma entidade
Durante uma operação, duas consultas carregam a partida 7 em objetos A e B independentes. A primeira parte da operação altera A; a segunda consulta B e vê o estado antigo. Explica o que uma Identity Map resolve, qual deve ser o seu âmbito e por que não substitui uma transação ou a coordenação com outras operações.
Pista
O identificador é o mesmo, mas as referências podem apontar para objetos diferentes.
Mais uma pista
Conservar uma instância não impede outra operação de alterar a mesma linha na base de dados.
Solução
A Identity Map mantém uma instância por identificador durante a operação. A segunda consulta da partida 7 devolve A, por isso as duas partes observam as alterações desse objeto. Limita o mapa ao âmbito decidido e liberta-o no fim. Isto não torna várias escritas atómicas nem impede outra operação de atualizar a mesma partida. Essas garantias dependem da transação e da política de concorrência. Uma Unit of Work pode coordenar mudanças acompanhadas, mas também não fornece sozinha todas essas garantias.
Confere a tua resposta:
- Devolve a mesma instância para a mesma identidade dentro da operação.
- Define o âmbito e evita um mapa global ilimitado.
- Distingue consistência em memória de atomicidade e concorrência no armazenamento.
Erros frequentes
Não confundas identidade persistente da entidade com igualdade de valores nem uses a cache como prova de dados atuais noutras operações.