- Conceitos de Padrões de Design
Padrões de design representam soluções reutilizáveis para problemas recorrentes no desenvolvimento de software, consolidadas a partir de experiências práticas. Assim como estratégias militares clássicas, esses padrões oferecem abordagens validadas para desafios específicos em arquitetura de software.
Os padrões facilitam a reutilização de designs bem-sucedidos e tornam as soluções mais compreensíveis para outros desenvolvedores. Eles formam a base para software orientado a objetos reutilizável, ajudando a tomar decisões que preservam a manutenibilidade e extensibilidade do sistema.
Os padrões amplamente adotados são conhecidos como GoF (Gang of Four), referência ao livro Design Patterns: Elements of Reusable Object-Oriented Software de Gamma, Helm, Johnson e Vissides, que cataloga 23 padrões clássicos.
Esses padrões são classificados de duas formas principais:
- Por finalidade:
- Criacionais: focam na criação de objetos, desacoplando a instanciação do uso.
- Estruturais: lidam com a composição de classes ou objetos em estruturas maiores.
- Comportamentais: definem interações e responsabilidades entre objetos para completar tarefas.
- Por escopo:
- Padrões de classe: baseados em herança, relações estáticas definidas em tempo de compilação.
- Padrões de objeto: baseados em composição ou agregação, relações dinâmicas em tempo de execução.
Observação: O padrão Adaptador pode ocorrer em ambas as formas (classe e objeto).
- Princípios de Design Orientado a Objetos
Softwares frequentemente sofrem mudanças de requisitos. Um design inadequado pode dificultar modificações ou exigir reeescrita total. Dependências entre classes aumentam a complexidade; alterações em um componente podem afetar todos os que o utilizam.
Por exemplo, considere uma camada de serviço que depende de uma camada de acesso a dados:
// Entidade Artigo
public class Artigo {
private String titulo;
private String conteudo;
public Artigo() {}
public Artigo(String titulo, String conteudo) {
this.titulo = titulo;
this.conteudo = conteudo;
}
// Getters e setters omitidos por brevidade
@Override
public String toString() {
return "Artigo{titulo='" + titulo + "', conteudo='" + conteudo + "'}";
}
}
// Interface DAO para Artigo
public interface ArtigoDao {
void salvar(Artigo artigo);
}
// Implementação concreta do DAO
public class ArtigoDaoJdbc implements ArtigoDao {
private Logger logger = Logger.getLogger(ArtigoDaoJdbc.class);
@Override
public void salvar(Artigo artigo) {
logger.debug("Persistindo artigo no banco de dados");
String sql = "INSERT INTO artigos VALUES(?,?)";
Object[] params = {artigo.getTitulo(), artigo.getConteudo()};
int affected = executarUpdate(sql, params);
if (affected > 0) {
System.out.println("Artigo salvo com sucesso");
}
}
}
// Interface de serviço para Artigo
public interface ArtigoServico {
void adicionar(Artigo artigo);
}
// Implementação concreta do serviço
public class ArtigoServicoImpl implements ArtigoServico {
private ArtigoDao dao = FabricaDaoSimples.obterInstancia();
@Override
public void adicionar(Artigo artigo) {
dao.salvar(artigo);
}
}
Nesse cenário, ArtigoServicoImpl está fortemente acoplado a ArtigoDaoJdbc. Se a implementação do DAO precisar mudar, a classe de serviço também deve ser alterada, dificultando a reutilização e manutenção.
Para construir sistemas flexíveis, princípios de design orientado a objetos são essenciais:
- Princípio da Responsabilidade Única (SRP): Uma classe deve ter apenas um motivo para mudar, encapsulando uma única responsabilidade.
- Princípio Aberto/Fechado (OCP): Entidades de software devem estar abertas para extensão, mas fechadas para modificação. Novas funcionalidades devem ser adicionadas via extensão, não alteração de código existente.
- Princípio de Substituição de Liskov (LSP): Objetos de uma classe derivada devem ser substituíveis por objetos da classe base sem comprometer o comportamento do programa.
- Princípio de Inversão de Dependência (DIP): Módulos de alto nível não devem depender de módulos de baixo nível; ambos devem depender de abstrações. Injeção de dependência (via construtor ou setter) é uma técnica comum.
- Princípio de Segregação de Interface (ISP): Interfaces devem ser granulares e específicas, evitando que clientes dependam de métodos que não utilizam.
- Princípio de Demeter (LoD): Entidades devem minimizar o conhecimento sobre outras, reduzindo acoplamento através de encapsulamento.
- Princípio de Composição sobre Herança: Prefira composição (relações has-a) à herança (relações is-a) para reutilziação, pois oferece mais flexibilidade em tempo de execução.
- Padrão Método de Fábrica
O acoplamento direto entre ArtigoServicoImpl e ArtigoDaoJdbc pode ser resolvido delegando a criação do DAO para uma fábrica. Assim, a classe de serviço depende apenas da interface abstrata.
Uma abordagem simples é uma fábrica estática:
public class FabricaDaoSimples {
public static ArtigoDao obterInstancia() {
return new ArtigoDaoJdbc();
}
}
Para maior flexibilidade, a fábrica pode aceitar parâmetros:
public class FabricaDaoSimples {
public static ArtigoDao obterInstancia(String tipo) {
switch (tipo) {
case "jdbc": return new ArtigoDaoJdbc();
case "hibernate": return new ArtigoDaoHibernate();
case "mongodb": return new ArtigoDaoMongo();
default: throw new IllegalArgumentException("Tipo de DAO desconhecido: " + tipo);
}
}
}
No código do serviço, a dependência é injetada via setter:
public class ArtigoServicoImpl implements ArtigoServico {
private ArtigoDao dao;
public void setDao(ArtigoDao dao) {
this.dao = dao;
}
@Override
public void adicionar(Artigo artigo) {
dao.salvar(artigo);
}
}
// Exemplo de uso em teste
public class ArtigoServicoTeste {
@Test
public void testarAdicao() {
ArtigoDao dao = FabricaDaoSimples.obterInstancia("jdbc");
ArtigoServicoImpl servico = new ArtigoServicoImpl();
servico.setDao(dao);
Artigo artigo = new Artigo("Título de teste", "Conteúdo de teste");
servico.adicionar(artigo);
}
}
A fábrica simples funciona para casos com poucos produtos, mas viola o OCP quando novos tipos são adicionados. O padrão Método de Fábrica resolve isso introduzindo uma interface de fábrica abstrata:
// Interface de fábrica abstrata
public interface FabricaDao {
ArtigoDao criarDao();
}
// Fábrica concreta para JDBC
public class FabricaDaoJdbc implements FabricaDao {
@Override
public ArtigoDao criarDao() {
return new ArtigoDaoJdbc();
}
}
// Fábrica concreta para Hibernate
public class FabricaDaoHibernate implements FabricaDao {
@Override
public ArtigoDao criarDao() {
return new ArtigoDaoHibernate();
}
}
A classe de serviço agora depende da interface FabricaDao:
public class ArtigoServicoImpl implements ArtigoServico {
private FabricaDao fabrica;
public ArtigoServicoImpl(FabricaDao fabrica) {
this.fabrica = fabrica;
}
@Override
public void adicionar(Artigo artigo) {
ArtigoDao dao = fabrica.criarDao();
dao.salvar(artigo);
}
}
Este padrão permite adicionar novos produtos e fábricas sem alterar código existente, respeitando o OCP. Seu inconveniente é o aumento no número de classes.
- Padrão Proxy
O padrão Proxy fornece um substituto ou representante para outro objeto, controlando o acesso a ele. Exemplos incluem intermediários em transações imobiliárias ou serviços de brokerage.
Considere uma interface Comprador com um método analisar():
// Interface assunto
public interface Comprador {
String analisar();
}
// Implementação real
public class CompradorConcreto implements Comprador {
private Logger logger = Logger.getLogger(CompradorConcreto.class);
@Override
public String analisar() {
logger.debug("Análise in loco do imóvel");
return "Observações do comprador";
}
}
// Proxy estático que adiciona lógica antes e depois
public class ProxyImobiliaria implements Comprador {
private Logger logger = Logger.getLogger(ProxyImobiliaria.class);
private Comprador compradorAlvo;
public ProxyImobiliaria(Comprador alvo) {
this.compradorAlvo = alvo;
}
@Override
public String analisar() {
preparar();
String feedback = compradorAlvo.analisar();
finalizar();
return "Relatório: Feedback do comprador: " + feedback;
}
private void preparar() {
logger.debug("Agendamento de visitas");
logger.debug("Busca de imóveis disponíveis");
}
private void finalizar() {
logger.debug("Acompanhamento pós-visita");
logger.debug("Negociação com proprietário");
}
}
Para testar:
public class CompradorTeste {
@Test
public void testarAnalise() {
Comprador comprador = new ProxyImobiliaria(new CompradorConcreto());
String resultado = comprador.analisar();
System.out.println(resultado);
}
}
Proxies estáticos requerem código manual para cada método alvo, violando o OCP. Proxies dinâmicos resolvem isso usando reflexão.
Usando a API de Proxy Dinâmico do JDK:
// Invocador para proxy dinâmico
public class InvocadorImobiliaria implements InvocationHandler {
private Logger logger = Logger.getLogger(InvocadorImobiliaria.class);
private Object alvo;
public InvocadorImobiliaria(Object alvo) {
this.alvo = alvo;
}
@Override
public Object invoke(Object proxy, Method metodo, Object[] argumentos) throws Throwable {
preparar();
Object resultado = metodo.invoke(alvo, argumentos);
finalizar();
return "Relatório: Feedback do comprador: " + resultado;
}
// Métodos preparar() e finalizar() omitidos por brevidade
}
// Fábrica de proxy
public class FabricaProxyComprador {
public static Comprador criar(Comprador alvo) {
return (Comprador) Proxy.newProxyInstance(
alvo.getClass().getClassLoader(),
alvo.getClass().getInterfaces(),
new InvocadorImobiliaria(alvo)
);
}
}
O teste:
public class CompradorTeste {
@Test
public void testarProxyDinamico() {
Comprador comprador = FabricaProxyComprador.criar(new CompradorConcreto());
String resultado = comprador.analisar();
System.out.println(resultado);
}
}
O proxy dinâmico do JDK requer que o objeto alvo implemente interfaces. Para classes não baseadas em interfaces, frameworks como CGLIB podem ser utilizados.