Saltar para o conteúdo
Voltar a todos os artigos
Architecture Development WordPress 7 May 2026 · 13 min de leitura

Domain-Driven Design dentro de um plugin WordPress: vale a pena?

Francisco Silva

Francisco Silva

Parceiro Sénior de Engenharia WordPress.

Domain-Driven Design dentro de um plugin WordPress: vale a pena?

A maioria dos plugins WordPress é escrita num estilo “mete tudo na class Plugin e reza”. Eu construí o meu com uma camada DDD adequada — Domain, Infrastructure, Application — e vivi com as consequências. Aqui fica uma avaliação honesta de quando compensa e quando é exagero.

Existe um certo tipo de plugin WordPress escrito sem qualquer arquitectura. Um ficheiro. Três mil linhas. Chamadas add_action espalhadas por funções globais, $wpdb->query() ao lado de wp_mail() ao lado de renderização HTML, tudo amontoado sem qualquer separação de responsabilidades. Funciona. É lançado. Ganha dinheiro.

Eu escrevo um tipo diferente de plugin. O mais recente que estou a construir tem 100 ficheiros PHP em 17.000 linhas, organizados em camadas Domain, Infrastructure e Application com fronteiras explícitas. A pasta Domain tem zero referências ao WordPress. A pasta Infrastructure é o único lugar onde $wpdb aparece. Os repositórios devolvem modelos imutáveis. Os cálculos são funções puras em value objects.

Isto é Domain-Driven Design (DDD) num plugin WordPress — não o “modo WordPress”, mas uma abordagem custo-eficaz para o tipo de produtos que estou a construir.

Este artigo é um balanço honesto. Onde o DDD dentro de um plugin compensa. Onde é exagero. O que custa. O que torna possível.

A arquitectura

Deixe-me primeiro descrever o que construí, para que o resto do artigo tenha algo concreto a que se referir.

A estrutura de pastas:

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

A regra de dependência: as setas apontam sempre para dentro. Api/ depende de Domain/. Domain/ não depende de mais nada. Infrastructure/ depende de Domain/ (especificamente, das interfaces em Domain/Repositories/).

Modelos de domínio como classes readonly imutáveis

Veja o 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;
    }
}

Três aspetos distinguem esta classe de uma classe de dados típica do WordPress:

final readonly class. Sem subclasses. Sem mutação após a construção. As classes readonly do PHP 8.2+ detetam toda uma categoria de erros em tempo de compilação — literalmente não é possível modificar acidentalmente um Student depois de o ter obtido a partir de um repositório.

Promoção de parâmetros no construtor com tipos estritos. A classe declara a sua estrutura completa num único lugar. Sem private $id; seguido algures por public function getId(): int { return $this->id; }. Menos linhas, menos lugares onde os erros se podem esconder.

Métodos de domínio, não getters. isDeleted() e hasAnySupportMeasure() são questões de domínio. Pertencem ao modelo porque são propriedades de «o que este Student é». Compare com uma classe WordPress típica que teria getDeletedAt() e obrigaria o invocador a verificar if ($student->getDeletedAt() !== null).

O Student não sabe como se guardar a si próprio. Não conhece o $wpdb. Não sabe se veio do MySQL, de um ficheiro JSON ou de uma fixture de testes unitários. É um facto com forma de valor acerca de uma entidade de domínio.

Repositórios: interface no Domain, implementação na Infrastructure

A camada Domain define as operações de dados de que necessita. Não especifica como são implementadas:

// 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;
}

A implementação reside na 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
}

O código do Domain que utiliza alunos nunca vê WpdbStudentRepository. Recebe uma interface StudentRepository. Nos testes, essa interface é satisfeita por um InMemoryStudentRepository que usa arrays. O código do Domain não consegue distinguir a diferença.

// 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
    }
}

A cola com o WordPress

A fronteira entre o WordPress e o Domínio é pequena e explícita. Encontra-se em dois lugares: os controladores em Api/ e os repositórios em Infrastructure/.

Um controlador liga o pedido ao domínio:

// 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
            ),
        ]);
    }
}

O controlador é a camada fina que conhece o HTTP. Utiliza o WpdbStudentRepository concreto porque, nesta camada, o código encontra-se no território do WordPress. Mas devolve o resultado através dos modelos do Domínio — quem consome (a infraestrutura REST do WordPress) vê dados, não implementação.

Um serviço simples como este é suficiente. Para operações mais complexas, o controlador delega para um Serviço de Domínio:

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)]);
}

O StudentEnrollmentService é lógica de domínio pura. Não sabe que está a ser invocado a partir de um endpoint REST. Não sabe que está a armazenar dados via $wpdb. Os seus testes não precisam de WordPress de todo.

Onde o DDD compensa no WordPress

Após cerca de 17.000 linhas de código, eis o que valeu a pena.

Testes que correm em milissegundos sem arrancar o WordPress. Os meus testes unitários de Domínio correm como PHPUnit simples, sem suite de testes do WordPress, sem necessidade de MySQL. A suite de testes para a Calculadora e o código de suporte corre em menos de 200ms. Posso executá-la ao guardar o ficheiro durante o desenvolvimento. Compare-se com testes de integração que exigem arrancar o WordPress a cada execução — facilmente 10 vezes mais lentos.

Refatorização sem ansiedade. Quando precisei de alterar a forma como as classificações de período são agregadas (um pedido do beta tester), a alteração ficou contida em PeriodAggregator e num value object. A alteração tinha dois ficheiros de teste associados, ambos testes unitários, ambos a correr em 50ms. Tive confiança imediata de que a alteração não quebrou nada mais.

Integração de novos colaboradores. Um programador que leia a pasta Domain consegue compreender as regras de negócio sem precisar de saber o que é $wpdb. O domínio lê-se como uma descrição do funcionamento da avaliação do ensino português, não como uma extensão do WordPress.

Carga mental durante a depuração. Quando aparece um erro, a arquitetura em camadas diz-me onde procurar. Cálculo errado? Camada de Domínio. Dados não carregam? Repositório. Formato do pedido HTTP errado? Controlador. Cada camada tem uma responsabilidade, por isso vou diretamente ao ficheiro suspeito.

Optionalidade de migração futura. Se alguma vez quiser sair do WordPress (Laravel, uma stack PHP personalizada, ou mesmo Node.js com as ferramentas certas), o código de Domínio viaja intacto. Reescreveria a camada de Infraestrutura uma vez. É uma opção real, não uma fantasia.

Onde o DDD é excessivo no WordPress

O DDD não é gratuito. Eis onde me custa.

Cada entidade precisa de um Model + interface de Repository + WpdbRepository + Hydration. São quatro ficheiros por tipo de dados. Para um plugin com 20 entidades, são 80 ficheiros de código maioritariamente mecânico. Para um plugin simples com três entidades (um formulário de contacto, por exemplo), isto é manifestamente desproporcionado.

A ligação de construtores é verbosa. Sem um contentor de DI, cada método de controlador que utiliza serviços de Domínio tem de construir manualmente a cadeia de dependências. Considerei adicionar um contentor (PHP-DI, league/container), mas resisti porque acrescenta mais uma peça móvel para um ganho marginal.

Alguns padrões do WordPress conflituam com a arquitetura. Os hooks do WordPress funcionam via funções globais. O sistema de filtros do WordPress encoraja a mutação. O modelo WP_Query entra em conflito com o meu padrão de repositório. Tive de escrever adaptadores em locais onde um plugin «nativo WordPress» não precisaria deles.

A curva de aprendizagem para programadores WordPress é íngreme. Um programador habituado a escrever add_action e $wpdb e a editar functions.php não consegue entrar neste código sem orientação. O CLAUDE.md com 415 linhas e o architecture.md com 851 linhas existem precisamente porque o código não é autoexplicativo para quem trabalha com WordPress.

Complexidade de build. Composer para autoloading, namespaces PSR-4, tipos estritos — isto não é WordPress padrão. Alguns alojamentos têm configurações PHP estranhas que conflituam com PHP moderno. Já tive de especificar composer install --no-dev com cuidado no processo de deployment.

A análise custo-benefício honesta

Para o meu projeto — um SaaS destinado a escalar para milhares de professores, com um motor de cálculo complexo que tem de ser defensável, e um roadmap que inclui multi-tenancy, integrações e possivelmente um futuro fora do WordPress — o DDD vale inequivocamente a pena.

Para um projeto de cliente típico — um tema personalizado com alguns CPTs, alguns campos ACF, um formulário de contacto e uma integração de pagamento — o DDD seria um exagero enorme. Passaria mais tempo em arquitetura do que a entregar a funcionalidade em si.

A linha divisória, na minha experiência:

Use DDD quando:

  • O produto tem regras de negócio não triviais (cálculos, máquinas de estado, conformidade regulatória)
  • Prevê que o código viva mais de 2 anos
  • Os testes são necessários para garantir confiança (cálculos, lógica financeira, integridade dos dados)
  • A equipa é suficientemente grande para que as convenções sejam importantes
  • O produto pode eventualmente crescer para além do WordPress

Não use DDD quando:

  • O produto é essencialmente CRUD sobre as capacidades existentes do WordPress
  • É um projeto pontual com um ponto final definido
  • A equipa é «o programador que entrega e vai embora»
  • A velocidade de entrega é mais importante do que a manutenibilidade a longo prazo
  • Está a aprender WordPress — a arquitetura vai trabalhar contra si

O que faria de forma diferente

Se estivesse a começar hoje, sabendo o que sei agora:

Adote o padrão Repository de forma explícita. Inicialmente tentei usar um repositório base “inteligente” com magia. Correu mal. Repositórios simples e explícitos, com um método por consulta, são mais fáceis de depurar.

Não extraia abstrações antes de ter três casos concretos. Esperei até que 3 ou mais repositórios tivessem a mesma forma antes de extrair AbstractWpdbRepository. Resultou. Mais cedo no projeto, teria sido tentado a sobre-engenheirar.

Documente as decisões de arquitetura, não apenas o código. O meu architecture.md documenta porquê WordPress, porquê React SPA na rota pública vs administração, porquê MySQL e não SQLite. Os futuros colaboradores não têm de redescobrir estas decisões.

Antecipe os problemas específicos do WordPress. Coisas como redirect_canonical a interferir com rotas SPA, dbDelta a ser pouco fiável com FK, wp-cron a ser pseudo. Surgem independentemente da arquitetura; documentá-los num gotchas.md evita repetir a descoberta.

O que isto oferece ao meu produto

Neste momento, o motor da calculadora tem 100% de cobertura de testes face ao Excel real do beta tester. Vinte e três alunos × dois períodos × oito instrumentos = 368 cálculos, cada um a coincidir com o Excel dentro de 0,001 pontos percentuais. Quando a professora pergunta «tem a certeza de que os cálculos estão corretos», posso mostrar-lhe o teste que o comprova.

Essa confiança é conquistada com a arquitetura. Sem o isolamento do Domínio, não conseguiria escrever esses testes de forma económica. Sem modelos imutáveis, não poderia ter a certeza de que os dados não se corromperam algures pelo caminho. Sem o padrão Repository, não conseguiria substituir dados de teste por dados de produção.

Não é gratuito. Mas para o tipo de produto que estou a construir, é o caminho mais económico para a confiança de que necessito.

Se está a construir um produto sério em WordPress e não tem a certeza se o investimento arquitetural vale a pena, fale comigo. A resposta certa depende do seu caso específico — mas prefiro ajudá-lo a decidir de forma deliberada do que deixá-lo descobrir a meio do caminho que a arquitetura com que começou não escala até onde quer chegar.

#domain-driven-design #php #software-architecture #testing #wordpress

Partilhar artigo

Últimos Insights