Introducción
Un patrón de diseño es una solución reutilizable a un problema recurrente en el diseño de software. No son fragmentos de código que copias directamente — son plantillas conceptuales que adaptas a tu contexto.
Los patrones surgieron del libro "Design Patterns: Elements of Reusable Object-Oriented Software" (Gang of Four, 1994) y desde entonces son el lenguaje común con el que los desarrolladores discuten arquitecturas.
Se dividen en tres categorías:
- Creacionales: cómo se crean los objetos (Singleton, Factory, Builder)
- Estructurales: cómo se componen los objetos (Adapter, Decorator, Facade)
- De comportamiento: cómo los objetos se comunican (Observer, Strategy, Command)
¿Qué vas a aprender en este lab?
- Singleton: garantizar una única instancia de una clase
- Factory Method: delegar la creación de objetos a subclases
- Abstract Factory: familias de objetos relacionados
- Builder: construir objetos complejos paso a paso
- Observer: notificar cambios a múltiples interesados
- Strategy: intercambiar algoritmos en tiempo de ejecución
Requisitos previos: POO sólida (clases, herencia, interfaces), genéricos y colecciones.
1. Singleton
Propósito: garantizar que una clase tenga exactamente una instancia y proporcionar un punto de acceso global a ella.
Cuándo usarlo: cuando un único objeto debe coordinar acciones en todo el sistema: logger, configuración de la aplicación, pool de conexiones, caché.
Implementación thread-safe con inicialización diferida
public class ConfiguracionApp {
// volatile garantiza visibilidad entre hilos
private static volatile ConfiguracionApp instancia;
private final String urlBaseDatos;
private final int maxConexiones;
// Constructor privado: nadie puede hacer new ConfiguracionApp()
private ConfiguracionApp() {
this.urlBaseDatos = System.getenv().getOrDefault("DB_URL", "jdbc:h2:mem:dev");
this.maxConexiones = 10;
}
// Double-checked locking
public static ConfiguracionApp getInstance() {
if (instancia == null) {
synchronized (ConfiguracionApp.class) {
if (instancia == null) {
instancia = new ConfiguracionApp();
}
}
}
return instancia;
}
public String getUrlBaseDatos() { return urlBaseDatos; }
public int getMaxConexiones() { return maxConexiones; }
}
ConfiguracionApp config1 = ConfiguracionApp.getInstance();
ConfiguracionApp config2 = ConfiguracionApp.getInstance();
System.out.println(config1 == config2); // true — misma instancia
System.out.println(config1.getUrlBaseDatos()); // jdbc:h2:mem:dev
Alternativa con enum (la más robusta en Java)
public enum Logger {
INSTANCE;
public void log(String mensaje) {
System.out.println("[LOG] " + mensaje);
}
}
// Uso:
Logger.INSTANCE.log("Aplicación iniciada");
El enum garantiza una sola instancia incluso frente a serialización y reflexión — casos extremos que rompen la implementación con volatile.
Antipatrón: cuándo NO usar Singleton
- Cuando necesitas múltiples configuraciones (tests vs producción)
- Cuando la instancia tiene estado que varía por usuario o por hilo
- Como sustituto de pasar dependencias: dificulta las pruebas unitarias
Para inyección de dependencias en producción, frameworks como Spring gestionan el ciclo de vida por ti con
@Component(singleton por defecto). El patrón manual es para código sin framework.
Ejercicio 1
Implementa un ContadorGlobal Singleton con métodos incrementar(), decrementar() y getValor(). Desde main, crea dos referencias con getInstance() y verifica que el contador compartido entre ambas refleja todos los cambios.
2. Factory Method
Propósito: definir una interfaz para crear objetos, pero dejar que las subclases decidan qué clase concreta instanciar.
Cuándo usarlo: cuando el código que usa el objeto no debe acoplarse a la clase concreta, o cuando las subclases deben poder cambiar el tipo de objeto que crean.
// Producto
public interface Notificacion {
void enviar(String mensaje);
}
// Productos concretos
public class NotificacionEmail implements Notificacion {
private final String destinatario;
public NotificacionEmail(String destinatario) {
this.destinatario = destinatario;
}
@Override
public void enviar(String mensaje) {
System.out.println("Email a " + destinatario + ": " + mensaje);
}
}
public class NotificacionSMS implements Notificacion {
private final String telefono;
public NotificacionSMS(String telefono) {
this.telefono = telefono;
}
@Override
public void enviar(String mensaje) {
System.out.println("SMS a " + telefono + ": " + mensaje);
}
}
public class NotificacionPush implements Notificacion {
private final String deviceToken;
public NotificacionPush(String deviceToken) {
this.deviceToken = deviceToken;
}
@Override
public void enviar(String mensaje) {
System.out.println("Push a " + deviceToken + ": " + mensaje);
}
}
// Factory
public class NotificacionFactory {
public static Notificacion crear(String tipo, String destino) {
return switch (tipo.toLowerCase()) {
case "email" -> new NotificacionEmail(destino);
case "sms" -> new NotificacionSMS(destino);
case "push" -> new NotificacionPush(destino);
default -> throw new IllegalArgumentException("Tipo desconocido: " + tipo);
};
}
}
// El código cliente no conoce las clases concretas
String tipo = "email"; // podría venir de configuración o entrada de usuario
String destino = "[email protected]";
Notificacion n = NotificacionFactory.crear(tipo, destino);
n.enviar("Tu pedido fue confirmado."); // Email a [email protected]: Tu pedido fue confirmado.
Ejercicio 2
Crea una Factory para distintos tipos de Descuento: DescuentoPorcentual (aplica un porcentaje), DescuentoFijo (resta un monto fijo) y SinDescuento (devuelve el precio original). La Factory recibe un String y devuelve la estrategia correcta.
3. Builder
Propósito: separar la construcción de un objeto complejo de su representación, permitiendo el mismo proceso de construcción para distintas representaciones.
Cuándo usarlo: cuando un constructor tiene más de 3-4 parámetros, cuando algunos son opcionales, o cuando el orden de configuración importa.
// Sin Builder — confuso con muchos parámetros
Persona p = new Persona("Julian", "Velandia", 25, "Colombia", "[email protected]", true, false);
// ¿Qué significan los dos booleanos del final?
public class Persona {
// Atributos obligatorios
private final String nombre;
private final String apellido;
// Atributos opcionales
private final int edad;
private final String pais;
private final String email;
private final boolean activo;
private final boolean admin;
// Constructor privado — solo el Builder puede crear instancias
private Persona(Builder builder) {
this.nombre = builder.nombre;
this.apellido = builder.apellido;
this.edad = builder.edad;
this.pais = builder.pais;
this.email = builder.email;
this.activo = builder.activo;
this.admin = builder.admin;
}
// Getters
public String getNombre() { return nombre; }
public String getApellido() { return apellido; }
public int getEdad() { return edad; }
public String getPais() { return pais; }
public String getEmail() { return email; }
public boolean isActivo() { return activo; }
public boolean isAdmin() { return admin; }
@Override
public String toString() {
return nombre + " " + apellido + " (" + email + ")";
}
// Clase Builder estática
public static class Builder {
// Obligatorios
private final String nombre;
private final String apellido;
// Opcionales con valores por defecto
private int edad = 0;
private String pais = "Colombia";
private String email = "";
private boolean activo = true;
private boolean admin = false;
public Builder(String nombre, String apellido) {
this.nombre = nombre;
this.apellido = apellido;
}
public Builder edad(int edad) { this.edad = edad; return this; }
public Builder pais(String pais) { this.pais = pais; return this; }
public Builder email(String email) { this.email = email; return this; }
public Builder activo(boolean v) { this.activo = v; return this; }
public Builder admin(boolean v) { this.admin = v; return this; }
public Persona build() {
if (nombre.isBlank() || apellido.isBlank()) {
throw new IllegalStateException("Nombre y apellido son obligatorios");
}
return new Persona(this);
}
}
}
// Código limpio y autodocumentado
Persona usuario = new Persona.Builder("Julian", "Velandia")
.edad(25)
.pais("Colombia")
.email("[email protected]")
.activo(true)
.build();
System.out.println(usuario); // Julian Velandia ([email protected])
// Solo con obligatorios
Persona minimo = new Persona.Builder("Ana", "García").build();
System.out.println(minimo); // Ana García ()
Ejercicio 3
Crea un Builder para una clase ConsultaSQL que construya strings SQL de forma segura:
tabla(String nombre)— obligatorioseleccionar(String... columnas)— por defecto"*"donde(String condicion)— opcionallimite(int n)— opcionalbuild(): String— devuelve la query completa
Ejemplo: SELECT nombre, edad FROM usuarios WHERE edad > 18 LIMIT 10
4. Observer
Propósito: definir una dependencia uno-a-muchos entre objetos, de forma que cuando uno cambia de estado, todos sus dependientes son notificados y actualizados automáticamente.
Cuándo usarlo: sistemas de eventos, UI reactiva, notificaciones en tiempo real, buses de eventos.
// Interfaz del observador
public interface Observador<T> {
void actualizar(T evento);
}
// Sujeto observable
public class EventBus<T> {
private final List<Observador<T>> observadores = new ArrayList<>();
public void suscribir(Observador<T> observador) {
observadores.add(observador);
}
public void desuscribir(Observador<T> observador) {
observadores.remove(observador);
}
public void publicar(T evento) {
for (Observador<T> obs : observadores) {
obs.actualizar(evento);
}
}
}
// Evento de dominio
public record PedidoCreado(int id, String producto, double precio) {}
// Observadores concretos
public class ServicioEmail implements Observador<PedidoCreado> {
@Override
public void actualizar(PedidoCreado evento) {
System.out.println("Email: pedido #" + evento.id() + " (" + evento.producto() + ") confirmado.");
}
}
public class ServicioInventario implements Observador<PedidoCreado> {
@Override
public void actualizar(PedidoCreado evento) {
System.out.println("Inventario: descontando stock de " + evento.producto());
}
}
public class ServicioAnalytics implements Observador<PedidoCreado> {
@Override
public void actualizar(PedidoCreado evento) {
System.out.println("Analytics: registrando venta de $" + evento.precio());
}
}
// Configuración
EventBus<PedidoCreado> bus = new EventBus<>();
bus.suscribir(new ServicioEmail());
bus.suscribir(new ServicioInventario());
bus.suscribir(new ServicioAnalytics());
// Publicar un evento — todos los observadores son notificados
bus.publicar(new PedidoCreado(1001, "Laptop", 1200.0));
// Email: pedido #1001 (Laptop) confirmado.
// Inventario: descontando stock de Laptop
// Analytics: registrando venta de $1200.0
Observer en Java estándar
Java incluye java.util.Observable (deprecated) y el patrón también aparece en:
ActionListeneren Swing- Eventos de DOM en JavaFX
@EventListeneren Spring
Ejercicio 4
Implementa un sistema de alertas de temperatura:
SensorTemperaturapublica eventosCambioTemperatura(double valor)- Tres observadores:
AlertaCritica(avisa si > 100°C),Registrador(guarda el historial),Termostato(activa el ventilador si > 30°C) - Simula 10 lecturas de temperatura con valores variados y verifica que cada observador reacciona correctamente
5. Strategy
Propósito: definir una familia de algoritmos, encapsular cada uno y hacerlos intercambiables. Strategy permite que el algoritmo varíe independientemente de los clientes que lo usan.
Cuándo usarlo: cuando tienes múltiples variantes de un algoritmo (ordenamiento, cálculo de precio, validación) y quieres poder cambiarlas en tiempo de ejecución o en la configuración.
// Interfaz de la estrategia
public interface EstrategiaEnvio {
double calcularCosto(double pesoKg, double distanciaKm);
String getNombre();
}
// Estrategias concretas
public class EnvioEstandar implements EstrategiaEnvio {
@Override
public double calcularCosto(double pesoKg, double distanciaKm) {
return pesoKg * 0.5 + distanciaKm * 0.1;
}
@Override
public String getNombre() { return "Estándar (5-7 días)"; }
}
public class EnvioExpress implements EstrategiaEnvio {
@Override
public double calcularCosto(double pesoKg, double distanciaKm) {
return (pesoKg * 0.5 + distanciaKm * 0.1) * 2.5;
}
@Override
public String getNombre() { return "Express (1-2 días)"; }
}
public class EnvioGratis implements EstrategiaEnvio {
@Override
public double calcularCosto(double pesoKg, double distanciaKm) {
return 0.0;
}
@Override
public String getNombre() { return "Gratis (membresía premium)"; }
}
// Contexto
public class CarritoCompras {
private EstrategiaEnvio estrategiaEnvio;
private double pesoTotal;
private double distanciaKm;
public CarritoCompras(double pesoTotal, double distanciaKm) {
this.pesoTotal = pesoTotal;
this.distanciaKm = distanciaKm;
this.estrategiaEnvio = new EnvioEstandar(); // por defecto
}
public void setEstrategiaEnvio(EstrategiaEnvio estrategia) {
this.estrategiaEnvio = estrategia;
}
public void mostrarCostoEnvio() {
double costo = estrategiaEnvio.calcularCosto(pesoTotal, distanciaKm);
System.out.printf("Envío %s: $%.2f%n", estrategiaEnvio.getNombre(), costo);
}
}
CarritoCompras carrito = new CarritoCompras(2.5, 150.0);
carrito.mostrarCostoEnvio(); // Envío Estándar (5-7 días): $16.25
carrito.setEstrategiaEnvio(new EnvioExpress());
carrito.mostrarCostoEnvio(); // Envío Express (1-2 días): $40.63
carrito.setEstrategiaEnvio(new EnvioGratis());
carrito.mostrarCostoEnvio(); // Envío Gratis (membresía premium): $0.00
Strategy con lambdas (Java 8+)
Si la interfaz tiene un solo método, puedes evitar crear clases separadas:
@FunctionalInterface
public interface EstrategiaDescuento {
double aplicar(double precio);
}
// Definidas como lambdas
EstrategiaDescuento sinDescuento = precio -> precio;
EstrategiaDescuento descuento10 = precio -> precio * 0.90;
EstrategiaDescuento descuento2x1 = precio -> precio / 2;
double precio = 100.0;
System.out.println(sinDescuento.aplicar(precio)); // 100.0
System.out.println(descuento10.aplicar(precio)); // 90.0
System.out.println(descuento2x1.aplicar(precio)); // 50.0
6. Cuándo usar cada patrón
| Patrón | Problema que resuelve | Señal de alarma para usarlo |
|---|---|---|
| Singleton | Una sola instancia global | Logger, configuración, caché |
| Factory | El new acoplado a clases concretas |
if/else o switch para crear objetos |
| Builder | Constructor con muchos parámetros opcionales | Más de 4 parámetros, algunos opcionales |
| Observer | Notificar cambios a múltiples objetos | Eventos, actualizaciones en tiempo real |
| Strategy | Múltiples variantes de un algoritmo | if/else para elegir cómo hacer algo |
7. Errores Comunes
Error 1 — Aplicar patrones donde no hacen falta
// Singleton para una clase de utilidades sin estado
public class MathUtils {
private static MathUtils instance;
private MathUtils() {}
public static MathUtils getInstance() { ... }
public int sumar(int a, int b) { return a + b; }
}
// Solo métodos estáticos
public class MathUtils {
private MathUtils() {} // evita instanciación
public static int sumar(int a, int b) { return a + b; }
}
Los patrones resuelven problemas concretos. Aplicarlos sin necesidad agrega complejidad innecesaria.
Error 2 — Observer sin posibilidad de desuscribirse
// Sin desuscribir, los objetos nunca son recolectados por el GC (memory leak)
public class EventBus {
private List<Observador> obs = new ArrayList<>();
public void suscribir(Observador o) { obs.add(o); }
// Falta: desuscribir
}
// Siempre ofrece un mecanismo de desuscripción
public void desuscribir(Observador o) { obs.remove(o); }
Error 3 — Strategy con lógica de selección en el cliente
// El cliente decide qué estrategia usar con if/else — el patrón pierde sentido
if (usuario.esPremium()) {
carrito.setEstrategiaEnvio(new EnvioGratis());
} else if (pedido.esUrgente()) {
carrito.setEstrategiaEnvio(new EnvioExpress());
}
// Encapsula la selección en un método o Factory
EstrategiaEnvio estrategia = EstrategiaEnvioFactory.seleccionar(usuario, pedido);
carrito.setEstrategiaEnvio(estrategia);
Resumen
| Patrón | Categoría | Palabra clave |
|---|---|---|
| Singleton | Creacional | Una sola instancia |
| Factory Method | Creacional | Delegar la creación |
| Builder | Creacional | Construir paso a paso |
| Observer | Comportamiento | Notificar cambios |
| Strategy | Comportamiento | Intercambiar algoritmos |
Próximos pasos
Con los patrones de diseño ya tienes las herramientas para escribir código profesional. Los siguientes temas te llevarán al desarrollo de aplicaciones completas:
- Spring Boot: DI, MVC, Data JPA — todos usan patrones que ya conoces
- Arquitectura limpia: Hexagonal, CQRS, Event-Driven
- Pruebas unitarias: JUnit 5 + Mockito — verifica que tus patrones funcionan en aislamiento
- APIs REST: construye servicios con los fundamentos sólidos de este lab