La mayoría de los plugins de WordPress están escritos con un estilo de «mete todo en
class Pluginy reza». Yo construí el mío con una estratificación DDD adecuada — Dominio, Infraestructura, Aplicación — y viví con las consecuencias. Aquí tienes una valoración honesta de cuándo merece la pena y cuándo es un exceso.
Existe cierto tipo de plugin de WordPress escrito sin ninguna arquitectura. Un solo archivo. Tres mil líneas. Llamadas a add_action dispersas entre funciones globales, $wpdb->query() junto a wp_mail() junto a la renderización de HTML, todo amontonado sin ninguna separación de responsabilidades. Funciona. Se publica. Genera dinero.
Yo escribo un tipo de plugin diferente. El último que estoy desarrollando tiene 100 archivos PHP en 17.000 líneas, organizados en capas de Dominio, Infraestructura y Aplicación con límites explícitos. La carpeta Domain no tiene ninguna referencia a WordPress. La carpeta Infrastructure es el único lugar donde aparece $wpdb. Los repositorios devuelven modelos inmutables. Los cálculos son funciones puras en objetos de valor.
Esto es Domain-Driven Design (DDD) en un plugin de WordPress — no es «la forma WordPress», pero sí una forma que resulta rentable para el tipo de productos que estoy desarrollando.
Este artículo es una valoración honesta. Dónde DDD dentro de un plugin resulta rentable. Dónde es un exceso. Qué cuesta. Qué hace posible.
La arquitectura
Permíteme describir primero lo que construí, para que el resto del artículo tenga algo concreto a lo que referirse.
La estructura de carpetas:
plugin/avaliar/
├── avaliar.php # Plugin bootstrap
├── includes/
│ ├── App/ # Public-facing routing (SPA serve)
│ ├── Api/ # REST controllers — one per resource
│ ├── Database/ # Migrations
│ ├── Domain/ # Pure PHP. No WordPress.
│ │ ├── Calculator/ # Grade calculation engine
│ │ ├── Enums/ # InstrumentType, UserRole, etc.
│ │ ├── Models/ # Immutable readonly classes
│ │ ├── Repositories/ # Interfaces (no implementations)
│ │ ├── Services/ # Application services
│ │ └── ValueObjects/ # LevelThresholds, AggregationFormula
│ └── Infrastructure/ # WordPress glue
│ └── Repositories/ # WpdbXRepository implements XRepository
├── assets/ # React SPA source
└── tests/
├── Unit/ # Domain tests, no DB, no WordPress
└── Integration/ # Real WP test suite, real MySQL
La regla de dependencia: las flechas siempre apuntan hacia dentro. Api/ depende de Domain/. Domain/ no depende de nada más. Infrastructure/ depende de Domain/ (concretamente, de las interfaces en Domain/Repositories/).
Modelos de dominio como clases readonly inmutables
Tomemos el modelo Student:
declare(strict_types=1);
namespace AvaliarDomainModels;
final readonly class Student
{
public function __construct(
public int $id,
public int $schoolId,
public int $classId,
public string $name,
public ?string $studentNumber,
public ?DateTimeImmutable $birthDate,
public bool $hasUniversalSupport,
public bool $hasSelectiveSupport,
public bool $hasAdditionalSupport,
public ?DateTimeImmutable $deletedAt = null,
) {}
public function isDeleted(): bool
{
return $this->deletedAt !== null;
}
public function hasAnySupportMeasure(): bool
{
return $this->hasUniversalSupport
|| $this->hasSelectiveSupport
|| $this->hasAdditionalSupport;
}
}
Tres aspectos distinguen esto de una clase de datos típica de WordPress:
final readonly class. Sin subclases. Sin mutación tras la construcción. Las clases readonly de PHP 8.2+ capturan toda una categoría de errores en tiempo de compilación: literalmente no es posible modificar accidentalmente un Student después de haberlo obtenido de un repositorio.
Promoción de parámetros de constructor con tipos estrictos. La clase declara su forma completa en un único lugar. Sin private $id; seguido en otro lugar de public function getId(): int { return $this->id; }. Menos líneas, menos sitios donde puedan ocultarse los errores.
Métodos de dominio, no getters. isDeleted() y hasAnySupportMeasure() son preguntas de dominio. Residen en el modelo porque son propiedades de «lo que este Student es». Compárese con una clase típica de WordPress que tendría getDeletedAt() y obligaría al llamador a comprobar if ($student->getDeletedAt() !== null).
El Student no sabe cómo guardarse a sí mismo. No conoce $wpdb. No sabe si proviene de MySQL, un archivo JSON o un fixture de prueba unitaria. Es un hecho con forma de valor sobre una entidad de dominio.
Repositorios: interfaz en Domain, implementación en Infrastructure
La capa Domain define qué operaciones de datos necesita. No indica cómo se implementan:
// includes/Domain/Repositories/StudentRepository.php
declare(strict_types=1);
namespace AvaliarDomainRepositories;
use AvaliarDomainModelsStudent;
interface StudentRepository
{
public function findById(int $id): ?Student;
/** @return Student[] */
public function findAllByClass(int $classId): array;
public function create(StudentCreationData $data): Student;
public function update(int $id, StudentUpdateData $data): Student;
public function softDelete(int $id): void;
}
La implementación reside en Infrastructure:
// includes/Infrastructure/Repositories/WpdbStudentRepository.php
declare(strict_types=1);
namespace AvaliarInfrastructureRepositories;
use AvaliarDomainModelsStudent;
use AvaliarDomainRepositoriesStudentRepository;
final class WpdbStudentRepository extends AbstractWpdbRepository implements StudentRepository
{
public function __construct()
{
parent::__construct('students'); // sets $this->table = wp_avaliar_students
}
public function findById(int $id): ?Student
{
return $this->fetchOne(
"SELECT * FROM {$this->table} WHERE id = %d AND deleted_at IS NULL",
$id
);
}
public function findAllByClass(int $classId): array
{
return $this->fetchMany(
"SELECT * FROM {$this->table}
WHERE class_id = %d AND deleted_at IS NULL
ORDER BY name ASC",
$classId
);
}
protected function hydrate(array $row): Student
{
return new Student(
id: (int) $row['id'],
schoolId: (int) $row['school_id'],
classId: (int) $row['class_id'],
name: (string) $row['name'],
studentNumber: $row['student_number'] ?? null,
birthDate: $row['birth_date']
? new DateTimeImmutable($row['birth_date'])
: null,
hasUniversalSupport: (bool) $row['has_universal_support'],
hasSelectiveSupport: (bool) $row['has_selective_support'],
hasAdditionalSupport: (bool) $row['has_additional_support'],
deletedAt: $row['deleted_at']
? new DateTimeImmutable($row['deleted_at'])
: null,
);
}
// ... create, update, softDelete
}
El código del Dominio que utiliza estudiantes nunca ve WpdbStudentRepository. Recibe una interfaz StudentRepository. En los tests, dicha interfaz es satisfecha por un InMemoryStudentRepository que utiliza arrays. El código del Dominio no puede distinguir la diferencia.
// includes/Domain/Services/SomeService.php
final readonly class SomeService
{
public function __construct(
private StudentRepository $students, // interface, not implementation
private ClassroomRepository $classes,
) {}
public function doSomethingDomainSpecific(int $classId): array
{
$students = $this->students->findAllByClass($classId);
$class = $this->classes->findById($classId);
// ... pure domain logic, no DB knowledge required
}
}
El pegamento de WordPress
El límite entre WordPress y el Dominio es pequeño y explícito. Reside en dos lugares: los controladores de Api/ y los repositorios de Infrastructure/.
Un controlador conecta la solicitud con el dominio:
// includes/Api/StudentsController.php
namespace AvaliarApi;
use AvaliarInfrastructureRepositoriesWpdbStudentRepository;
final class StudentsController
{
public function index(WP_REST_Request $request): WP_REST_Response
{
$classId = (int) $request->get_param('class_id');
// Wire up the concrete repository — the boundary between WordPress and Domain
$repository = new WpdbStudentRepository();
$students = $repository->findAllByClass($classId);
return rest_ensure_response([
'data' => array_map(
fn(Student $s) => [
'id' => $s->id,
'name' => $s->name,
'studentNumber' => $s->studentNumber,
'hasSupport' => $s->hasAnySupportMeasure(),
],
$students
),
]);
}
}
El controlador es la capa delgada que conoce HTTP. Utiliza el repositorio concreto WpdbStudentRepository porque en esta capa el código se encuentra en el territorio de WordPress. Sin embargo, devuelve el resultado a través de los modelos del Dominio: el llamador (la infraestructura REST de WordPress) recibe datos, no la implementación.
Un servicio sencillo como este es suficiente. Para operaciones complejas, el controlador delega en un Servicio de Dominio:
public function create(WP_REST_Request $request): WP_REST_Response
{
$service = new AvaliarDomainServicesStudentEnrollmentService(
students: new WpdbStudentRepository(),
classes: new WpdbClassroomRepository(),
audit: new WpdbAuditLogRepository(),
);
$student = $service->enroll(
classId: (int) $request->get_param('class_id'),
data: StudentCreationData::fromRequest($request),
);
return rest_ensure_response(['data' => $this->serialize($student)]);
}
El StudentEnrollmentService es lógica de dominio pura. No sabe que se le está invocando desde un endpoint REST. No sabe que almacena datos a través de $wpdb. Sus pruebas no necesitan WordPress en absoluto.
Dónde DDD merece la pena en WordPress
Tras aproximadamente 17 000 líneas de código, esto es lo que ha valido la pena.
Tests que se ejecutan en milisegundos sin arrancar WordPress. Mis tests unitarios de Dominio se ejecutan como PHPUnit puro, sin suite de tests de WordPress ni MySQL. La suite de tests del Calculator y su código de soporte se ejecuta en menos de 200 ms. Puedo lanzarla al guardar un archivo durante el desarrollo. Compáralo con los tests de integración que requieren arrancar WordPress en cada ejecución —fácilmente 10 veces más lentos.
Refactorización sin ansiedad. Cuando necesité cambiar la forma en que se agregan las notas por período (una petición del beta tester), el cambio quedó contenido en PeriodAggregator y un objeto valor. El cambio tenía dos archivos de tests asociados, ambos unitarios, ambos ejecutándose en 50 ms. Tuve confianza inmediata en que el cambio no había roto nada más.
Incorporación de nuevos colaboradores. Un desarrollador que lea la carpeta Domain puede entender las reglas de negocio sin necesidad de saber qué es $wpdb. El dominio se lee como una descripción de cómo funciona la calificación educativa portuguesa, no como una extensión de WordPress.
Carga mental durante la depuración. Cuando aparece un error, la arquitectura en capas me indica dónde buscar. ¿Cálculo incorrecto? Capa de Dominio. ¿Datos que no cargan? Repositorio. ¿Forma incorrecta de la petición HTTP? Controlador. Cada capa tiene una única responsabilidad, así que voy directamente al archivo sospechoso.
Opcionalidad para migraciones futuras. Si alguna vez quisiera salir de WordPress (Laravel, una pila PHP personalizada, incluso Node.js con las herramientas adecuadas), el código de Dominio se traslada intacto. Reescribiría la capa de Infraestructura una sola vez. Es una opción real, no una fantasía.
Dónde DDD es excesivo en WordPress
DDD no es gratuito. Aquí es donde me cuesta.
Cada entidad necesita un Model + interfaz Repository + WpdbRepository + Hydration. Son cuatro archivos por tipo de dato. Para un plugin con 20 entidades, son 80 archivos de código mayormente mecánico. Para un plugin sencillo con tres entidades (un formulario de contacto, por ejemplo), esto es absolutamente desproporcionado.
El cableado de constructores es verboso. Sin un contenedor de DI, cada método de controlador que utiliza servicios de Dominio debe construir manualmente la cadena de dependencias. He considerado añadir un contenedor (PHP-DI, league/container) pero lo he descartado porque añade otra pieza móvil con una ganancia marginal.
Algunos patrones de WordPress luchan contra la arquitectura. Los hooks de WordPress funcionan mediante funciones globales. El sistema de filtros de WordPress fomenta la mutación. El modelo WP_Query entra en conflicto con mi patrón de repositorio. He tenido que escribir adaptadores en lugares donde un plugin «nativo de WordPress» no los necesitaría.
La curva de aprendizaje para los desarrolladores de WordPress es pronunciada. Un desarrollador acostumbrado a escribir add_action y $wpdb y a editar functions.php no puede adentrarse en esta base de código sin orientación previa. El CLAUDE.md de 415 líneas y el architecture.md de 851 líneas existen precisamente porque la base de código no se explica por sí sola a una audiencia de WordPress.
Complejidad de construcción. Composer para la autocarga, espacios de nombres PSR-4, tipos estrictos — nada de esto es estándar en WordPress. Algunos servidores tienen configuraciones de PHP extrañas que pelean con el PHP moderno. He tenido que especificar composer install --no-dev en el despliegue con mucho cuidado.
El análisis coste-beneficio honesto
Para mi proyecto — un SaaS orientado a escalar a miles de profesores, con un motor de cálculo complejo que debe ser defendible, y una hoja de ruta que incluye multitenencia, integraciones y posiblemente un futuro sin WordPress — DDD merece la pena sin lugar a dudas.
Para un proyecto de cliente típico — un tema personalizado con unos pocos CPTs, algunos campos ACF, un formulario de contacto y una integración de pagos — DDD sería un exceso enorme. Dedicarías más tiempo a la arquitectura que a entregar la funcionalidad real.
La línea divisoria, en mi experiencia:
Usa DDD cuando:
- El producto tiene reglas de negocio no triviales (cálculos, máquinas de estado, cumplimiento normativo)
- Esperas que la base de código viva más de 2 años
- Las pruebas son necesarias para tener confianza (cálculos, lógica financiera, integridad de datos)
- El equipo es lo suficientemente grande como para que las convenciones importen
- El producto podría eventualmente superar a WordPress
No uses DDD cuando:
- El producto es principalmente CRUD sobre las capacidades existentes de WordPress
- Es un proyecto puntual con un punto final definido
- El equipo es «el desarrollador que entregará y se marchará»
- La velocidad importa más que la mantenibilidad a largo plazo
- Estás aprendiendo WordPress — la arquitectura te pondrá trabas
Qué haría de forma diferente
Si empezara hoy, sabiendo lo que sé ahora:
Adopta el patrón Repository de forma explícita. Al principio intenté usar un repositorio base «inteligente» con magia. Me trajo problemas. Los repositorios aburridos y explícitos, con un método por consulta, son más fáciles de depurar.
No extraigas abstracciones hasta tener tres casos concretos. Esperé a que 3 o más repositorios tuvieran la misma forma antes de extraer AbstractWpdbRepository. Funcionó. En una etapa anterior del proyecto habría caído en la tentación de sobre-diseñar.
Documenta las decisiones de arquitectura, no solo el código. Mi architecture.md explica por qué WordPress, por qué React SPA en la ruta pública frente al admin, por qué MySQL y no SQLite. Los futuros colaboradores no tendrán que volver a deducir estas decisiones.
Anticípate pronto a los problemas específicos de WordPress. Cosas como redirect_canonical interfiriendo con las rutas de la SPA, dbDelta siendo poco fiable para claves foráneas, o wp-cron siendo pseudocron. Aparecen independientemente de la arquitectura; documentarlos en un gotchas.md evita redescubrir los mismos problemas.
Qué aporta esto a mi producto
Ahora mismo, el motor de cálculo tiene una cobertura de pruebas del 100 % contrastada con el Excel real de la beta tester. Veintitrés alumnos × dos trimestres × ocho instrumentos = 368 cálculos, cada uno coincidiendo con el Excel con una diferencia inferior a 0,001 puntos porcentuales. Cuando la profesora pregunta «¿estás seguro de que los cálculos son correctos?», puedo mostrarle el test que lo demuestra.
Esa confianza se obtiene gracias a la arquitectura. Sin el aislamiento del Dominio, no podría escribir esas pruebas de forma económica. Sin modelos inmutables, no podría estar seguro de que los datos no se corrompieron en algún punto del camino. Sin el patrón Repository, no podría sustituir los datos de prueba por los de producción.
No es gratuito. Pero para el tipo de producto que estoy construyendo, es el camino más barato hacia la confianza que necesito.
Si estás construyendo un producto serio sobre WordPress y no tienes claro si merece la pena la inversión en arquitectura, hablemos. La respuesta correcta depende de tu caso concreto, pero prefiero ayudarte a decidir de forma consciente antes de que descubras a mitad del proyecto que la arquitectura con la que empezaste no escala hasta donde quieres llegar.