Skip to content

5. Patrones de diseño

Un patrón de diseño (design pattern) es una solución con nombre a un problema de diseño que se repite. Tiene tres partes:

Problema → qué situación lo provoca
Contexto → bajo qué condiciones aplica
Solución → cómo se resuelve, de forma general, no una implementación específica

Un patrón de diseño no es una decoración para el código: es una solución ya probada a un problema que ya se repitió muchas veces antes que en tu proyecto.

FamiliaResuelveEjemplo típico
CreacionalCómo se crean objetos, sin exponer el detalle de construcciónFactory, Singleton
EstructuralCómo se organizan y relacionan clases y objetosRepository, Adapter, Facade
De comportamientoCómo se comunican y reparten responsabilidad los objetosStrategy, Observer

Un patrón no siempre es la mejor opción: aplicarlo donde no hace falta agrega complejidad sin beneficio. La pregunta correcta no es “¿qué patrón puedo usar?”. Es “¿qué problema tengo, y qué patrón lo resuelve?”.

Si tu problema es…Considera…Familia
Crear distintos tipos de un mismo objeto según una condición (por ejemplo, distintos tipos de notificación)FactoryCreacional
Desacoplar la lógica de negocio del acceso a datosRepositoryEstructural
Adaptar una librería externa a la interfaz que tu proyecto esperaAdapterEstructural
Elegir entre varios algoritmos o reglas intercambiables (por ejemplo, distintas formas de calcular un costo o una prioridad)StrategyDe comportamiento
Notificar a varias partes del sistema cuando algo cambiaObserverDe comportamiento

Ejemplos por familia: caso de uso, ventajas y desventajas

Section titled “Ejemplos por familia: caso de uso, ventajas y desventajas”

Los siguientes siete patrones son un punto de partida — no la lista completa del catálogo original (Gamma, Helm, Johnson y Vlissides documentan 23) — elegidos porque resuelven problemas que aparecen con frecuencia en un proyecto del tamaño del tuyo.

Problema que resuelveNecesitas crear distintos tipos de un mismo objeto según una condición, sin que el código que los usa tenga que conocer cada clase concreta
Caso de usoUn sistema que debe crear distintos tipos de notificación (correo, SMS, push) según la preferencia del usuario, o distintos tipos de reporte (PDF, Excel, HTML) a partir de los mismos datos
VentajasDesacopla el código que necesita el objeto de las clases concretas que lo construyen. Agregar un tipo nuevo (por ejemplo, notificación por WhatsApp) no obliga a modificar el código que ya funciona, solo a agregar una fábrica nueva
DesventajasPuede generar muchas clases pequeñas (una fábrica por tipo) si se aplica a problemas con pocas variantes. Agrega una capa de indirección que no siempre se justifica para dos o tres tipos fijos que nunca van a crecer

Implementación en ReservaFIC. Cuando se aprueba un préstamo, hay que notificar al estudiante por el canal que haya elegido (correo, SMS o push), sin que el código que aprueba el préstamo tenga que saber cómo se envía cada tipo de notificación:

app/Notificaciones/Notificador.php
interface Notificador
{
public function enviar(Prestamo $prestamo): void;
}
app/Notificaciones/NotificadorCorreo.php
class NotificadorCorreo implements Notificador
{
public function enviar(Prestamo $prestamo): void
{
Mail::to($prestamo->estudiante->correo)
->send(new PrestamoAprobado($prestamo));
}
}
app/Notificaciones/NotificadorFactory.php
class NotificadorFactory
{
public static function crear(string $canal): Notificador
{
return match ($canal) {
'correo' => new NotificadorCorreo(),
'sms' => new NotificadorSms(),
'push' => new NotificadorPush(),
default => throw new \InvalidArgumentException("Canal no soportado: {$canal}"),
};
}
}
// Uso, por ejemplo dentro de PrestamoService
$notificador = NotificadorFactory::crear($prestamo->estudiante->canal_preferido);
$notificador->enviar($prestamo);

El PrestamoService nunca escribe new NotificadorCorreo() ni conoce las clases concretas: solo conoce la interfaz Notificador y le pide a la fábrica que le entregue la implementación correcta. Agregar un canal nuevo (por ejemplo, WhatsApp) significa agregar una clase y un caso al match de la fábrica — no tocar PrestamoService.

Problema que resuelveGarantizar que exista una única instancia de una clase en toda la aplicación, y un punto de acceso global a ella
Caso de usoUna configuración compartida que se lee una sola vez y se reutiliza en toda la aplicación, o una conexión a un recurso que no tiene sentido duplicar
VentajasEvita crear múltiples instancias costosas de crear o que deben mantenerse sincronizadas. Controla el acceso a un recurso compartido
DesventajasIntroduce estado global oculto, lo que dificulta las pruebas unitarias (el resultado de una prueba puede depender del estado que dejó otra). Es fácil de sobreusar hasta convertirlo en un “objeto todopoderoso” que termina conociendo demasiado sobre el resto del sistema — en Laravel, buena parte de lo que “se sentiría” como Singleton ya lo resuelve el contenedor de servicios del framework, sin que necesites implementarlo tú mismo

Implementación en ReservaFIC. El número máximo de préstamos activos que puede tener un estudiante al mismo tiempo es una regla que se consulta desde varios lugares del sistema (al aprobar un préstamo, al mostrar el formulario, al generar reportes) y que debe leerse una sola vez, no releerse de la base de datos cada vez:

app/Configuracion/ConfiguracionPrestamos.php
class ConfiguracionPrestamos
{
private static ?ConfiguracionPrestamos $instancia = null;
private int $maximoPrestamosActivos;
private function __construct()
{
$this->maximoPrestamosActivos = config('reservafic.maximo_prestamos_activos', 3);
}
public static function obtener(): self
{
if (self::$instancia === null) {
self::$instancia = new self();
}
return self::$instancia;
}
public function maximoPrestamosActivos(): int
{
return $this->maximoPrestamosActivos;
}
}
// Uso, por ejemplo dentro de PrestamoService
if ($prestamosActivos >= ConfiguracionPrestamos::obtener()->maximoPrestamosActivos()) {
throw new LimiteDePrestamosExcedidoException();
}

El constructor es private: nadie puede escribir new ConfiguracionPrestamos() desde fuera de la clase, por lo que la única forma de obtener la instancia es ConfiguracionPrestamos::obtener() — y esa llamada siempre devuelve la misma instancia, ya construida.

Alternativa en Laravel. Para este caso concreto (un valor de configuración), Laravel ya resuelve el problema con config() y con singletons registrados en el contenedor de servicios ($this->app->singleton(...) en un service provider) — no necesitas escribir la clase de arriba en un proyecto real. Se muestra aquí para que entiendas qué problema resuelve el patrón por dentro, incluso cuando el framework ya te lo da resuelto.

El problema. El PrestamoService documentado en 4. Arquitectura de software llama directamente a la consulta de base de datos. Si mañana cambian de motor de base de datos, o si quieren probar el Service sin tocar la base de datos real, tienen que modificar el Service.

El patrón. Repository (estructural): separa el Service de los detalles de cómo se accede a los datos. Se define una interfaz que declara qué operaciones necesita el Service (por ejemplo, “existe un conflicto de horario” y “crear un préstamo”), y una clase concreta que implementa esa interfaz usando el motor de base de datos elegido. El Service ya no sabe qué motor de base de datos existe por debajo — solo conoce la interfaz.

Caso de usoDesacoplar la lógica de negocio (el Service) de los detalles de cómo se accede a los datos (Eloquent, otra base de datos, incluso una API externa)
VentajasEl Service se puede probar sin una base de datos real, sustituyendo la interfaz por una implementación de prueba. Cambiar de motor de persistencia no obliga a tocar la lógica de negocio
DesventajasAgrega una capa adicional (interfaz + implementación) que, para un CRUD simple sin lógica de negocio real, puede ser más estructura de la que el problema necesita
Problema que resuelveDos interfaces incompatibles necesitan trabajar juntas: tienes una clase o librería ya existente cuya interfaz no coincide con la que tu código espera
Caso de usoIntegrar una librería externa de pagos o de envío de correo cuya forma de llamarse no coincide con la interfaz que ya usa tu Service
VentajasPermite reutilizar código o librerías externas sin modificarlas. Aísla el resto de tu proyecto de los detalles de una API externa específica
DesventajasAgrega una capa de indirección adicional. Si tienes que adaptar muchas librerías distintas, el número de adaptadores puede crecer y volverse difícil de mantener

Implementación en ReservaFIC. Retomando el NotificadorSms del ejemplo de Factory Method: la librería externa para enviar SMS tiene su propia interfaz (sendMessage), distinta de la interfaz Notificador que ya usa el resto del sistema (enviar). El Adapter conecta ambas sin modificar la librería externa:

// Librería externa — no la controla el equipo, no se debe modificar
class ClienteSmsExterno
{
public function sendMessage(string $to, string $body): array
{
// Lógica propia de la librería del proveedor de SMS
}
}
app/Notificaciones/NotificadorSms.php
class NotificadorSms implements Notificador
{
public function __construct(
private ClienteSmsExterno $cliente
) {}
public function enviar(Prestamo $prestamo): void
{
$this->cliente->sendMessage(
to: $prestamo->estudiante->telefono,
body: "Tu préstamo de {$prestamo->equipo->nombre} fue aprobado."
);
}
}

NotificadorSms es el adaptador: por fuera cumple la interfaz Notificador que espera NotificadorFactory (sección de Factory Method), y por dentro traduce esa llamada al método sendMessage de la librería externa. Si el equipo cambia de proveedor de SMS más adelante, solo se reescribe este adaptador — el resto del sistema sigue llamando a enviar() sin enterarse del cambio.

Problema que resuelveUn subsistema tiene varias clases que colaboran entre sí de forma compleja, y el código que las usa no debería necesitar conocer esa complejidad
Caso de usoExponer una sola clase ReporteService que internamente coordina varias clases especializadas en generar PDF, Excel y HTML, para que el controlador solo llame a un método
VentajasSimplifica el uso del subsistema desde fuera. Reduce el acoplamiento entre el código cliente y los detalles internos del subsistema
DesventajasSi la fachada empieza a acumular demasiada lógica propia (no solo coordinación), puede convertirse en un “God Object” que concentra responsabilidades que deberían seguir separadas

Implementación en ReservaFIC. El administrador de laboratorio necesita descargar un reporte de préstamos en distintos formatos. Cada formato tiene su propia clase generadora, pero el controlador no debe conocer los detalles de ninguna de las tres:

app/Reportes/ReporteService.php
class ReporteService
{
public function __construct(
private GeneradorPdf $pdf,
private GeneradorExcel $excel,
private GeneradorHtml $html,
) {}
public function generarReportePrestamos(string $formato, Collection $prestamos): string
{
return match ($formato) {
'pdf' => $this->pdf->generar($prestamos),
'excel' => $this->excel->generar($prestamos),
'html' => $this->html->generar($prestamos),
default => throw new \InvalidArgumentException("Formato no soportado: {$formato}"),
};
}
}
app/Http/Controllers/ReporteController.php
public function descargar(Request $request, ReporteService $reportes)
{
$prestamos = Prestamo::activos()->get();
return $reportes->generarReportePrestamos($request->query('formato', 'pdf'), $prestamos);
}

El controlador llama a un solo método de una sola clase. No necesita saber que, por dentro, ReporteService coordina tres generadores distintos — esa complejidad queda oculta detrás de la fachada.

Problema que resuelveNecesitas elegir entre varios algoritmos o reglas intercambiables para resolver la misma tarea, y quieres evitar un bloque gigante de condicionales (if/else o switch) para decidir cuál usar
Caso de usoDistintas formas de calcular un descuento según el tipo de cliente, o distintas reglas de prioridad para atender una solicitud
VentajasPermite agregar una estrategia nueva sin modificar las que ya existen (ni el código que las usa). Evita condicionales largos y difíciles de mantener
DesventajasEl código que usa el patrón debe conocer las estrategias disponibles para poder elegir la correcta. Para dos opciones simples que nunca van a cambiar, puede ser más estructura de la que el problema justifica

Implementación en ReservaFIC. Cuando hay más solicitudes de préstamo que equipos disponibles, alguien tiene que decidir en qué orden se atienden. El criterio puede cambiar (por ejemplo, orden de llegada durante el semestre normal, prioridad para tesistas durante el cierre de tesis) sin que el resto del sistema cambie:

app/Priorizacion/EstrategiaPriorizacion.php
interface EstrategiaPriorizacion
{
public function ordenar(Collection $solicitudes): Collection;
}
app/Priorizacion/PriorizacionPorOrdenDeLlegada.php
class PriorizacionPorOrdenDeLlegada implements EstrategiaPriorizacion
{
public function ordenar(Collection $solicitudes): Collection
{
return $solicitudes->sortBy('created_at');
}
}
app/Priorizacion/PriorizacionPorTesistas.php
class PriorizacionPorTesistas implements EstrategiaPriorizacion
{
public function ordenar(Collection $solicitudes): Collection
{
return $solicitudes->sortByDesc(fn ($solicitud) => $solicitud->estudiante->es_tesista);
}
}
app/Services/SolicitudPrestamoService.php
class SolicitudPrestamoService
{
public function __construct(
private EstrategiaPriorizacion $estrategia
) {}
public function listarPendientes(Collection $solicitudes): Collection
{
return $this->estrategia->ordenar($solicitudes);
}
}
// Uso: la estrategia se decide al construir el servicio, no dentro de él
$servicio = new SolicitudPrestamoService(new PriorizacionPorTesistas());
$pendientesOrdenadas = $servicio->listarPendientes($solicitudesPendientes);

SolicitudPrestamoService nunca pregunta “¿qué época del semestre es?” con un if: simplemente usa la estrategia que le inyectaron. Agregar un tercer criterio de priorización no requiere tocar esta clase, solo agregar una nueva implementación de EstrategiaPriorizacion.

Problema que resuelveVarias partes del sistema necesitan enterarse cuando algo cambia, sin que el objeto que cambia tenga que conocer a cada una de ellas de antemano
Caso de usoNotificar a los módulos de facturación, inventario y correo cada vez que se aprueba un préstamo, sin que esos módulos sepan unos de otros ni el PrestamoService tenga que llamarlos uno por uno de forma explícita
VentajasBajo acoplamiento entre quien emite el cambio y quien reacciona a él. Agregar un nuevo interesado en el evento no requiere modificar el código que lo genera
DesventajasEl orden en que reaccionan los distintos observadores puede no estar garantizado. Rastrear qué observador reaccionó a qué evento (para depurar un error) puede ser más difícil que seguir una llamada directa a un método

Implementación en ReservaFIC. Cuando se aprueba un préstamo, tres cosas distintas deben ocurrir: actualizar la disponibilidad del equipo, registrar el movimiento en una bitácora, y enviar la notificación al estudiante. PrestamoService no debería conocer a los tres módulos uno por uno:

app/Observadores/ObservadorDePrestamo.php
interface ObservadorDePrestamo
{
public function actualizar(Prestamo $prestamo): void;
}
app/Observadores/ActualizarInventario.php
class ActualizarInventario implements ObservadorDePrestamo
{
public function actualizar(Prestamo $prestamo): void
{
$prestamo->equipo->marcarComoPrestado();
}
}
app/Observadores/RegistrarEnBitacora.php
class RegistrarEnBitacora implements ObservadorDePrestamo
{
public function actualizar(Prestamo $prestamo): void
{
Bitacora::create([
'prestamo_id' => $prestamo->id,
'evento' => 'prestamo_aprobado',
]);
}
}
app/Observadores/EnviarNotificacionDePrestamo.php
class EnviarNotificacionDePrestamo implements ObservadorDePrestamo
{
public function actualizar(Prestamo $prestamo): void
{
NotificadorFactory::crear($prestamo->estudiante->canal_preferido)
->enviar($prestamo);
}
}
app/Services/PrestamoService.php
class PrestamoService
{
/** @var ObservadorDePrestamo[] */
private array $observadores = [];
public function agregarObservador(ObservadorDePrestamo $observador): void
{
$this->observadores[] = $observador;
}
public function aprobar(Prestamo $prestamo): void
{
$prestamo->aprobar();
foreach ($this->observadores as $observador) {
$observador->actualizar($prestamo);
}
}
}

PrestamoService::aprobar() no menciona inventario, bitácora ni notificaciones por su nombre: solo recorre una lista de observadores. Agregar un cuarto módulo interesado (por ejemplo, un reporte de métricas) significa agregar una clase e inscribirla — no modificar aprobar(). Nota además cómo este ejemplo reutiliza el NotificadorFactory de Factory Method: los patrones no se aplican aislados, se combinan cuando el problema lo pide.

Elegir un patrón “porque toca verlo” o porque suena avanzado, sin haber identificado antes un problema real de tu proyecto que ese patrón resuelva. Si no puedes escribir en una frase cuál es el problema, todavía no tienes un problema real que justifique aplicar un patrón.

  • Piensa en una funcionalidad de tu proyecto que podría necesitar, en el futuro, más de una forma de calcular algo (por ejemplo, distintos tipos de descuento o distintas reglas de prioridad). ¿Qué familia de patrón consideraste, y por qué?
  • ¿Qué complejidad adicional introduce el patrón Repository, y bajo qué condición esa complejidad vale la pena?

Practica primero el orden correcto (problema antes que patrón) en la Actividad 5 — Del problema al patrón: mapa de decisiones, y después aplica un patrón real a tu proyecto en el Laboratorio 3 — Aplicar un patrón de diseño.

  • Gamma, Helm, Johnson y Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software — catálogo original de las tres familias de patrones.
  • Freeman y Robson. Head First Design Patterns — lectura de entrada más accesible. Ver Referencias del bloque.

Con Git colaborativo, integración, arquitectura y un patrón de diseño aplicado, ya cuentas con todos los elementos del primer incremento del proyecto: continúa con 6. Primer incremento — qué debes producir.