public interface DomainStore {
public <E> E save(E instance);
public <E> void delete(E instance);
public <E> QueryResult<E> query(Criteria<E> criteria);
}
Partimos da definição de Repositório como peça integrante do Domínio. Nesta definição as regras de pesquisa estabelecidas em cada Repositório fazem parte das regras do domínio tanto quanto as regras de validação do Validador ou as regras que integram os serviços do domínio. Note que poderíamos adotar uma outra definição onde as regras do Repositório não fazem parte do domínio. Isto nos levaria a outra configuração de arquitetura.
O ponto de extensão nesta arquitetura é o DomainStore
. Esta interface consome e retorna objecto do modelo do domínio sem termos que nos preocupar em como as operações são realmente efetuadas.
Para manter este isolamento utilizamos o padrão Query Object na forma da classe Criteria
. O Criteria
é o conjunto de todos os criterios de pesquisa que são separados do objeto que monta esses critérios (Repository) e do objeto responsável por executar esses critérios e retornar os resultados (DomainStore).
Adicionalmente colocaremos a responsabilidade de controle do ciclo de vida da entidade pesquisada no repositório apenas delegando para o DomainStore o como esse controle é realmente executado.
A estratégia de persistência representa o objeto responsável pelo trabalho real de executar os critérios de pesquisa e operações CRUD. Eis um esqueleto do contrato deste objeto:
public interface DomainStore {
public <E> E save(E instance);
public <E> void delete(E instance);
public <E> QueryResult<E> query(Criteria<E> criteria);
}
A primeira coisa a reparar é que todos os métodos são fortemente tipados, mas o tipo depende apenas de onde o método é usado e não da classe DomainStore
. Basicamente estamos assegurando que o tipo do resultado é compatível com o do argumento. Na prática, esta classe aceita qualquer objeto e não depende realmente de nenhum outro tipo. A segunda coisa importante é que todas as pesquisas acontecem através dos objetos Criteria
e QueryResult
. O primeiro define o que queremos encontrar, o segundo encapsula o resultado.
Na definição do repositório como integrante do domínio, o repositório não precisa da declaração de um contrato (interface), até por que, cada repositório tem um contrato diferente do outro. Contudo, vamos definir uma classe abstrata para isolar algumas partes comuns a todos os repositórios.
Uma coisa que todos os repositórios que vamos construir, têm em comum, é o controle do ciclo da sua respetiva entidade. Além disso, todos eles irão delegar algumas funcionalidades à classe DomainStore
. O uso de uma classe abstrata irá facilitar isto também.
public abstract class AbstractRepository<E> {
private DomainStore domainStore;
public AbstractRepository (DomainStore domainStore){
this.domainStore = domainStore;
}
protected DomainStore domainStore(){
return domainStore;
}
public <E> E save( <E> instance){
return domainStore.save(instance);
}
public <E> void delete( <E> instance){
return domainStore.delete(instance);
}
}
Os métodos save
e delete
são essencialmente delegados à estratégia de persistência encapsulada no DomainStore
. Caso seja necessário, o repositório poderá sobrescrever esse comportamento, mas isso é raro sendo usado mais em ambiente de testes.
Vamos usar as classes acima para criar nosso primeiro repositório. Este será um repositório de senhas (Password
).
A senha conta com a associação entre um nome de usuário e uma palavra-passe devidamente encriptada (digest).
Ao trabalhar com senhas precisamos essencialmente de duas pesquisas. Uma que encontre a senha dado o nome do usuário e outra que encontre todas as senhas que irão expirar dentro de um certo periodo.
public class PasswordRepository extends AbstractRepository<Password> {
public PasswordRepository (DomainStore domainStore){
super(domainStore);
}
public Optional<Password> findUsernamePassword(String username){
Criteria<Password> criteria = CriteriaBuilder.search(Password.class)
.and("username").eq(username)
.all();
QueryResult<Password> result = domainStore().query(criteria);
return result.first();
}
public List<Password> findWillExpireInPeriod(LocalDate start, LocalDate end){
Criteria<Password> criteria = CriteriaBuilder.search(Password.class)
.and("expiration").bewteen(start,end)
.all();
QueryResult<Password> result = domainStore().query(criteria);
return result.all();
}
}
A classe de criteria é construída no padrão Query Object como a composição de objetos Criterion
. A classe CriteriaBuilder
serve apenas para prover uma forma fluente de compor o criterio de pesquisa. A implementação é simples,
mas demasiado longa para o escopo deste artigo. Para um exemplo de implementação, por favor consultar
a classe EntityCriteriaBuilder no pacote de critérios do MiddleHeaven
O ponto importante aqui é que tanto a classe Criteria
, assim como, a classe CriteriaBuilder
não dependem de mais nada. Especialmente não dependem do repositório, nem da estratégia de persistência nem do modelo das entidades.
Os métodos de pesquisa são simples e mostram exatamente o objetivo de encapsulamento do repositório. As pesquisas são realmente realizadas pela estratégia de persistência e retornam um objeto QueryResult
, o qual por sua vez permite retornar uma coleção dos objetos procurados,
ou apenas o primeiro item dessa coleção. Poderíamos usar alguma interface da API de coleções padrão do Java, mas nos traria várias desvantagens.
Primeiro, todas as coleções são mutáveis por natureza com métodos como retainAll
e addAll
. Isso nos obrigaria a usar mecanismos como Collections.unmodifiable()
ou a criar uma implementação especial dessa coleção. Por outro lado, o uso de um objeto diferente nos permite implementar padrões como Fast Lane Reader
e Paginator ou técnicas como lazy loading caso necessário, de uma forma mais simples e controlada,
sem nos preocuparmos com compatibilidade com a API de coleções.
A seguir implementaremos um repositório de usuários.O repositório de usuários é mais simples ainda. Precisamos de um método que encontre o usuário pelo nome, outro que o encontre pelo identificador de persistencia ( o id) e finalmente um que nos retorne todos os usuários que existem cadastrados (para podermos implementar listagens).
public class UserRepository extends AbstractRepository<User> {
public UserRepository (DomainStore domainStore){
super(domainStore);
}
public Optional<User> findByUsername(String username){
Criteria<User> criteria = CriteriaBuilder.search(User.class)
.and("username").eq(username)
.all();
QueryResult<User> result = domainStore().query(criteria);
return result.first();
}
public Optional<User> findByIdentifier(Long id){
Criteria<User> criteria = CriteriaBuilder.search(User.class)
.and("id").eq(id)
.all();
QueryResult<User> result = domainStore().query(criteria);
return result.first();
}
public List<User> findAll(){
Criteria<User> criteria = CriteriaBuilder.search(User.class).all();
QueryResult<User> result = domainStore().query(criteria);
return result.all();
}
}
O interessante aqui é o método findAll
porque ele cria um critério de pesquisa sem nenhuma restrição. Este método é tão simples e genérico que
o poderíamos adicionar diretamente em AbstractRepository
de forma genérica. O mesmo para o método findByIdentifier
.
public List<E> findAll()
Criteria<E> criteria = CriteriaBuilder.search(resolveGenericClass()).all();
QueryResult<E> result = domainStore().query(criteria);
return result.all();
}
public Optional<E> findByIdentifier(Long id){
Criteria<E> criteria = CriteriaBuilder.search(resolveGenericClass())
.and("id").eq(id)
.all();
QueryResult<E> result = domainStore().query(criteria);
return result.first();
}
private Class<E> resolveGenericClass () {
ParameterizedType parameterizedType = (ParameterizedType) getClass().getGenericSuperclass();
return (Class) parameterizedType.getActualTypeArguments()[0];
}
Embora o java faça errasure de tipos genéricos isso não se aplica quando o tipo é um parâmetro de uma classe abstrata, portanto, AbstractRepository
,
sendo abstrata, permite saber qual o tipo real que está sendo usado. O código que faz isso está no método resolveGenericClass
Em todos os sistemas temos necessidade de criar lógicas que controlem o fluxo da informação e ações do sistema. Em particular, no nosso sistema de exemplo precisamos de um serviço que avise os usuários que sua senha irá expirar. Isto implica em determinar quais senhas irão expirar, recolher o email do usuário e enviar o email de aviso.
O próximo código mostra como:
public class EmailExpirationWarningService implements ExpirationWarningService {
private EmailSendingService emailSendingService;
private PasswordRepository passwordRepository;
private UserRepository userRepository;
public SimpleExpirationWarningService(
EmailSendingService emailSendingService,
PasswordRepository passwordRepo,
UserRepository userRepo
){
this.emailSendingService = emailSendingService;
this.passwordRepository = passwordRepository;
this.userRepository = userRepository;
}
@Override
public void sendExpirationWarnings(){
// Regra: envia apenas aviso para quem terá sua senha experiada no periodo de 3 dias
// contando a partir de amanhã
LocalDate start = CalendarUtils.tomorrow();
LocalDate end = CalendarUtils.addDays(start, 3);
List<Password> passwords = passwordRepo.findWillExpireInPeriod(start, end);
for ( Password password : passwords ){
User user = userRepo.findByUsername(password.getUserName());
Email email = new Email();
email.setTo(user.getEmailAddress());
email.setMessage("A sua senha está prestes a expirar. Por favor, altere-a");
emailSendingService.sendAsync(email);
}
}
}
A lógica é clara e está totalmente contida em um método. Este serviço dependente do serviço de aplicação que envia emails e dos repositórios de senha e usuário.
Quando, e se, esta lógica mudar basta vir neste método e alterar a lógica. Em alternativa podemos criar outra implementação de ExpirationWarningService
que faça de outra forma.
A vantagem de ter dois objetos da mesma classe de serviços ( ExpirationWarningService
) é que podemos alterar de uma para outra e comparar ambas em relação a funcionalidade,
desempenho e/ou outras métricas.
Um serviço é construído no padrão Service através da definição de um contrato (interface) e várias implementações
(tantas quanto necessárias). O serviço é um ponto de extensão e modificação muito flexível e o uso de vários serviços simplifica a construção de testes ao mesmo tempo
que contribui para aumentar a velocidade com que os testes são executados. Por exemplo para testar se um e-mail foi enviado, podemos utilizar uma implementação
de EmailSendingService
que guarda todos os objetos email numa lista na memoria, no final do teste basta verificar que essa lista não está vazia e contém
os emails direcionados aos usuários certos.
Serviço de aplicação como EmailSendingService
podem ser reaproveitados entre aplicações, enquanto serviços de domínio como ExpirationWarningService
não podem.
Em uma aplicação de cadastro temos normalmente uma tela onde as instâncias dos objetos são listadas (com algum tipo de filtro) e uma outra onde podemos editar os atributos da entidade.
A leitura de dados não altera o estado do sistema. Consultas não precisam ser verificadas nem produzem efeitos secundários como envio de email. Por outro lado, operações de alteração sim precisam de validação e podem produzir efeitos secundários.
Para ações de pesquisa como a mencionada listagem podemos consultar o repositório diretamente. Não há quebra de camadas porque tanto o repositório quanto o serviço vivem na mesma camada: o domínio.Para ações de alteração precisamos ter maior controle. Temos validações a fazer, temos mecanismos e regras para implementar e precisamos garantir que estas alterações são transacionais. Para ações de alteração usamos um serviço de domínio.
A seguir está um exemplo de pesquisa e um outro de alteração. Para a pesquisa usaremos como exemplo um servlet mas pode substituir pela classe de action do seu framework favorito. Vamos supor também que o repositório de usuários foi devidamente injetado.
public void doGet(HttpServletRequest req, HttpServletResponse res){
// monta o objeto de filtro
UserFilter filter = new UserFilter();
filter.setActivo("true".equals(req.getParameer("ativo")));
filter.setPartialUsername(req.getParameer("username"));
// executa a pesquisa
Query<User> query = usuarioRepo.findByFilter(filter);
// disponibiliza os dados
req.setAttribute("list", query.fetchAll());
req.setAttribute("filter", filter);
}
A implementação do método findByFilter
é simplesmente:
public Query<User> findByFilter(UserFilter filter){
CriteriaBuilder<User> criteriaBuilder = CriteriaBuilder.search(User.class)
.and("ativo").eq(filler.getAtivo());
if(!filter.getPartialUsername().isEmpty()){
criteriaBuilder.aand("username").contains(filter.getPartialUsername());
}
return domainStore().query(criteriaBuilder.all());
}
O objecto CriteriaBuilder
é usado como qualquer outro objeto no padrão Builder, não necessariamente
temos que escrever o critério numa única instrução de código fluente.
Olhemos agora para a atualização. Também aqui supomos que o UserEditionService
foi injetado corretamente.
public void doPost(HttpServletRequest req, HttpServletResponse res){
// monta o objeto do request
User user = new User();
user.setId(req.getParameer("id").isEmpty() ? null : Integer.parseInt(req.getParameer("id")));
user.setActivo("true".equals(req.getParameer("ativo")));
user.setUsername(req.getParameer("username"));
user.setRealName(req.getParameer("nomeReal"));
// validação prévia de imput
UserValidator validator;
if(user.getId()==null){
validator = UserValidator.getValidatorForInsertion();
} else {
validator = UserValidator.getValidatorForEdition();
}
ValidationResult result = validator.validate(user);
if(!result.isValid(){
//termina com erro
req.setAttribute("validationResult", result);
return;
}
// executa
try{
userEditionService.edit(user);
//retorna normalmente
}catch (InvalidationException e){
//termina com erro
req.setAttribute("validationResult", e.getValidationResult());
}
}
A implementação do serviço seria mais ou menos assim:
public class SimpleUserEditionService implements UserEditionService {
UserRepository userRepo;
public SimpleUserEditionService (UserRepository userRepo){
this.userRepo = userRepo;
}
@Override
public void edit(User user) throws InvalidationException, IllegalArgumentException{
if(user ==null){
throw new IllegalArgumentException();
}
// validação
UserValidator validator;
if(user.getId()==null){
validator = UserValidator.getValidatorForInsertion();
} else {
validator = UserValidator.getValidatorForModification(userRepo);
}
ValidatioResult result = validator.validate(user);
if(!result.isValid(){
//termina com erro
throw new InvalidationException(result);
}
userRepo.save(user);
}
}
Pode reparar que usamos um validador diferente no caso de edição. Este validador depende do repositório para verificar duplicidades. Poderíamos ter utilizado este mesmo validador na classe de ação, mas estamos fazendo diferente para demonstrar que as logicas de validação de pré-validação são, em geral, diferentes.
Poderíamos ignorar o passo de pré-validação se garantirmos que todos os serviços de edição fazem uma validação interna, contudo a pré-validação é útil para quando queremos ir validando enquanto o usuário escreve. Útil com o uso de ajax, por exemplo, em que a validação não aconteça simultaneamente com a edição.
O mecanismo de validação é baseado em uma
API de validação que você mesmo pode criar. O mecanismo de validação é baseado em uma, especialmente
utilizando algum tipo de validador composto. Os métodos estáticos de UserValidator
devolvem um validador previamente montado para diferentes finalidades. Validações para
inserção são normalmente diferentes das de edição.
Esta configuração de arquitetura é bastante flexível e bastante amigável para testes unitários e de integração, já que simplesmente trocando a estratégia de persistência ,
trocando a implementação do DomainStore
, podemos fazer toda a aplicação funcionar apenas em memórias e usando dados controlados. De igual forma podemos simular serviços e repositórios.