Ir al contenido

Práctica · Parcial 1 · viernes 12 de junio

Ejercicios de práctica

14 escenarios en PHP. Cada ejercicio esconde qué concepto es: diagnosticá, refactorizá y recién después revelá la solución.

Smells · KISS · DRY · YAGNI SOLID Patrones GoF
Cómo usar esta práctica · 3 pasos

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.

  1. Diagnosticá — leé el código y escribí qué smell, principio o patrón aplica. Sin mirar la respuesta.
  2. Refactorizá — abrí el problema en php.cesar.sh, escribí tu solución y ejecutala.
  3. 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 →

Ejercicio 1 ¿qué aplica acá?

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.

Tu turno ¿Qué viola este código y cómo lo corregirías?
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?

Ejercicio 2 ¿qué aplica acá?

Un desarrollador quiso hacer "la solución general" para validar campos de formulario.

Tu turno ¿Qué principio de diseño se está violando acá? Reescribí el código para resolverlo.
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?

Ejercicio 3 ¿qué aplica acá?

El método checkout() creció hasta 50 líneas porque cada sprint le agregaban una responsabilidad nueva.

Tu turno ¿Qué smell tiene este método? Identificá las responsabilidades mezcladas y refactorizá.
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.

Ejercicio 4 ¿qué aplica acá?

Al agregar soporte para reportes PDF, un desarrollador construyó un sistema de plugins extensible "para el futuro".

Tu turno ¿Qué problema tiene este diseño? ¿Qué harías en su lugar?
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?

SOLID

Cada ejercicio viola un principio. Identificá cuál, refactorizá, y verificá contra la solución.

Ejercicio 5 ¿qué aplica acá?

La clase User acumuló responsabilidades porque "total, es del usuario".

Tu turno ¿Cuántas razones para cambiar tiene esta clase? Reorganizá las responsabilidades.
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.

Ejercicio 6 ¿qué aplica acá?

Cada vez que el negocio pide un nuevo formato de reporte, hay que abrir esta clase.

Tu turno Refactorizá para que agregar un nuevo formato no requiera modificar ReportExporter.
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?

Ejercicio 7 ¿qué aplica acá?

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.

Tu turno ¿Qué principio se viola acá? ¿Cómo rediseñarías la jerarquía?
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?

Ejercicio 8 ¿qué aplica acá?

Se creó una interfaz Worker para todos los tipos de empleado, pero algunos tipos no implementan todas las operaciones.

Tu turno ¿Qué fuerza a hacer esta interfaz? ¿Cómo la dividirías?
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.

Ejercicio 9 ¿qué aplica acá?

OrderService instancia su propio mailer. No se puede testear sin enviar emails reales ni cambiar el proveedor sin modificar la clase.

Tu turno ¿Qué principio se viola y por qué no podés testear confirm() sin enviar emails reales? Refactorizá para resolverlo.
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?

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.

Ejercicio 10 ¿qué aplica acá?

ShippingService elige el proveedor de envío con un switch. Cada proveedor nuevo exige abrir la clase.

Tu turno Diagnosticá qué patrón aplica acá y refactorizá para que agregar un proveedor (DHL) no requiera tocar ShippingService.
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.

Ejercicio 11 ¿qué aplica acá?

Dos parsers de importación (CSV y JSON) tienen el mismo esqueleto: abrir → parsear → validar → guardar. Solo el paso de parseo varía.

Tu turno Diagnosticá qué patrón aplica y eliminá la duplicación entre los dos importadores.
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?

Ejercicio 12 ¿qué aplica acá?

Order gestiona su estado con un string y switches. Cada vez que llega un estado nuevo, hay que actualizar todos los switches del sistema.

Tu turno Diagnosticá qué patrón aplica y refactorizá para que cada estado gestione sus propias transiciones.
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.

Ejercicio 13 ¿qué aplica acá?

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.

Tu turno Diagnosticá qué patrón aplica y refactorizá para encapsular cada acción y soportar un historial con deshacer.
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?

Ejercicio 14 ¿qué aplica acá?

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.

Tu turno Diagnosticá qué patrón aplica y refactorizá para que la decisión de qué notificador crear viva en un solo lugar, separada de quien lo usa.
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.