Most WordPress plugins are written in a “throw it all in
class Pluginand pray” style. I built mine with proper DDD layering — Domain, Infrastructure, Application — and lived with the consequences. Here’s an honest assessment of when it pays off and when it’s overkill.
There’s a certain kind of WordPress plugin that’s written with no architecture at all. One file. Three thousand lines. add_action calls scattered through global functions, $wpdb->query() next to wp_mail() next to HTML rendering, all jammed together with no separation of concerns. It works. It ships. It earns money.
I write a different kind of plugin. The latest one I’m building has 100 PHP files in 17,000 lines, organised into Domain, Infrastructure, and Application layers with explicit boundaries. The Domain folder has zero references to WordPress. The Infrastructure folder is the only place $wpdb appears. Repositories return immutable models. Calculations are pure functions in value objects.
This is Domain-Driven Design (DDD) in a WordPress plugin — not the “WordPress way”, but a way that’s cost-effective for the kind of products I’m building.
This article is an honest accounting. Where DDD inside a plugin pays off. Where it’s overkill. What it costs. What it makes possible.
The architecture
Let me first describe what I built, so the rest of the article has something concrete to refer to.
The folder structure:
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
The dependency rule: arrows always point inwards. Api/ depends on Domain/. Domain/ depends on nothing else. Infrastructure/ depends on Domain/ (specifically, on the interfaces in Domain/Repositories/).
Domain models as immutable readonly classes
Take the Student model:
declare(strict_types=1);
namespace Avaliar\Domain\Models;
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;
}
}
Three things distinguish this from a typical WordPress data class:
final readonly class. No subclassing. No mutation after construction. PHP 8.2+ readonly classes catch a whole category of bugs at compile time — you literally cannot accidentally modify a Student after you’ve fetched it from a repository.
Constructor parameter promotion with strict types. The class declares its complete shape in one place. No private $id; followed somewhere else by public function getId(): int { return $this->id; }. Fewer lines, fewer places for bugs to hide.
Domain methods, not getters. isDeleted() and hasAnySupportMeasure() are domain questions. They live on the model because they’re properties of “what this Student is”. Compare to a typical WordPress class that would have getDeletedAt() and require the caller to check if ($student->getDeletedAt() !== null).
The Student doesn’t know how to save itself. It doesn’t know about $wpdb. It doesn’t know if it came from MySQL, a JSON file, or a unit test fixture. It’s a value-shaped fact about a domain entity.
Repositories: interface in Domain, implementation in Infrastructure
The Domain layer defines what data operations it needs. It does not say how they’re implemented:
// includes/Domain/Repositories/StudentRepository.php
declare(strict_types=1);
namespace Avaliar\Domain\Repositories;
use Avaliar\Domain\Models\Student;
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;
}
The implementation lives in Infrastructure:
// includes/Infrastructure/Repositories/WpdbStudentRepository.php
declare(strict_types=1);
namespace Avaliar\Infrastructure\Repositories;
use Avaliar\Domain\Models\Student;
use Avaliar\Domain\Repositories\StudentRepository;
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
}
The Domain code that uses students never sees WpdbStudentRepository. It receives a StudentRepository interface. In tests, that interface is satisfied by an InMemoryStudentRepository that uses arrays. The Domain code can’t tell the difference.
// 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
}
}
The WordPress glue
The boundary between WordPress and the Domain is small and explicit. It lives in two places: the Api/ controllers and the Infrastructure/ repositories.
A controller wires the request to the domain:
// includes/Api/StudentsController.php
namespace Avaliar\Api;
use Avaliar\Infrastructure\Repositories\WpdbStudentRepository;
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
),
]);
}
}
The controller is the thin layer that knows about HTTP. It uses the concrete WpdbStudentRepository because at this layer the code is in WordPress-land. But it returns the result through the Domain models — the caller (WordPress REST infrastructure) sees data, not implementation.
A simple service like this is fine. For complex operations, the controller delegates to a Domain Service:
public function create(\WP_REST_Request $request): \WP_REST_Response
{
$service = new \Avaliar\Domain\Services\StudentEnrollmentService(
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)]);
}
The StudentEnrollmentService is pure domain logic. It doesn’t know it’s being called from a REST endpoint. It doesn’t know it’s storing data via $wpdb. Its tests don’t need WordPress at all.
Where DDD pays off in WordPress
After about 17,000 lines of code, here’s what’s been worth it.
Tests that run in milliseconds without booting WordPress. My Domain unit tests run as plain PHPUnit, no WordPress test suite, no MySQL needed. The test suite for the Calculator and its supporting code runs in under 200ms. I can run it on file save during development. Compare to integration tests that require booting WordPress for each run — easily 10x slower.
Refactoring without anxiety. When I needed to change how period grades aggregate (a request from the beta tester), the change was contained to PeriodAggregator and one value object. The change had two test files associated with it, both unit tests, both running in 50ms. I had immediate confidence the change didn’t break anything else.
Onboarding new collaborators. A developer reading the Domain folder can understand the business rules without having to know what $wpdb is. The domain reads like a description of how Portuguese assessment grading works, not like an extension of WordPress.
Mental load while debugging. When a bug appears, layered architecture tells me where to look. Calculation wrong? Domain layer. Data not loading? Repository. HTTP request shape wrong? Controller. Each layer has one responsibility, so I jump straight to the suspect file.
Future migration optionality. If I ever want to move off WordPress (Laravel, a custom PHP stack, even Node.js with the right tooling), the Domain code travels intact. I’d rewrite the Infrastructure layer once. That’s a real option, not a fantasy.
Where DDD is overkill in WordPress
DDD is not free. Here’s where it costs me.
Every entity needs a Model + Repository interface + WpdbRepository + Hydration. That’s four files per data type. For a plugin with 20 entities, that’s 80 files of mostly mechanical code. For a simple plugin with three entities (a contact form, say), this is wildly disproportionate.
Constructor wiring is verbose. Without a DI container, every controller method that uses Domain services has to manually construct the dependency chain. I’ve considered adding a container (PHP-DI, league/container) but resisted because it adds another moving part for marginal gain.
Some WordPress patterns fight against the architecture. WordPress hooks work via global functions. WordPress’s filter system encourages mutation. The WP_Query model conflicts with my repository pattern. I’ve had to write adapters in places where a “native WordPress” plugin wouldn’t need them.
The learning curve for WordPress developers is steep. A developer who’s used to writing add_action and $wpdb and editing functions.php can’t drop into this codebase without orientation. The 415-line CLAUDE.md and 851-line architecture.md exist precisely because the codebase isn’t self-explanatory to a WordPress audience.
Build complexity. Composer for autoloading, PSR-4 namespaces, strict types — these aren’t standard WordPress. Some hosts have weird PHP setups that fight modern PHP. I’ve had to specify composer install --no-dev in deployment carefully.
The honest cost-benefit
For my project — a SaaS aimed at scaling to thousands of teachers, with a complex calculation engine that has to be defensible, and a roadmap that includes multi-tenancy, integrations, and possibly a non-WordPress future — DDD is unambiguously worth it.
For a typical client project — a custom theme with a few CPTs, some ACF fields, a contact form, and a payment integration — DDD would be massive overkill. You’d spend more time on architecture than on shipping the actual feature.
The dividing line, in my experience:
Use DDD when:
- The product has non-trivial business rules (calculations, state machines, regulatory compliance)
- You expect the codebase to live longer than 2 years
- Tests are required for confidence (calculations, financial logic, data integrity)
- The team is large enough that conventions matter
- The product might eventually outgrow WordPress
Don’t use DDD when:
- The product is mostly CRUD on top of WordPress’s existing capabilities
- It’s a one-off project with a defined endpoint
- The team is “the developer who’ll ship and walk away”
- Velocity matters more than long-term maintainability
- You’re learning WordPress — the architecture will fight you
What I’d do differently
If I were starting today, knowing what I know now:
Embrace the verbose Repository pattern. Initially I tried to use a “smart” base repository with magic. It bit me. Boring, explicit repositories with one method per query are easier to debug.
Don’t extract abstractions until you have three concrete cases. I waited until 3+ repositories had the same shape before extracting AbstractWpdbRepository. That worked. Earlier in the project I’d have been tempted to over-engineer.
Document the architecture decisions, not just the code. My architecture.md documents why WordPress, why React SPA in public route vs admin, why MySQL not SQLite. Future contributors don’t have to re-derive these decisions.
Plan for the WordPress-specific gotchas early. Things like redirect_canonical interfering with SPA routes, dbDelta being unreliable for FK, wp-cron being pseudo. These show up regardless of architecture; documenting them in a gotchas.md saves repeating the discovery.
What this gives my product
Right now, the calculator engine has 100% test coverage against the beta tester’s actual Excel. Twenty-three students × two terms × eight instruments = 368 calculations, each one matching the Excel within 0.001 percentage points. When the teacher asks “are you sure the calculations are correct”, I can show her the test that proves it.
That confidence is bought with the architecture. Without the Domain isolation, I couldn’t write those tests cheaply. Without immutable models, I couldn’t be sure my data didn’t drift somewhere along the way. Without the Repository pattern, I couldn’t substitute test data for production data.
It’s not free. But for the kind of product I’m building, it’s the cheapest route to the confidence I need.
If you’re building a serious product on WordPress and you’re not sure whether the architectural investment is worth it, let’s talk. The right answer depends on your specific case — but I’d rather help you decide deliberately than have you discover halfway through that the architecture you started with doesn’t scale to where you’re going.