Aller au contenu
Retour à tous les articles
Architecture Development WordPress 7 May 2026 · 13 min de lecture

La conception pilotée par le domaine dans un plugin WordPress : est-ce que ça vaut le coup ?

Francisco Silva

Francisco Silva

Partenaire Senior d'Ingénierie WordPress.

La conception pilotée par le domaine dans un plugin WordPress : est-ce que ça vaut le coup ?

La plupart des plugins WordPress sont écrits dans un style « tout mettre dans class Plugin et croiser les doigts ». J’ai construit le mien avec une architecture DDD correcte — Domain, Infrastructure, Application — et j’en ai vécu les conséquences. Voici une évaluation honnête de quand ça en vaut la peine et quand c’est excessif.

Il existe un certain type de plugin WordPress écrit sans aucune architecture. Un seul fichier. Trois mille lignes. Des appels add_action éparpillés dans des fonctions globales, $wpdb->query() côte à côte avec wp_mail() et le rendu HTML, le tout entassé sans aucune séparation des responsabilités. Ça fonctionne. Ça sort en production. Ça génère de l’argent.

Je construis un autre type de plugin. Le dernier en cours de développement compte 100 fichiers PHP pour 17 000 lignes, organisés en couches Domain, Infrastructure et Application avec des frontières explicites. Le dossier Domain ne contient aucune référence à WordPress. Le dossier Infrastructure est le seul endroit où $wpdb apparaît. Les dépôts retournent des modèles immuables. Les calculs sont des fonctions pures dans des objets valeur.

C’est du Domain-Driven Design (DDD) dans un plugin WordPress — pas la « façon WordPress », mais une approche rentable pour le type de produits que je développe.

Cet article est un bilan honnête. Là où le DDD dans un plugin est payant. Là où c’est excessif. Ce que ça coûte. Ce que ça rend possible.

L’architecture

Laissez-moi d’abord décrire ce que j’ai construit, afin que le reste de l’article ait quelque chose de concret auquel se référer.

La structure des dossiers :

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 règle de dépendance : les flèches pointent toujours vers l’intérieur. Api/ dépend de Domain/. Domain/ ne dépend de rien d’autre. Infrastructure/ dépend de Domain/ (plus précisément, des interfaces dans Domain/Repositories/).

Les modèles du domaine en tant que classes readonly immuables

Prenons le modèle 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;
    }
}

Trois éléments distinguent cette approche d’une classe de données WordPress classique :

final readonly class. Pas de sous-classement. Pas de mutation après la construction. Les classes readonly de PHP 8.2+ éliminent toute une catégorie de bugs à la compilation — il est littéralement impossible de modifier accidentellement un Student après l’avoir récupéré depuis un dépôt.

Promotion de paramètres de constructeur avec types stricts. La classe déclare sa forme complète en un seul endroit. Plus besoin de private $id; suivi quelque part d’un public function getId(): int { return $this->id; }. Moins de lignes, moins d’endroits où les bugs peuvent se cacher.

Des méthodes métier, pas des accesseurs. isDeleted() et hasAnySupportMeasure() sont des questions relevant du domaine. Elles appartiennent au modèle car elles expriment des propriétés de « ce qu’est cet étudiant ». Comparez à une classe WordPress classique qui exposerait getDeletedAt() et obligerait l’appelant à vérifier if ($student->getDeletedAt() !== null).

Student ne sait pas comment se sauvegarder lui-même. Il ne connaît pas $wpdb. Il ne sait pas s’il provient de MySQL, d’un fichier JSON ou d’une fixture de test unitaire. C’est un fait de forme valeur concernant une entité du domaine.

Les dépôts : interface dans le domaine, implémentation dans l’infrastructure

La couche Domain définit les opérations sur les données dont elle a besoin. Elle ne précise pas comment elles sont implémentées :

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

L’implémentation se trouve dans 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
}

Le code du domaine qui utilise les élèves ne voit jamais WpdbStudentRepository. Il reçoit une interface StudentRepository. Dans les tests, cette interface est satisfaite par un InMemoryStudentRepository qui utilise des tableaux. Le code du domaine ne peut pas faire la différence.

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

La colle WordPress

La frontière entre WordPress et le domaine est réduite et explicite. Elle se situe à deux endroits : les contrôleurs Api/ et les dépôts Infrastructure/.

Un contrôleur relie la requête au domaine :

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

Le contrôleur est la fine couche qui connaît HTTP. Il utilise le WpdbStudentRepository concret car, à ce niveau, le code se trouve dans l’univers WordPress. Mais il retourne le résultat via les modèles du domaine — l’appelant (l’infrastructure REST de WordPress) voit des données, pas l’implémentation.

Un service simple comme celui-ci convient parfaitement. Pour les opérations complexes, le contrôleur délègue à un service du domaine :

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

Le StudentEnrollmentService est de la logique de domaine pure. Il ne sait pas qu’il est appelé depuis un point de terminaison REST. Il ne sait pas qu’il stocke des données via $wpdb. Ses tests n’ont absolument pas besoin de WordPress.

Où le DDD porte ses fruits dans WordPress

Après environ 17 000 lignes de code, voici ce qui en a valu la peine.

Des tests qui s’exécutent en millisecondes sans démarrer WordPress. Mes tests unitaires du Domaine tournent en PHPUnit pur, sans suite de tests WordPress, sans MySQL. La suite de tests du Calculator et de son code de support s’exécute en moins de 200 ms. Je peux la lancer à chaque sauvegarde de fichier pendant le développement. Comparés aux tests d’intégration qui nécessitent de démarrer WordPress à chaque exécution — facilement 10 fois plus lents.

Refactorisation sans anxiété. Lorsque j’ai eu besoin de modifier la façon dont les notes de période sont agrégées (une demande d’un bêta-testeur), le changement a été contenu dans PeriodAggregator et un objet valeur. Le changement était associé à deux fichiers de tests, tous deux des tests unitaires, tous deux s’exécutant en 50 ms. J’avais immédiatement la certitude que le changement n’avait rien cassé d’autre.

Intégration de nouveaux collaborateurs. Un développeur qui lit le dossier Domain peut comprendre les règles métier sans avoir à savoir ce qu’est $wpdb. Le domaine se lit comme une description du fonctionnement de la notation scolaire portugaise, et non comme une extension de WordPress.

Charge mentale lors du débogage. Lorsqu’un bug apparaît, l’architecture en couches m’indique où chercher. Un calcul erroné ? La couche Domain. Des données qui ne se chargent pas ? Le Repository. Une forme de requête HTTP incorrecte ? Le Controller. Chaque couche a une seule responsabilité, je passe donc directement au fichier suspect.

Optionnalité de migration future. Si je souhaite un jour quitter WordPress (Laravel, une stack PHP personnalisée, voire Node.js avec les bons outils), le code du Domaine voyage intact. Je réécrirais la couche Infrastructure une seule fois. C’est une option bien réelle, pas une fantaisie.

Où le DDD est superflu dans WordPress

Le DDD n’est pas gratuit. Voici ce qu’il me coûte.

Chaque entité nécessite un Model + une interface Repository + un WpdbRepository + une Hydration. Cela représente quatre fichiers par type de données. Pour un plugin avec 20 entités, cela fait 80 fichiers de code essentiellement mécanique. Pour un plugin simple avec trois entités (un formulaire de contact, par exemple), c’est totalement disproportionné.

Le câblage des constructeurs est verbeux. Sans conteneur d’injection de dépendances, chaque méthode de contrôleur utilisant des services du Domaine doit construire manuellement la chaîne de dépendances. J’ai envisagé d’ajouter un conteneur (PHP-DI, league/container) mais j’y ai résisté, car cela ajoute une pièce mobile supplémentaire pour un gain marginal.

Certains patterns WordPress s’opposent à l’architecture. Les hooks WordPress fonctionnent via des fonctions globales. Le système de filtres de WordPress encourage la mutation. Le modèle WP_Query entre en conflit avec mon pattern Repository. J’ai dû écrire des adaptateurs là où un plugin « natif WordPress » n’en aurait pas eu besoin.

La courbe d’apprentissage pour les développeurs WordPress est abrupte. Un développeur habitué à écrire add_action et $wpdb et à modifier functions.php ne peut pas plonger dans cette base de code sans orientation préalable. Les 415 lignes de CLAUDE.md et les 851 lignes de architecture.md existent précisément parce que la base de code n’est pas intuitive pour un public WordPress.

Complexité de build. Composer pour l’autoloading, les espaces de noms PSR-4, les types stricts — tout cela n’est pas standard sous WordPress. Certains hébergeurs ont des configurations PHP particulières qui se heurtent au PHP moderne. J’ai dû spécifier composer install --no-dev avec soin lors des déploiements.

L’analyse coût-bénéfice en toute honnêteté

Pour mon projet — un SaaS destiné à passer à l’échelle pour des milliers d’enseignants, avec un moteur de calcul complexe qui doit être défendable, et une feuille de route incluant la mutualisation, des intégrations et potentiellement un avenir en dehors de WordPress — le DDD est sans ambiguïté rentable.

Pour un projet client classique — un thème personnalisé avec quelques CPT, des champs ACF, un formulaire de contact et une intégration de paiement — le DDD serait largement surdimensionné. Vous passeriez plus de temps sur l’architecture que sur la livraison de la fonctionnalité réelle.

La ligne de démarcation, selon mon expérience :

Utilisez le DDD quand :

  • Le produit comporte des règles métier non triviales (calculs, machines à états, conformité réglementaire)
  • Vous prévoyez que la base de code vivra plus de 2 ans
  • Des tests sont nécessaires pour assurer la fiabilité (calculs, logique financière, intégrité des données)
  • L’équipe est suffisamment grande pour que les conventions aient de l’importance
  • Le produit pourrait éventuellement dépasser le cadre de WordPress

N’utilisez pas le DDD quand :

  • Le produit est essentiellement du CRUD s’appuyant sur les fonctionnalités existantes de WordPress
  • Il s’agit d’un projet unique avec une fin définie
  • L’équipe se résume à « le développeur qui livre et passe à autre chose »
  • La vélocité prime sur la maintenabilité à long terme
  • Vous apprenez WordPress — l’architecture vous compliquera la tâche

Ce que je ferais différemment

Si je recommençais aujourd’hui, fort de ce que je sais désormais :

Adoptez le pattern Repository dans toute sa verbosité. Au départ, j’ai tenté d’utiliser un référentiel de base « intelligent » avec de la magie. Cela s’est retourné contre moi. Des repositories explicites et sans surprise, avec une méthode par requête, sont bien plus faciles à déboguer.

N’extrayez pas les abstractions avant d’avoir trois cas concrets. J’ai attendu que 3 repositories ou plus aient la même forme avant d’extraire AbstractWpdbRepository. Cette approche a fonctionné. Plus tôt dans le projet, j’aurais été tenté de trop complexifier.

Documentez les décisions d’architecture, pas seulement le code. Mon fichier architecture.md explique pourquoi WordPress, pourquoi une SPA React sur une route publique plutôt qu’en admin, pourquoi MySQL et non SQLite. Les futurs contributeurs n’ont pas à redériver ces décisions.

Anticipez tôt les pièges spécifiques à WordPress. Des éléments comme redirect_canonical qui interfère avec les routes SPA, dbDelta peu fiable pour les clés étrangères, ou wp-cron qui n’est que pseudo-planifié. Ces problèmes surgissent quelle que soit l’architecture ; les consigner dans un fichier gotchas.md évite de redécouvrir la roue.

Ce que cela apporte à mon produit

À ce jour, le moteur de calcul affiche une couverture de tests à 100 % par rapport au vrai fichier Excel de la bêta-testeuse. Vingt-trois élèves × deux trimestres × huit instruments = 368 calculs, chacun correspondant au fichier Excel à 0,001 point de pourcentage près. Quand l’enseignante demande « êtes-vous sûr que les calculs sont corrects ? », je peux lui montrer le test qui le prouve.

Cette confiance est le fruit de l’architecture. Sans l’isolation du domaine, je ne pourrais pas écrire ces tests à moindre coût. Sans les modèles immuables, je ne pourrais pas garantir que mes données n’ont pas dérivé en chemin. Sans le pattern Repository, je ne pourrais pas substituer des données de test aux données de production.

Ce n’est pas gratuit. Mais pour le type de produit que je construis, c’est le chemin le moins coûteux vers la confiance dont j’ai besoin.

Si vous développez un produit sérieux sur WordPress et que vous ne savez pas si l’investissement architectural en vaut la peine, parlons-en. La bonne réponse dépend de votre cas précis — mais je préfère vous aider à décider en connaissance de cause plutôt que vous laissiez découvrir à mi-chemin que l’architecture choisie au départ ne tient pas la route là où vous voulez aller.

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

Partager l'article

Derniers Insights