🎨 Sysprovider Code
Sysprovider LogoWiki
🇪🇸Hosting español para ecommerce

Desarrollo de Plugins WordPress con TypeScript y Arquitectura Hexagonal

Actualizado el 11 de junio de 2026

Introducción: La evolución del desarrollo de plugins WordPress

El ecosistema de WordPress ha madurado enormemente en los últimos años. Lo que antes se resolvía con un archivo functions.php y unas cuantas funciones sueltas, hoy exige escalabilidad, mantenibilidad y calidad de código a nivel empresarial. En este contexto, dos tendencias están convergiendo para transformar la forma en que construimos plugins: TypeScript como lenguaje de tipado estático para JavaScript, y la Arquitectura Hexagonal (también conocida como Puertos y Adaptadores) como patrón de diseño limpio y desacoplado.

Este artículo es una guía práctica y profunda para desarrolladores que quieren llevar sus plugins WordPress al siguiente nivel. Aprenderás a integrar TypeScript en tu flujo de trabajo de WordPress, a estructurar tu plugin siguiendo los principios de la arquitectura hexagonal, y a implementar testing efectivo desde el primer día. Todo con ejemplos de código reales y buenas prácticas.

[INFO] Este artículo asume que tienes conocimientos básicos de WordPress (hooks, shortcodes, REST API) y fundamentos de TypeScript. Si eres nuevo en TypeScript, te recomiendo leer primero la documentación oficial.


¿Por qué TypeScript en el desarrollo de plugins WordPress?

Ventajas del tipado estático

WordPress, en su núcleo, sigue siendo PHP. Sin embargo, la mayoría de plugins modernos incorporan una parte frontal (frontend) cada vez más compleja: paneles de administración con React, bloques Gutenberg, widgets interactivos, etc. Aquí es donde TypeScript marca la diferencia.

  • Detección temprana de errores: El compilador de TypeScript (tsc) atrapa errores de tipo, propiedades faltantes o funciones mal llamadas antes de que el código llegue al navegador.
  • Autocompletado y documentación viva: Los IDEs modernos (VS Code, WebStorm) ofrecen un autocompletado excelente gracias a los tipos. Esto acelera el desarrollo y reduce la fricción.
  • Refactorización segura: Cambiar la firma de una función o la estructura de un objeto es mucho menos arriesgado cuando el compilador te dice exactamente qué partes del código hay que actualizar.
  • Mejor colaboración en equipo: Los tipos actúan como documentación ejecutable. Un nuevo miembro del equipo puede entender rápidamente las interfaces y contratos del plugin.

Integración con WordPress

WordPress no "habla" TypeScript de forma nativa, pero eso no es un problema. Usamos herramientas como wp-scripts (el paquete oficial de WordPress para desarrollo moderno) o Vite para compilar TypeScript a JavaScript que WordPress pueda consumir. El resultado es un plugin que aprovecha lo mejor de ambos mundos: la potencia de TypeScript en desarrollo y la compatibilidad total con WordPress en producción.


Arquitectura Hexagonal: El patrón que necesitas

¿Qué es la Arquitectura Hexagonal?

Propuesta por Alistair Cockburn, la Arquitectura Hexagonal (también llamada Puertos y Adaptadores) busca aislar la lógica de negocio de los detalles técnicos (bases de datos, frameworks, APIs externas). Imagina un hexágono: en el centro está tu dominio (reglas de negocio puras), y alrededor, los puertos (interfaces) que conectan con el mundo exterior a través de adaptadores (implementaciones concretas).

Beneficios para plugins WordPress

Aplicar esta arquitectura a un plugin WordPress ofrece ventajas concretas:

  • Desacoplamiento de WordPress: Tu lógica de negocio no depende de $wpdb, add_action() o la REST API. Puedes probarla sin necesidad de cargar WordPress.
  • Testeabilidad: El dominio se prueba con tests unitarios rápidos (sin base de datos, sin servidor web). Los adaptadores se prueban con tests de integración controlados.
  • Flexibilidad: ¿Quieres cambiar de base de datos (de MySQL a MariaDB o incluso a un almacenamiento en la nube)? Solo cambias el adaptador. ¿Necesitas exponer la misma funcionalidad vía CLI, REST y AJAX? Añades nuevos adaptadores sin tocar el núcleo.
  • Mantenibilidad a largo plazo: El código está organizado por responsabilidades, no por "carpetas de WordPress". Es más fácil entender, modificar y extender.

Estructura de un plugin con TypeScript y Hexagonal

Vamos a definir una estructura de carpetas que refleje esta filosofía. Usaremos un ejemplo práctico: un plugin de gestión de membresías (suscripciones, usuarios premium, contenido restringido).

mi-plugin-membresias/
├── src/
│   ├── domain/
│   │   ├── entities/
│   │   │   └── Member.ts
│   │   ├── ports/
│   │   │   ├── MemberRepository.ts        // Puerto de salida
│   │   │   └── MemberService.ts           // Puerto de entrada (casos de uso)
│   │   └── use-cases/
│   │       ├── CreateMember.ts
│   │       └── RenewSubscription.ts
│   ├── infrastructure/
│   │   ├── persistence/
│   │   │   └── WordPressMemberRepository.ts  // Adaptador para WordPress DB
│   │   ├── http/
│   │   │   ├── RestApiController.ts          // Adaptador REST API
│   │   │   └── AdminPageController.ts        // Adaptador página admin
│   │   └── cli/
│   │       └── CliCommands.ts                // Adaptador WP-CLI
│   ├── application/
│   │   └── DependencyContainer.ts            // Inyección de dependencias
│   └── main.ts                               // Punto de entrada (bootstrap)
├── tests/
│   ├── unit/
│   │   └── domain/
│   │       └── CreateMember.test.ts
│   └── integration/
│       └── persistence/
│           └── WordPressMemberRepository.test.ts
├── package.json
├── tsconfig.json
└── webpack.config.js (o vite.config.ts)

Explicación de cada capa

  • domain/: Contiene las entidades (objetos de negocio con su comportamiento), los puertos (interfaces que definen contratos) y los casos de uso (la lógica de negocio pura). No depende de nada externo.
  • infrastructure/: Implementa los puertos definidos en domain. Aquí viven los adaptadores concretos: cómo guardar en WordPress ($wpdb), cómo responder a una petición REST, cómo mostrar una página de administración.
  • application/: Orquesta la creación de objetos y la inyección de dependencias. Es el pegamento entre el dominio y la infraestructura.
  • main.ts: El bootstrap del plugin. Se ejecuta cuando WordPress carga el plugin, registra hooks, inicializa el contenedor de dependencias y arranca los adaptadores necesarios.

[TIP] No intentes aplicar la arquitectura hexagonal de golpe. Empieza con un caso de uso pequeño (por ejemplo, "crear un miembro") y ve expandiendo. La sobreingeniería es el enemigo.


Implementación práctica: El dominio

Entidad Member

// src/domain/entities/Member.ts

export type MemberId = string;

export interface MemberProps {
  id: MemberId;
  name: string;
  email: string;
  subscriptionEndDate: Date | null;
  isActive: boolean;
}

export class Member {
  private constructor(private props: MemberProps) {}

  static create(props: MemberProps): Member {
    if (!props.name || props.name.trim().length === 0) {
      throw new Error('Member name is required');
    }
    if (!props.email || !props.email.includes('@')) {
      throw new Error('Valid email is required');
    }
    return new Member(props);
  }

  get id(): MemberId { return this.props.id; }
  get name(): string { return this.props.name; }
  get email(): string { return this.props.email; }
  get isActive(): boolean { return this.props.isActive; }

  renewSubscription(days: number): void {
    const now = new Date();
    const currentEnd = this.props.subscriptionEndDate ?? now;
    this.props.subscriptionEndDate = new Date(currentEnd.getTime() + days * 86400000);
    this.props.isActive = true;
  }

  toPrimitives(): MemberProps {
    return { ...this.props };
  }
}

Puerto de repositorio (interfaz)

// src/domain/ports/MemberRepository.ts

import { Member } from '../entities/Member';

export interface MemberRepository {
  findById(id: string): Promise<Member | null>;
  findByEmail(email: string): Promise<Member | null>;
  save(member: Member): Promise<void>;
  delete(id: string): Promise<void>;
}

Caso de uso: Crear miembro

// src/domain/use-cases/CreateMember.ts

import { Member, MemberProps } from '../entities/Member';
import { MemberRepository } from '../ports/MemberRepository';

export class CreateMember {
  constructor(private memberRepository: MemberRepository) {}

  async execute(props: Omit<MemberProps, 'id' | 'isActive' | 'subscriptionEndDate'>): Promise<Member> {
    const existing = await this.memberRepository.findByEmail(props.email);
    if (existing) {
      throw new Error('A member with this email already exists');
    }

    const newMember = Member.create({
      ...props,
      id: crypto.randomUUID(), // O usa un generador de IDs
      isActive: true,
      subscriptionEndDate: null,
    });

    await this.memberRepository.save(newMember);
    return newMember;
  }
}

[WARNING] En el ejemplo usamos crypto.randomUUID(). Asegúrate de que tu entorno lo soporte (Node 19+ o polyfill). En WordPress, puedes usar wp_generate_uuid4() desde PHP y pasarlo al frontend.


Implementación práctica: La infraestructura

Adaptador de persistencia para WordPress

// src/infrastructure/persistence/WordPressMemberRepository.ts

import { Member } from '../../domain/entities/Member';
import { MemberRepository } from '../../domain/ports/MemberRepository';

declare const wpdb: any; // Declaración global de $wpdb (definida en WordPress)

export class WordPressMemberRepository implements MemberRepository {
  private tableName: string;

  constructor() {
    this.tableName = `${wpdb.prefix}mi_plugin_members`; // Personaliza según tu plugin
  }

  async findById(id: string): Promise<Member | null> {
    const row = await wpdb.getRow(`SELECT * FROM ${this.tableName} WHERE id = %s`, [id]);
    if (!row) return null;
    return this.rowToMember(row);
  }

  async findByEmail(email: string): Promise<Member | null> {
    const row = await wpdb.getRow(`SELECT * FROM ${this.tableName} WHERE email = %s`, [email]);
    if (!row) return null;
    return this.rowToMember(row);
  }

  async save(member: Member): Promise<void> {
    const data = member.toPrimitives();
    const existing = await this.findById(data.id);
    if (existing) {
      await wpdb.update(this.tableName, data, { id: data.id });
    } else {
      await wpdb.insert(this.tableName, data);
    }
  }

  async delete(id: string): Promise<void> {
    await wpdb.delete(this.tableName, { id });
  }

  private rowToMember(row: any): Member {
    return Member.create({
      id: row.id,
      name: row.name,
      email: row.email,
      subscriptionEndDate: row.subscription_end_date ? new Date(row.subscription_end_date) : null,
      isActive: Boolean(row.is_active),
    });
  }
}

Adaptador REST API

// src/infrastructure/http/RestApiController.ts

import { CreateMember } from '../../domain/use-cases/CreateMember';
import { MemberRepository } from '../../domain/ports/MemberRepository';

export class RestApiController {
  constructor(
    private createMemberUseCase: CreateMember,
    private memberRepository: MemberRepository
  ) {}

  registerRoutes(): void {
    // Usamos la API REST de WordPress
    add_action('rest_api_init', () => {
      register_rest_route('mi-plugin/v1', '/members', [
        {
          methods: 'POST',
          callback: this.createMemberHandler.bind(this),
          permission_callback: () => current_user_can('manage_options'),
        },
        {
          methods: 'GET',
          callback: this.listMembersHandler.bind(this),
          permission_callback: '__return_true',
        },
      ]);
    });
  }

  private async createMemberHandler(request: any): Promise<any> {
    try {
      const { name, email } = request.get_params();
      const member = await this.createMemberUseCase.execute({ name, email });
      return new WP_REST_Response(member.toPrimitives(), 201);
    } catch (error: any) {
      return new WP_Error('creation_failed', error.message, { status: 400 });
    }
  }

  // ... otros handlers
}

Testing: La piedra angular de la calidad

La arquitectura hexagonal brilla especialmente en el testing. Al tener el dominio aislado, podemos probar los casos de uso sin depender de WordPress.

Test unitario del caso de uso

// tests/unit/domain/use-cases/CreateMember.test.ts

import { CreateMember } from '../../../src/domain/use-cases/CreateMember';
import { MemberRepository } from '../../../src/domain/ports/MemberRepository';
import { Member } from '../../../src/domain/entities/Member';

// Mock del repositorio
class MockMemberRepository implements MemberRepository {
  private members: Map<string, Member> = new Map();

  async findById(id: string): Promise<Member | null> {
    return this.members.get(id) ?? null;
  }
  async findByEmail(email: string): Promise<Member | null> {
    for (const member of this.members.values()) {
      if (member.email === email) return member;
    }
    return null;
  }
  async save(member: Member): Promise<void> {
    this.members.set(member.id, member);
  }
  async delete(id: string): Promise<void> {
    this.members.delete(id);
  }
}

describe('CreateMember use case', () => {
  it('should create a member successfully', async () => {
    const repo = new MockMemberRepository();
    const useCase = new CreateMember(repo);

    const member = await useCase.execute({ name: 'John Doe', email: 'john@example.com' });

    expect(member.name).toBe('John Doe');
    expect(member.isActive).toBe(true);
    expect(member.id).toBeDefined();
  });

  it('should throw error if email already exists', async () => {
    const repo = new MockMemberRepository();
    const useCase = new CreateMember(repo);

    await useCase.execute({ name: 'John', email: 'dup@example.com' });
    await expect(useCase.execute({ name: 'Jane', email: 'dup@example.com' }))
      .rejects.toThrow('A member with this email already exists');
  });
});

Test de integración del adaptador WordPress

Para probar el adaptador real, necesitas un entorno WordPress (puedes usar wp-env o @wordpress/env).

// tests/integration/persistence/WordPressMemberRepository.test.ts

import { WordPressMemberRepository } from '../../../src/infrastructure/persistence/WordPressMemberRepository';
import { Member } from '../../../src/domain/entities/Member';

describe('WordPressMemberRepository', () => {
  let repo: WordPressMemberRepository;

  beforeAll(() => {
    // Asume que wp-env está corriendo y $wpdb está disponible
    repo = new WordPressMemberRepository();
  });

  it('should save and retrieve a member', async () => {
    const member = Member.create({
      id: 'test-123',
      name: 'Integration Test',
      email: 'integration@test.com',
      isActive: true,
      subscriptionEndDate: null,
    });

    await repo.save(member);
    const retrieved = await repo.findById('test-123');
    expect(retrieved).not.toBeNull();
    expect(retrieved!.name).toBe('Integration Test');
  });
});

[TIP] Usa jest o vitest para los tests. Configúralos con ts-jest o @swc/jest para transpilar TypeScript sobre la marcha. Para los tests de integración, puedes usar @wordpress/env para levantar un WordPress temporal.


Configura

¿Necesitas ayuda?Son dos de nuestros técnicos, Agustín y Mikel, y están disponibles para resolver cualquier problema.

Hablar con ellos ahora
Agustín y Mikel