Volver a Labs
Avanzado

Patrones de Diseño en Java

3–4 horas Actualizado Junio 2026
Java Design Patterns Arquitectura Backend

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) — obligatorio
  • seleccionar(String... columnas) — por defecto "*"
  • donde(String condicion) — opcional
  • limite(int n) — opcional
  • build(): 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:

  • ActionListener en Swing
  • Eventos de DOM en JavaFX
  • @EventListener en Spring

Ejercicio 4

Implementa un sistema de alertas de temperatura:

  • SensorTemperatura publica eventos CambioTemperatura(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