Código Limpio

Principios SOLID en PHP: Cómo los aplicamos en un catálogo industrial real

Ejemplos reales del proyecto ProSilicones64: 30 productos, 19 series, 127 certificaciones. Así estructuramos el código para que escale sin romperse.

Publicado
Lectura 6 min

Introducción

Cuando en Portocarrero empezamos a diseñar la arquitectura de ProSilicones64, teníamos un problema claro: el catálogo no era una lista plana de productos. Eran 30 productos que podían fabricarse en 19 series de silicona diferentes, cada serie con sus propias propiedades térmicas y de dureza, aplicables a 10 sectores industriales distintos, y sujetos a 127 normas y certificaciones.

Si hubiéramos metido todo en un ProductController de 2000 líneas con switches y condicionales, el sistema habría funcionado... durante tres meses. Después, cada cambio habría sido una pesadilla.

Los principios SOLID nos dieron la estructura para que el código pudiera crecer. No los aplicamos por purismo académico, sino porque un catálogo industrial complejo necesita una arquitectura que refleje esa complejidad sin convertirse en un monstruo inmantenible.

El problema

El WordPress que reemplazamos tenía exactamente los problemas que SOLID previene. Había un plugin de catálogo con una clase CatalogManager que hacía todo: consultaba productos, calculaba precios, validaba stock, generaba PDFs, enviaba emails. 1.847 líneas en un solo archivo.

Cada vez que el cliente pedía un cambio —"añade el sector naval", "muestra la temperatura máxima en la ficha"— había que bucear en ese archivo, rezar para no romper nada, y testear manualmente porque no había tests unitarios posibles.

  • 1.847 líneas en una sola clase CatalogManager
  • 34 métodos públicos haciendo de todo
  • 12 dependencias hardcodeadas (MySQL, mailer, logger...)
  • 0 tests unitarios posibles por el acoplamiento
  • Cada cambio = riesgo de regresión

Eso es lo que pasa cuando no aplicas SRP. Y arrastra los demás problemas: no puedes extender sin modificar (viola OCP), no puedes sustituir componentes (viola LSP), dependes de implementaciones concretas (viola DIP).

S

SRP: Cada clase hace una sola cosa

Separamos el monolito en piezas con responsabilidades claras

En ProSilicones64, las relaciones producto-serie-sector-norma son el núcleo del negocio. Si una sola clase gestiona todo, cualquier cambio en certificaciones afecta al catálogo, al exportador, a las fichas técnicas. Separamos por responsabilidad: cada clase tiene una única razón para cambiar.

Antes: CatalogManager monolítico (WordPress)

<?php
class CatalogManager
{
    public function getProduct($id)
    {
        // Consulta SQL directa
        $product = $this->db->query('SELECT * FROM products WHERE id = ?', [$id]);
        // Calcula precio con margen
        $product['price'] = $product['cost'] * 1.3;
        // Obtiene series aplicables
        $product['series'] = $this->db->query('SELECT * FROM series WHERE...');
        // Obtiene certificaciones
        $product['certs'] = $this->db->query('SELECT * FROM certifications WHERE...');
        // Genera HTML de ficha
        $product['html'] = $this->renderProductCard($product);
        // Log
        $this->logger->info('Product retrieved');
        return $product;
    }
    // ... 33 métodos más
}
  • 6 responsabilidades en un solo método
  • Imposible testear sin base de datos real
  • Cambiar el cálculo de precio afecta a todo
  • Cambiar el logger requiere modificar la clase

Después: Servicios separados por responsabilidad

<?php
// Solo consultas de productos
class ProductRepository
{
    public function findById(int $id): ?Product
    {
        return $this->db->query(
            'SELECT * FROM productos WHERE id = ?', 
            [$id]
        )->fetch(Product::class);
    }
}

// Solo lógica de series
class SeriesService
{
    public function getForProduct(int $productId): array
    {
        return $this->seriesRepo->findByProduct($productId);
    }
}

// Solo validación de certificaciones
class CertificationValidator
{
    public function validate(Product $p, Series $s, Sector $sec): bool
    {
        $required = $sec->getRequiredCertifications();
        $available = $s->getCertifications();
        return empty(array_diff($required, $available));
    }
}

// Orquesta las piezas, no hace lógica
class ProductService
{
    public function __construct(
        private ProductRepository $products,
        private SeriesService $series,
        private CertificationValidator $validator
    ) {}

    public function getProductWithSeries(int $id): array
    {
        $product = $this->products->findById($id);
        $series = $this->series->getForProduct($id);
        return ['product' => $product, 'series' => $series];
    }
}
  • Cada clase testeable de forma aislada
  • Cambiar validación no toca el repositorio
  • Reutilización: SeriesService sirve para catálogo, fichas, exports
  • Nuevos devs entienden el código en horas, no semanas
O

OCP: Añadir sectores sin modificar código

Abierto para extensión, cerrado para modificación

Cuando el cliente dijo "vamos a añadir el sector naval", no tuvimos que tocar ninguna clase existente. Cada sector tiene su clase de requisitos que implementa una interfaz común. Añadir sector = crear clase + insertar datos en BD.

Antes: Switch infinito que crece con cada sector

<?php
class SectorValidator
{
    public function getRequirements(string $sector): array
    {
        switch ($sector) {
            case 'railway':
                return ['EN45545-2-HL2', 'EN45545-2-HL3'];
            case 'food':
                return ['FDA-21CFR177', 'EU-1935-2004'];
            case 'medical':
                return ['ISO-10993', 'USP-CLASS-VI'];
            // Cada nuevo sector = modificar este switch
            // Cada modificación = riesgo de romper los demás
        }
    }
}
  • Añadir sector naval = modificar clase existente
  • El switch crece infinitamente
  • Un error en un case afecta a todos
  • Imposible que equipos trabajen en paralelo

Después: Strategy pattern con interfaz común

<?php
interface SectorRequirements
{
    public function getCertifications(): array;
    public function validateSeries(Series $series): bool;
    public function getTemperatureRange(): array;
}

class RailwaySectorRequirements implements SectorRequirements
{
    public function getCertifications(): array
    {
        return ['EN45545-2-HL2', 'EN45545-2-HL3'];
    }

    public function validateSeries(Series $series): bool
    {
        return $series->maxTemperature >= 150;
    }

    public function getTemperatureRange(): array
    {
        return ['min' => -40, 'max' => 150];
    }
}

// Añadir sector naval = crear esta clase. Nada más.
class NavalSectorRequirements implements SectorRequirements
{
    public function getCertifications(): array
    {
        return ['DNV-GL', 'USCG-164'];
    }

    public function validateSeries(Series $series): bool
    {
        return $series->saltWaterResistant === true;
    }

    public function getTemperatureRange(): array
    {
        return ['min' => -20, 'max' => 80];
    }
}
  • Sector naval implementado en 4 horas (vs 2-3 días)
  • Código existente no se toca = 0 regresiones
  • Cada sector testeable de forma independiente
  • Equipos pueden trabajar en sectores en paralelo
L

LSP: Todos los exportadores son intercambiables

Las subclases sustituyen a sus padres sin romper nada

ProSilicones64 exporta fichas técnicas en PDF, datos para ERPs en CSV, y catálogos en XML. Todos los exportadores implementan la misma interfaz: reciben un Product, devuelven un string. El controlador no sabe ni le importa qué formato está generando.

Antes: Exportadores inconsistentes

<?php
class PdfExporter
{
    public function export($product)
    {
        // Devuelve contenido binario
        return $this->pdf->generate($product);
    }
}

class CsvExporter
{
    public function export($product)
    {
        // Devuelve array, no string
        return [$product['name'], $product['sku']];
    }
}

class XmlExporter
{
    public function export($product)
    {
        // Imprime directamente, no devuelve nada
        echo '<product>' . $product['name'] . '</product>';
    }
}
  • Cada exportador devuelve algo diferente
  • El controlador necesita if/else por tipo
  • No se pueden intercambiar
  • Tests requieren verificar comportamiento específico

Después: Contrato común, comportamiento predecible

<?php
interface ProductExporter
{
    public function export(Product $product): string;
    public function getContentType(): string;
    public function getExtension(): string;
}

class PdfExporter implements ProductExporter
{
    public function export(Product $product): string
    {
        $html = $this->template->render('ficha-tecnica', $product);
        return $this->pdfEngine->fromHtml($html);
    }

    public function getContentType(): string { return 'application/pdf'; }
    public function getExtension(): string { return 'pdf'; }
}

class CsvExporter implements ProductExporter
{
    public function export(Product $product): string
    {
        return implode(';', [
            $product->sku,
            $product->name,
            $product->series->code,
            $product->maxTemp
        ]);
    }

    public function getContentType(): string { return 'text/csv'; }
    public function getExtension(): string { return 'csv'; }
}

// Controlador genérico que funciona con cualquier exportador
class ExportController
{
    public function download(int $id, string $format): Response
    {
        $product = $this->products->findById($id);
        $exporter = $this->factory->create($format);

        return new Response(
            $exporter->export($product),
            ['Content-Type' => $exporter->getContentType()]
        );
    }
}
  • Añadir XLSX = crear clase, registrar en factory
  • Controlador no cambia nunca
  • Tests con mock genérico para cualquier exportador
  • Tiempo de implementación de nuevo formato: 2 horas
I

ISP: Interfaces pequeñas y específicas

Nadie depende de métodos que no usa

Las vistas públicas del catálogo solo leen productos. El panel de admin lee y escribe. El sistema de búsqueda solo filtra. En lugar de una interfaz gigante que todos implementan, separamos por caso de uso.

Antes: Interfaz hinchada que obliga a implementar todo

<?php
interface ProductRepositoryInterface
{
    public function findById(int $id): ?Product;
    public function findBySlug(string $slug): ?Product;
    public function findAll(): array;
    public function findBySector(int $sectorId): array;
    public function save(Product $product): void;
    public function delete(int $id): void;
    public function search(string $query): array;
    public function filter(array $criteria): array;
    public function export(string $format): string;
    public function import(string $data): void;
    public function count(): int;
    public function paginate(int $page): array;
}

// El catálogo público implementa esto pero deja 6 métodos vacíos
// El admin implementa esto pero no necesita export/import
// Todos dependen de métodos que no usan
  • Clases con métodos vacíos o throw Exception
  • Cambiar search() afecta a quien solo lee
  • Tests deben mockear 12 métodos aunque usen 2
  • Violación implícita de SRP

Después: Interfaces segregadas por uso

<?php
interface ReadableProducts
{
    public function findById(int $id): ?Product;
    public function findBySlug(string $slug): ?Product;
    public function findBySector(int $sectorId): array;
}

interface WritableProducts
{
    public function save(Product $product): void;
    public function delete(int $id): void;
}

interface SearchableProducts
{
    public function search(string $query): array;
    public function filter(array $criteria): array;
}

// El catálogo público solo necesita leer
class PublicCatalogController
{
    public function __construct(
        private ReadableProducts $products
    ) {}
}

// El admin necesita leer y escribir
class AdminController
{
    public function __construct(
        private ReadableProducts $reader,
        private WritableProducts $writer
    ) {}
}

// El buscador solo necesita buscar
class SearchController
{
    public function __construct(
        private SearchableProducts $search
    ) {}
}
  • Cada controlador depende solo de lo que usa
  • Tests mockean 2-3 métodos, no 12
  • Cambios en búsqueda no afectan al catálogo
  • Código muerto eliminado: -70%
D

DIP: La lógica no conoce la infraestructura

Depender de abstracciones, no de implementaciones

El SeriesService no sabe si el caché es Redis, JSON en disco, o memoria. Solo sabe que tiene algo que implementa CacheInterface. Cambiar de sistema de caché es cambiar una línea en el contenedor de dependencias.

Antes: Dependencias hardcodeadas

<?php
class ProductService
{
    private $cache;
    private $db;
    private $logger;

    public function __construct()
    {
        // Dependencias concretas, imposible cambiar
        $this->cache = new RedisCache('localhost:6379');
        $this->db = new MySQLConnection('host=db user=root');
        $this->logger = new FileLogger('/var/log/app.log');
    }

    public function getProduct(int $id): Product
    {
        $this->logger->info('Getting product ' . $id);
        // ... usa $this->cache y $this->db directamente
    }
}
  • Tests requieren Redis y MySQL reales
  • Cambiar a Memcached = reescribir la clase
  • Imposible usar en entorno sin Redis
  • La clase sabe demasiado de infraestructura

Después: Inyección de abstracciones

<?php
interface CacheInterface
{
    public function get(string $key): mixed;
    public function set(string $key, mixed $value, int $ttl): void;
}

class ProductService
{
    public function __construct(
        private CacheInterface $cache,
        private ProductRepository $repository
    ) {}

    public function getProduct(int $id): Product
    {
        $key = "product_{$id}";

        if ($cached = $this->cache->get($key)) {
            return $cached;
        }

        $product = $this->repository->findById($id);
        $this->cache->set($key, $product, 3600);

        return $product;
    }
}

// En producción
$service = new ProductService(
    new FileCache('/var/cache'),
    new MySqlProductRepository($pdo)
);

// En tests
$service = new ProductService(
    new ArrayCache(),  // En memoria, sin I/O
    new InMemoryProductRepository()
);
  • Tests sin infraestructura real: ejecutan en 0.3s
  • Migrar de FileCache a Redis = 1 línea
  • Entorno de desarrollo sin dependencias externas
  • La lógica de negocio no cambia aunque cambie el caché

Métricas reales del proyecto ProSilicones64

Comparativa entre el WordPress original y la arquitectura SOLID después de 6 meses en producción:

Métrica WordPress original Arquitectura SOLID
Archivo más grande 1.847 líneas 187 líneas
Tiempo para añadir sector 2-3 días 4 horas
Tiempo para nuevo exportador 1-2 días 2 horas
Tests unitarios 0 47
Bugs en 6 meses N/A 3 menores
Cambios que causaron regresión Frecuentes 0

Conclusión

En Portocarrero no aplicamos SOLID porque quede bien en el currículum. Lo aplicamos porque un catálogo industrial con relaciones complejas necesita una arquitectura que pueda crecer. Si metes todo en un archivo gigante, funcionará hoy y será un infierno mañana.

Los cinco principios se resumen en una idea: cada pieza hace una cosa, las piezas se conectan por contratos claros, y añadir funcionalidad significa añadir piezas nuevas, no modificar las existentes.

ProSilicones64 lleva seis meses en producción. Ha crecido con tres nuevos sectores, dos formatos de exportación, integración con ERP, y un sistema de fichas técnicas dinámicas. El código del primer día sigue funcionando sin cambios. Eso es lo que SOLID permite.

Si cada cambio en tu sistema es una aventura, si tienes archivos de miles de líneas que nadie quiere tocar, o si añadir funcionalidad significa semanas de trabajo... tu arquitectura necesita una revisión. En Portocarrero podemos hacer una auditoría técnica: identificar acoplamientos, medir complejidad, y proponer un plan de refactor con prioridades claras.

Solicitar auditoría de código

Adrián Morín

Desarrollador y arquitectura visual

Responsable del desarrollo técnico, diseño de interfaces y arquitectura web sin dependencias.