El parcial es en computadora y evalúa tres cosas: diagnosticar, aplicar y justificar. Por eso cada ejercicio oculta el concepto hasta que vos lo nombres.
- Diagnosticá — leé el código y escribí qué smell, principio o patrón aplica. Sin mirar la respuesta.
- Refactorizá — abrí el problema en php.cesar.sh, escribí tu solución y ejecutala.
- Verificá — recién ahí revelá el diagnóstico correcto, la solución modelo y el porqué. Compará con lo tuyo y con lo que escribiste en el Paso 1.
Code Smells · KISS · DRY · YAGNI
Para repasar la teoría de cada smell: Lectura S1 M →
Dos métodos en OrderService calculan el total con descuento de la misma manera. Nadie notó la duplicación hasta que cambió la regla del 10 % y solo se actualizó un lugar.
class OrderService
{
public function getTotalForCustomer(Order $order): float
{
if ($order->customer->isMember()) {
return $order->subtotal * 0.90;
}
return $order->subtotal;
}
public function getTotalForInvoice(Order $order): float
{
if ($order->customer->isMember()) {
return $order->subtotal * 0.90; // mismo cálculo
}
return $order->subtotal;
}
} Mostrar pista
Si mañana el descuento cambia al 15 %, ¿cuántos lugares tenés que tocar?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
class OrderService
{
public function getTotalForCustomer(Order $order): float
{
return $this->applyMemberDiscount($order);
}
public function getTotalForInvoice(Order $order): float
{
return $this->applyMemberDiscount($order);
}
private function applyMemberDiscount(Order $order): float
{
if ($order->customer->isMember()) {
return $order->subtotal * 0.90;
}
return $order->subtotal;
}
} Un desarrollador quiso hacer "la solución general" para validar campos de formulario.
class Validator
{
private array $rules = [];
private array $errors = [];
public function addRule(string $field, array $config): self
{
$this->rules[$field] = $config;
return $this;
}
public function validate(array $data): bool
{
foreach ($this->rules as $field => $config) {
foreach ($config['checks'] as $check) {
if ($check['type'] === 'required') {
if (empty($data[$field])) {
$this->errors[$field][] = $config['messages']['required'];
}
}
}
}
return empty($this->errors);
}
}
// Uso real en el proyecto:
$v = new Validator();
$v->addRule('email', [
'checks' => [['type' => 'required']],
'messages' => ['required' => 'Email requerido'],
]);
$v->validate($request->all()); Mostrar pista
¿Cuántas clases y configuraciones necesitás para validar que un campo no esté vacío?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
// Para lo que el proyecto necesita hoy:
function validateOrderForm(array $data): array
{
$errors = [];
if (empty($data['email'])) $errors['email'] = 'Email requerido';
if (empty($data['address'])) $errors['address'] = 'Dirección requerida';
return $errors;
} El método checkout() creció hasta 50 líneas porque cada sprint le agregaban una responsabilidad nueva.
class CheckoutService
{
public function checkout(Cart $cart, array $paymentData): Order
{
// Validar carrito
if ($cart->items()->isEmpty()) {
throw new \Exception('El carrito está vacío');
}
foreach ($cart->items() as $item) {
if ($item->product->stock < $item->quantity) {
throw new \Exception("Sin stock: {$item->product->name}");
}
}
// Calcular totales
$subtotal = 0;
foreach ($cart->items() as $item) {
$subtotal += $item->product->price * $item->quantity;
}
$discount = $cart->customer->isMember() ? $subtotal * 0.10 : 0;
$total = $subtotal - $discount;
// Cobrar
$gateway = new WompiGateway();
$result = $gateway->charge($total, $paymentData['token']);
if ($result['estado'] !== 'APROBADO') {
throw new PaymentException('Pago rechazado');
}
// Crear orden
$order = Order::create([
'customer_id' => $cart->customer_id,
'total' => $total,
'tx_id' => $result['id_transaccion'],
]);
foreach ($cart->items() as $item) {
$order->items()->create([
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'price' => $item->product->price,
]);
}
return $order;
}
} Mostrar pista
Cada bloque con un comentario es una responsabilidad candidata a su propio método.
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
class CheckoutService
{
public function checkout(Cart $cart, array $paymentData): Order
{
$this->validateCart($cart);
$total = $this->calculateTotal($cart);
$txId = $this->charge($total, $paymentData['token']);
return $this->createOrder($cart, $total, $txId);
}
private function validateCart(Cart $cart): void
{
if ($cart->items()->isEmpty()) {
throw new \Exception('El carrito está vacío');
}
foreach ($cart->items() as $item) {
if ($item->product->stock < $item->quantity) {
throw new \Exception("Sin stock: {$item->product->name}");
}
}
}
private function calculateTotal(Cart $cart): float
{
$subtotal = $cart->items()->sum(fn($i) => $i->product->price * $i->quantity);
$discount = $cart->customer->isMember() ? $subtotal * 0.10 : 0;
return $subtotal - $discount;
}
private function charge(float $total, string $token): string
{
$result = (new WompiGateway())->charge($total, $token);
if ($result['estado'] !== 'APROBADO') {
throw new PaymentException('Pago rechazado');
}
return $result['id_transaccion'];
}
private function createOrder(Cart $cart, float $total, string $txId): Order
{
$order = Order::create([
'customer_id' => $cart->customer_id,
'total' => $total,
'tx_id' => $txId,
]);
foreach ($cart->items() as $item) {
$order->items()->create([
'product_id' => $item->product_id,
'quantity' => $item->quantity,
'price' => $item->product->price,
]);
}
return $order;
}
} Al agregar soporte para reportes PDF, un desarrollador construyó un sistema de plugins extensible "para el futuro".
interface ReportPlugin
{
public function getName(): string;
public function getVersion(): string;
public function supports(string $format): bool;
public function render(array $data, array $options = []): string;
public function configure(array $config): void;
public function teardown(): void;
}
class PdfReportPlugin implements ReportPlugin
{
private array $config = [];
public function getName(): string { return 'pdf-report'; }
public function getVersion(): string { return '1.0.0'; }
public function supports(string $format): bool { return $format === 'pdf'; }
public function configure(array $config): void { $this->config = $config; }
public function teardown(): void { /* nada por ahora */ }
public function render(array $data, array $options = []): string
{
return $this->generatePdf($data);
}
private function generatePdf(array $data): string { /* ... */ }
}
class ReportPluginRegistry
{
private array $plugins = [];
public function register(ReportPlugin $p): void { $this->plugins[$p->getName()] = $p; }
public function get(string $name): ReportPlugin { return $this->plugins[$name]; }
} Mostrar pista
¿Cuántos tipos de reporte existen hoy? ¿Cuántos métodos de la interfaz se usan?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
// Solo existe PDF hoy. Cuando llegue otro formato, extraemos la interfaz.
class ReportService
{
public function generatePdf(array $data): string
{
// lógica de PDF
}
} SOLID
Cada ejercicio viola un principio. Identificá cuál, refactorizá, y verificá contra la solución.
La clase User acumuló responsabilidades porque "total, es del usuario".
class User
{
public string $name;
public string $email;
public string $passwordHash;
public function hashPassword(string $plain): string
{
return password_hash($plain, PASSWORD_BCRYPT);
}
public function sendWelcomeEmail(): void
{
mail($this->email, 'Bienvenido', "Hola {$this->name}");
}
public function toArray(): array
{
return ['name' => $this->name, 'email' => $this->email];
}
public function saveToDatabase(): void
{
DB::table('users')->insert($this->toArray());
}
} Mostrar pista
¿Qué pasa si cambia la librería de email? ¿Si cambia el algoritmo de hashing? ¿Si cambia la base de datos? Cada pregunta revela una razón de cambio independiente.
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
// User: solo datos del dominio
class User
{
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly string $passwordHash,
) {}
public function toArray(): array
{
return ['name' => $this->name, 'email' => $this->email];
}
}
// Responsabilidad de persistencia
class UserRepository
{
public function save(User $user): void
{
DB::table('users')->insert($user->toArray());
}
}
// Responsabilidad de notificación
class UserMailer
{
public function sendWelcome(User $user): void
{
mail($user->email, 'Bienvenido', "Hola {$user->name}");
}
}
// Responsabilidad de autenticación
class PasswordHasher
{
public function hash(string $plain): string
{
return password_hash($plain, PASSWORD_BCRYPT);
}
} Cada vez que el negocio pide un nuevo formato de reporte, hay que abrir esta clase.
class ReportExporter
{
public function export(Report $report, string $format): string
{
if ($format === 'csv') {
$lines = [];
foreach ($report->rows as $row) {
$lines[] = implode(',', $row);
}
return implode("\n", $lines);
}
if ($format === 'json') {
return json_encode($report->rows);
}
if ($format === 'html') {
$rows = array_map(
fn($r) => '<tr><td>' . implode('</td><td>', $r) . '</td></tr>',
$report->rows
);
return '<table>' . implode('', $rows) . '</table>';
}
throw new \Exception("Formato desconocido: {$format}");
}
} Mostrar pista
¿Qué necesitás cambiar para agregar formato XML? ¿Qué debería pasar en su lugar?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
interface ReportFormatter
{
public function format(Report $report): string;
}
class CsvFormatter implements ReportFormatter
{
public function format(Report $report): string
{
return implode("\n", array_map(fn($r) => implode(',', $r), $report->rows));
}
}
class JsonFormatter implements ReportFormatter
{
public function format(Report $report): string
{
return json_encode($report->rows);
}
}
class HtmlFormatter implements ReportFormatter
{
public function format(Report $report): string
{
$rows = array_map(
fn($r) => '<tr><td>' . implode('</td><td>', $r) . '</td></tr>',
$report->rows
);
return '<table>' . implode('', $rows) . '</table>';
}
}
class ReportExporter
{
public function export(Report $report, ReportFormatter $formatter): string
{
return $formatter->format($report);
}
} ReadOnlyFile hereda de File pero lanza excepción en write() — cualquier código que espera un File y recibe un ReadOnlyFile se rompe en runtime.
class File
{
public function read(): string { /* lee el archivo */ }
public function write(string $content): void { /* escribe */ }
public function delete(): void { /* borra */ }
}
class ReadOnlyFile extends File
{
public function write(string $content): void
{
throw new \Exception('Este archivo es de solo lectura');
}
public function delete(): void
{
throw new \Exception('Este archivo es de solo lectura');
}
}
// Código cliente — espera que cualquier File se pueda escribir:
function appendLog(File $file, string $msg): void
{
$file->write($file->read() . "\n" . $msg); // explota con ReadOnlyFile
} Mostrar pista
Si ReadOnlyFile no puede cumplir el contrato de File, ¿debería heredar de él?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
// Separar por capacidad, no por jerarquía de "tipo de archivo"
interface Readable
{
public function read(): string;
}
interface Writable
{
public function write(string $content): void;
}
interface Deletable
{
public function delete(): void;
}
class File implements Readable, Writable, Deletable
{
public function read(): string { /* ... */ }
public function write(string $content): void { /* ... */ }
public function delete(): void { /* ... */ }
}
class ReadOnlyFile implements Readable
{
public function read(): string { /* ... */ }
// No implementa Writable ni Deletable — esas operaciones no existen
}
// El cliente ahora declara lo que necesita:
function appendLog(Readable&Writable $file, string $msg): void
{
$file->write($file->read() . "\n" . $msg);
} Se creó una interfaz Worker para todos los tipos de empleado, pero algunos tipos no implementan todas las operaciones.
interface Worker
{
public function work(): void;
public function eat(): void; // robots no comen
public function sleep(): void; // robots no duermen
}
class HumanWorker implements Worker
{
public function work(): void { echo 'trabajando'; }
public function eat(): void { echo 'comiendo'; }
public function sleep(): void { echo 'durmiendo'; }
}
class RobotWorker implements Worker
{
public function work(): void { echo 'trabajando'; }
public function eat(): void { throw new \Exception("Robots don't eat"); }
public function sleep(): void { throw new \Exception("Robots don't sleep"); }
} Mostrar pista
Cuando una clase se ve forzada a implementar métodos que no le aplican (lanzando excepción o dejándolos vacíos), el problema está en la interfaz.
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
interface Workable
{
public function work(): void;
}
interface Eatable
{
public function eat(): void;
}
interface Sleepable
{
public function sleep(): void;
}
class HumanWorker implements Workable, Eatable, Sleepable
{
public function work(): void { echo 'trabajando'; }
public function eat(): void { echo 'comiendo'; }
public function sleep(): void { echo 'durmiendo'; }
}
class RobotWorker implements Workable
{
public function work(): void { echo 'trabajando'; }
// No implementa lo que no aplica — sin métodos vacíos ni excepciones
} OrderService instancia su propio mailer. No se puede testear sin enviar emails reales ni cambiar el proveedor sin modificar la clase.
class OrderService
{
public function confirm(Order $order): void
{
// lógica de negocio ...
$order->status = 'confirmed';
$order->save();
// acoplado a la implementación concreta
$mailer = new SendGridMailer();
$mailer->send(
to: $order->customer->email,
subject: 'Tu pedido fue confirmado',
body: "Pedido #{$order->id} confirmado."
);
}
} Mostrar pista
¿Qué necesitás cambiar en OrderService para testear confirm() sin enviar emails?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
// Abstracción — define el contrato, no la implementación
interface MailerInterface
{
public function send(string $to, string $subject, string $body): void;
}
// Implementación concreta
class SendGridMailer implements MailerInterface
{
public function send(string $to, string $subject, string $body): void
{
// llama a SendGrid
}
}
// Para tests
class FakeMailer implements MailerInterface
{
public array $sent = [];
public function send(string $to, string $subject, string $body): void
{
$this->sent[] = compact('to', 'subject', 'body');
}
}
// La dependencia se inyecta, no se construye
class OrderService
{
public function __construct(private MailerInterface $mailer) {}
public function confirm(Order $order): void
{
$order->status = 'confirmed';
$order->save();
$this->mailer->send(
to: $order->customer->email,
subject: 'Tu pedido fue confirmado',
body: "Pedido #{$order->id} confirmado."
);
}
} Patrones GoF — Bloque A
Los patrones candidatos: Command · Factory Method · State · Strategy · Template Method (en cualquier orden). No te decimos cuál va en cada ejercicio — diagnosticá primero, después refactorizá. La señal suele estar en el switch o el if.
ShippingService elige el proveedor de envío con un switch. Cada proveedor nuevo exige abrir la clase.
class ShippingService
{
public function calculateCost(Order $order, string $provider): float
{
switch ($provider) {
case 'correos':
return $order->weight * 2.50;
case 'fedex':
$base = 5.00;
if ($order->weight > 5) $base += ($order->weight - 5) * 3.00;
return $base;
case 'pickup':
return 0.0;
default:
throw new \Exception("Proveedor desconocido: {$provider}");
}
}
} Mostrar pista
¿Qué es lo que varía entre proveedores? Aislá eso en su propia abstracción.
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
interface ShippingStrategy
{
public function calculateCost(Order $order): float;
}
class CorreosShipping implements ShippingStrategy
{
public function calculateCost(Order $order): float
{
return $order->weight * 2.50;
}
}
class FedExShipping implements ShippingStrategy
{
public function calculateCost(Order $order): float
{
$base = 5.00;
if ($order->weight > 5) $base += ($order->weight - 5) * 3.00;
return $base;
}
}
class PickupShipping implements ShippingStrategy
{
public function calculateCost(Order $order): float
{
return 0.0;
}
}
// La estrategia entra por el constructor. Agregar DHL = nueva clase; ShippingService nunca cambia.
class ShippingService
{
public function __construct(private ShippingStrategy $strategy) {}
public function calculateCost(Order $order): float
{
return $this->strategy->calculateCost($order);
}
}
// Quien arma el servicio elige la estrategia:
$service = new ShippingService(new FedExShipping()); Dos parsers de importación (CSV y JSON) tienen el mismo esqueleto: abrir → parsear → validar → guardar. Solo el paso de parseo varía.
class CsvImporter
{
public function import(string $path): void
{
$raw = file_get_contents($path);
$rows = array_map('str_getcsv', explode("\n", trim($raw)));
foreach ($rows as $row) {
if (count($row) < 3) throw new \Exception('Fila inválida');
}
foreach ($rows as $row) {
Product::create(['name' => $row[0], 'price' => $row[1], 'stock' => $row[2]]);
}
}
}
class JsonImporter
{
public function import(string $path): void
{
$raw = file_get_contents($path);
$rows = json_decode($raw, true);
foreach ($rows as $row) {
if (!isset($row['name'], $row['price'], $row['stock'])) {
throw new \Exception('Objeto inválido');
}
}
foreach ($rows as $row) {
Product::create(['name' => $row['name'], 'price' => $row['price'], 'stock' => $row['stock']]);
}
}
} Mostrar pista
¿Qué pasos son idénticos en ambas clases? ¿Cuál es el único que varía?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
abstract class ProductImporter
{
// Template Method: el esqueleto es final — las subclases no lo redefinen
final public function import(string $path): void
{
$raw = file_get_contents($path);
$rows = $this->parse($raw); // paso variable
$this->validate($rows);
$this->save($rows);
}
// Paso variable — cada subclase lo implementa
abstract protected function parse(string $raw): array;
// Pasos comunes — definidos una sola vez
protected function validate(array $rows): void
{
foreach ($rows as $row) {
if (!isset($row['name'], $row['price'], $row['stock'])) {
throw new \Exception('Fila inválida');
}
}
}
protected function save(array $rows): void
{
foreach ($rows as $row) {
Product::create($row);
}
}
}
class CsvImporter extends ProductImporter
{
protected function parse(string $raw): array
{
// Solo separa en columnas — la validación de forma vive en validate()
return array_map(
function ($line) {
$cols = str_getcsv($line);
return ['name' => $cols[0] ?? null, 'price' => $cols[1] ?? null, 'stock' => $cols[2] ?? null];
},
explode("\n", trim($raw))
);
}
}
class JsonImporter extends ProductImporter
{
protected function parse(string $raw): array
{
return json_decode($raw, true);
}
} Order gestiona su estado con un string y switches. Cada vez que llega un estado nuevo, hay que actualizar todos los switches del sistema.
class Order
{
public string $status = 'pending';
public function pay(): void
{
switch ($this->status) {
case 'pending': $this->status = 'paid'; break;
case 'paid': throw new \Exception('Ya está pagado'); break;
case 'shipped': throw new \Exception('Ya fue enviado'); break;
case 'cancelled':throw new \Exception('Está cancelado'); break;
}
}
public function ship(): void
{
switch ($this->status) {
case 'pending': throw new \Exception('Debe pagarse primero'); break;
case 'paid': $this->status = 'shipped'; break;
case 'shipped': throw new \Exception('Ya fue enviado'); break;
case 'cancelled':throw new \Exception('Está cancelado'); break;
}
}
} Mostrar pista
¿Qué sabe hacer cada estado? Mirá el tipo de retorno de cada método: ahí va a vivir la transición.
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
interface OrderState
{
// Cada método devuelve el estado siguiente. Ahí vive la transición.
public function pay(): OrderState;
public function ship(): OrderState;
}
class PendingState implements OrderState
{
public function pay(): OrderState { return new PaidState(); }
public function ship(): OrderState { throw new \Exception('Debe pagarse primero'); }
}
class PaidState implements OrderState
{
public function pay(): OrderState { throw new \Exception('Ya está pagado'); }
public function ship(): OrderState { return new ShippedState(); }
}
class ShippedState implements OrderState
{
public function pay(): OrderState { throw new \Exception('Ya fue enviado'); }
public function ship(): OrderState { throw new \Exception('Ya fue enviado'); }
}
class CancelledState implements OrderState
{
public function pay(): OrderState { throw new \Exception('Está cancelado'); }
public function ship(): OrderState { throw new \Exception('Está cancelado'); }
}
class Order
{
private OrderState $state;
public function __construct()
{
$this->state = new PendingState();
}
// El contexto reasigna su estado con lo que devuelve la transición.
public function pay(): void { $this->state = $this->state->pay(); }
public function ship(): void { $this->state = $this->state->ship(); }
} El panel de administración ejecuta acciones directamente. No hay historial, no se pueden deshacer, y agregar una acción nueva toca el dispatcher central.
class AdminPanel
{
public function execute(string $action, Order $order): void
{
if ($action === 'cancel') {
$order->status = 'cancelled';
$order->save();
Log::info("Orden #{$order->id} cancelada");
}
if ($action === 'refund') {
$order->status = 'refunded';
$order->refunded = true;
$order->save();
Log::info("Orden #{$order->id} reembolsada");
}
if ($action === 'prioritize') {
$order->priority = 'high';
$order->save();
Log::info("Orden #{$order->id} priorizada");
}
}
} Mostrar pista
Cada bloque if es un comando candidato. ¿Qué necesita guardar cada comando para poder deshacerse?
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
interface Command
{
public function execute(): void;
public function undo(): void;
}
class CancelOrderCommand implements Command
{
private string $previousStatus;
public function __construct(private Order $order) {}
public function execute(): void
{
$this->previousStatus = $this->order->status;
$this->order->status = 'cancelled';
$this->order->save();
Log::info("Orden #{$this->order->id} cancelada");
}
public function undo(): void
{
$this->order->status = $this->previousStatus;
$this->order->save();
}
}
class RefundOrderCommand implements Command
{
public function __construct(private Order $order) {}
public function execute(): void
{
$this->order->status = 'refunded';
$this->order->refunded = true;
$this->order->save();
Log::info("Orden #{$this->order->id} reembolsada");
}
public function undo(): void { /* lógica de reversar el reembolso */ }
}
class PrioritizeOrderCommand implements Command
{
private string $previousPriority;
public function __construct(private Order $order) {}
public function execute(): void
{
$this->previousPriority = $this->order->priority;
$this->order->priority = 'high';
$this->order->save();
Log::info("Orden #{$this->order->id} priorizada");
}
public function undo(): void
{
$this->order->priority = $this->previousPriority;
$this->order->save();
}
}
// El invoker mantiene historial — no sabe qué hace cada comando
class AdminPanel
{
private array $history = [];
public function execute(Command $command): void
{
$command->execute();
$this->history[] = $command;
}
public function undoLast(): void
{
$command = array_pop($this->history);
$command?->undo();
}
} OrderService elige qué notificador instanciar con un if según el canal. Cada canal nuevo (WhatsApp, Telegram) obliga a abrir OrderService y agregar otra rama.
interface Notifier
{
public function send(string $recipient, string $message): void;
}
class EmailNotifier implements Notifier
{
public function send(string $recipient, string $message): void { /* envía email */ }
}
class SmsNotifier implements Notifier
{
public function send(string $recipient, string $message): void { /* envía SMS */ }
}
class OrderService
{
public function notifyCustomer(string $channel, string $customer): void
{
// La decisión de qué crear está soldada a la lógica de notificación.
if ($channel === 'email') {
$notifier = new EmailNotifier();
} elseif ($channel === 'sms') {
$notifier = new SmsNotifier();
} else {
throw new \Exception("Canal desconocido: {$channel}");
}
$notifier->send($customer, 'Tu pedido #42 fue confirmado.');
}
} Mostrar pista
¿Quién debería decidir qué clase concreta instanciar? Sacá esa decisión de OrderService a una clase dedicada que reciba el canal y devuelva el notificador.
Compará tu diagnóstico del Paso 1 con esto. ¿Acertaste el concepto?
Ver solución modelo en código
interface Notifier
{
public function send(string $recipient, string $message): void;
}
class EmailNotifier implements Notifier
{
public function send(string $recipient, string $message): void { /* envía email */ }
}
class SmsNotifier implements Notifier
{
public function send(string $recipient, string $message): void { /* envía SMS */ }
}
class PushNotifier implements Notifier
{
public function send(string $recipient, string $message): void { /* envía push */ }
}
// El factory: un mapa canal → clase concreta. La decisión vive acá, en un solo lugar.
class NotifierFactory
{
private array $map = [
'email' => EmailNotifier::class,
'sms' => SmsNotifier::class,
'push' => PushNotifier::class,
];
public function create(string $channel): Notifier
{
if (!isset($this->map[$channel])) {
throw new \Exception("Canal desconocido: {$channel}");
}
return new $this->map[$channel]();
}
}
// El consumidor recibe el factory y pide un notificador — no sabe qué clase concreta es.
class OrderService
{
public function __construct(private NotifierFactory $factory) {}
public function notifyCustomer(string $channel, string $customer): void
{
$notifier = $this->factory->create($channel);
$notifier->send($customer, 'Tu pedido #42 fue confirmado.');
}
}
// Agregar WhatsApp = una línea en el mapa. OrderService no cambia.