How to design a modular Domain-driven architecture (DDD) blocks architecture for enterprise-level custom plugins
Deconstructing WordPress Plugin Complexity with DDD Building Blocks
Enterprise-level WordPress plugins often evolve into monolithic beasts, making maintenance, scalability, and feature expansion a significant challenge. This post outlines a strategy for designing modular plugin architectures using Domain-Driven Design (DDD) principles, focusing on practical implementation with PHP and WordPress hooks. We’ll break down a hypothetical e-commerce plugin into distinct, manageable “building blocks” or “bounded contexts.”
Defining Bounded Contexts: The Core of Modularity
The first step in a DDD approach is to identify the core business domains and delineate them into Bounded Contexts. For an e-commerce plugin, these might include:
- Catalog Management: Handles products, categories, attributes, and variations.
- Order Processing: Manages shopping carts, checkout, order creation, and status updates.
- Customer Management: Deals with user accounts, profiles, addresses, and purchase history.
- Payment Gateway Integration: Abstracts different payment providers.
- Shipping & Fulfillment: Manages shipping methods, rates, and tracking.
Each of these contexts should be as independent as possible, communicating with others through well-defined interfaces (APIs or events). This isolation is key to achieving true modularity.
Structuring the Plugin Filesystem for DDD
A clear directory structure is crucial for maintaining DDD principles. We’ll adopt a structure where each Bounded Context has its own dedicated directory, containing its domain logic, application services, and infrastructure concerns.
Consider a plugin named `my-enterprise-ecommerce`. The proposed structure would look like this:
my-enterprise-ecommerce/src/Catalog/(Bounded Context: Catalog Management)Domain/Entities/Product.phpValueObjects/Price.phpRepositories/ProductRepository.phpServices/ProductService.php
Application/Commands/CreateProductCommand.phpQueries/GetProductByIdQuery.phpHandlers/CreateProductCommandHandler.php
Infrastructure/Persistence/WPDBProductRepository.php
CatalogServiceProvider.php(or similar for dependency injection)
Order/(Bounded Context: Order Processing)Domain/Entities/Order.phpValueObjects/OrderStatus.phpRepositories/OrderRepository.php
Application/Commands/PlaceOrderCommand.phpHandlers/PlaceOrderCommandHandler.php
Infrastructure/Persistence/WPDBOrderRepository.php
OrderServiceProvider.php
Customer/(Bounded Context: Customer Management)Payment/(Bounded Context: Payment Gateway Integration)Shipping/(Bounded Context: Shipping & Fulfillment)Common/(Shared entities, value objects, or interfaces if absolutely necessary)
includes/(WordPress specific bootstrapping, hooks, etc.)my-enterprise-ecommerce.php(Main plugin file)
Implementing Domain Logic: The Heart of a Context
Within each Bounded Context, we focus on the core domain logic. This involves entities, value objects, aggregates, domain services, and repositories. Let’s take the `Catalog` context as an example.
Catalog Context: Domain Layer Example
We’ll define a `Product` entity and a `Price` value object.
Entities: Product.php
Entities have a unique identity that persists over time. In WordPress, this often maps to a post type or a custom table ID.
namespace MyEnterpriseEcommerce\Catalog\Domain\Entities;
use MyEnterpriseEcommerce\Catalog\Domain\ValueObjects\Price;
use InvalidArgumentException;
class Product {
private int $id;
private string $name;
private Price $price;
private array $attributes = []; // e.g., ['color' => 'red', 'size' => 'M']
public function __construct(int $id, string $name, Price $price) {
if (empty($name)) {
throw new InvalidArgumentException('Product name cannot be empty.');
}
$this->id = $id;
$this->name = $name;
$this->price = $price;
}
public function getId(): int {
return $this->id;
}
public function getName(): string {
return $this->name;
}
public function setName(string $name): void {
if (empty($name)) {
throw new InvalidArgumentException('Product name cannot be empty.');
}
$this->name = $name;
}
public function getPrice(): Price {
return $this->price;
}
public function setPrice(Price $price): void {
$this->price = $price;
}
public function addAttribute(string $key, string $value): void {
$this->attributes[$key] = $value;
}
public function getAttribute(string $key): ?string {
return $this->attributes[$key] ?? null;
}
public function getAttributes(): array {
return $this->attributes;
}
}
Value Objects: Price.php
Value objects are immutable and defined by their attributes, not an identity. They are useful for representing concepts like currency, dates, or measurements.
namespace MyEnterpriseEcommerce\Catalog\Domain\ValueObjects;
use InvalidArgumentException;
class Price {
private float $amount;
private string $currency; // e.g., 'USD', 'EUR'
public function __construct(float $amount, string $currency) {
if ($amount < 0) {
throw new InvalidArgumentException('Price amount cannot be negative.');
}
if (empty($currency)) {
throw new InvalidArgumentException('Currency cannot be empty.');
}
$this->amount = $amount;
$this->currency = strtoupper($currency);
}
public function getAmount(): float {
return $this->amount;
}
public function getCurrency(): string {
return $this->currency;
}
// Example of immutability: methods return new instances
public function add(Price $other): Price {
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Cannot add prices with different currencies.');
}
return new Price($this->amount + $other->amount, $this->currency);
}
public function equals(Price $other): bool {
return $this->amount === $other->amount && $this->currency === $other->currency;
}
}
Repositories: Abstracting Data Persistence
Repositories provide an abstraction layer for data access, hiding the underlying storage mechanism (e.g., `wpdb`, custom tables, external APIs). This allows the domain layer to remain independent of infrastructure details.
Catalog Repository Interface: ProductRepository.php
namespace MyEnterpriseEcommerce\Catalog\Domain\Repositories;
use MyEnterpriseEcommerce\Catalog\Domain\Entities\Product;
interface ProductRepository {
public function findById(int $id): ?Product;
public function save(Product $product): bool;
public function delete(int $id): bool;
public function findAll(): array;
// Add other methods as needed, e.g., findByCategory(int $categoryId)
}
Concrete Implementation: WPDBProductRepository.php
This implementation uses WordPress’s `wpdb` global object to interact with the database. We’ll assume a custom table `wp_ecommerce_products` for simplicity.
namespace MyEnterpriseEcommerce\Catalog\Infrastructure\Persistence;
use MyEnterpriseEcommerce\Catalog\Domain\Entities\Product;
use MyEnterpriseEcommerce\Catalog\Domain\Repositories\ProductRepository;
use MyEnterpriseEcommerce\Catalog\Domain\ValueObjects\Price;
use wpdb;
use Exception;
class WPDBProductRepository implements ProductRepository {
private wpdb $db;
private string $tableName;
public function __construct(wpdb $db) {
$this->db = $db;
$this->tableName = $db->prefix . 'ecommerce_products';
}
public function findById(int $id): ?Product {
$result = $this->db->get_row(
$this->db->prepare(
"SELECT * FROM {$this->tableName} WHERE id = %d",
$id
),
ARRAY_A
);
if (!$result) {
return null;
}
try {
$price = new Price((float) $result['price_amount'], $result['price_currency']);
$product = new Product($result['id'], $result['name'], $price);
// Load attributes if stored separately or serialized
if (!empty($result['attributes'])) {
$attributes = json_decode($result['attributes'], true);
if (is_array($attributes)) {
foreach ($attributes as $key => $value) {
$product->addAttribute($key, $value);
}
}
}
return $product;
} catch (Exception $e) {
// Log error, handle gracefully
error_log("Error creating Product entity from DB row: " . $e->getMessage());
return null;
}
}
public function save(Product $product): bool {
$data = [
'name' => $product->getName(),
'price_amount' => $product->getPrice()->getAmount(),
'price_currency' => $product->getPrice()->getCurrency(),
'attributes' => json_encode($product->getAttributes()),
];
$format = ['%s', '%f', '%s', '%s'];
if ($product->getId() > 0) { // Update existing
$where = ['id' => $product->getId()];
$where_format = ['%d'];
$updated = $this->db->update($this->tableName, $data, $where, $format, $where_format);
return $updated !== false;
} else { // Insert new
$inserted = $this->db->insert($this->tableName, $data, $format);
if ($inserted) {
$product->__set('id', $this->db->insert_id); // Hacky way to set ID after insert, consider a better approach
}
return $inserted !== false;
}
}
public function delete(int $id): bool {
$deleted = $this->db->delete($this->tableName, ['id' => $id], ['%d']);
return $deleted !== false;
}
public function findAll(): array {
$results = $this->db->get_results("SELECT id FROM {$this->tableName}", ARRAY_A);
$products = [];
if ($results) {
foreach ($results as $row) {
$product = $this->findById($row['id']);
if ($product) {
$products[] = $product;
}
}
}
return $products;
}
}
Application Layer: Orchestrating Use Cases
The application layer orchestrates the domain objects to fulfill specific use cases. It doesn’t contain business logic itself but rather coordinates the domain and infrastructure layers. This is where commands and queries are typically handled.
Catalog Context: Application Layer Example
Let’s define a command to create a new product.
Command: CreateProductCommand.php
namespace MyEnterpriseEcommerce\Catalog\Application\Commands;
class CreateProductCommand {
public string $name;
public float $priceAmount;
public string $currency;
public array $attributes;
public function __construct(string $name, float $priceAmount, string $currency, array $attributes = []) {
$this->name = $name;
$this->priceAmount = $priceAmount;
$this->currency = $currency;
$this->attributes = $attributes;
}
}
Command Handler: CreateProductCommandHandler.php
The handler takes the command, uses the repository to create a domain entity, and saves it.
namespace MyEnterpriseEcommerce\Catalog\Application\Handlers;
use MyEnterpriseEcommerce\Catalog\Domain\Entities\Product;
use MyEnterpriseEcommerce\Catalog\Domain\Repositories\ProductRepository;
use MyEnterpriseEcommerce\Catalog\Domain\ValueObjects\Price;
use MyEnterpriseEcommerce\Catalog\Application\Commands\CreateProductCommand;
class CreateProductCommandHandler {
private ProductRepository $productRepository;
public function __construct(ProductRepository $productRepository) {
$this->productRepository = $productRepository;
}
public function handle(CreateProductCommand $command): Product {
// Create domain objects
$price = new Price($command->priceAmount, $command->currency);
$product = new Product(0, $command->name, $price); // ID 0 for new product
// Add attributes
foreach ($command->attributes as $key => $value) {
$product->addAttribute($key, $value);
}
// Persist using the repository
if (!$this->productRepository->save($product)) {
throw new \RuntimeException('Failed to save product.');
}
return $product;
}
}
Dependency Injection and Service Providers
To manage dependencies between different parts of your application (e.g., injecting `ProductRepository` into `CreateProductCommandHandler`), a Dependency Injection Container (DIC) is highly recommended. For WordPress plugins, you can implement a simple service locator pattern or use a lightweight DIC library. Each Bounded Context can have its own “Service Provider” to register its services.
Example: CatalogServiceProvider.php (Conceptual)
namespace MyEnterpriseEcommerce\Catalog;
use MyEnterpriseEcommerce\Catalog\Domain\Repositories\ProductRepository;
use MyEnterpriseEcommerce\Catalog\Infrastructure\Persistence\WPDBProductRepository;
use MyEnterpriseEcommerce\Catalog\Application\Handlers\CreateProductCommandHandler;
use MyEnterpriseEcommerce\Common\DI\Container; // Assuming a simple DIC
class CatalogServiceProvider {
public function register(Container $container) {
// Register the repository implementation
$container->singleton(ProductRepository::class, function(Container $c) {
global $wpdb; // Accessing global $wpdb
return new WPDBProductRepository($wpdb);
});
// Register the command handler, it will automatically resolve its dependencies
$container->register(CreateProductCommandHandler::class, function(Container $c) {
$productRepository = $c->get(ProductRepository::class);
return new CreateProductCommandHandler($productRepository);
});
// Register other services for the Catalog context...
}
}
Integrating with WordPress Hooks
The WordPress-specific integration layer (often in `includes/` or within each context’s `Infrastructure` or a dedicated `Presentation` layer) is responsible for hooking into WordPress actions and filters. This layer translates WordPress events into commands or queries for the application layer and formats the results for display.
Example: Hooking into `save_post` for Product Creation
This example assumes you’re using WordPress posts for products, which is a common scenario. A more robust solution might use custom tables or CPTs managed by the `Catalog` context itself.
namespace MyEnterpriseEcommerce\Includes;
use MyEnterpriseEcommerce\Catalog\Application\Commands\CreateProductCommand;
use MyEnterpriseEcommerce\Catalog\Application\Handlers\CreateProductCommandHandler;
use MyEnterpriseEcommerce\Common\DI\Container; // Assuming a simple DIC
class HookManager {
private Container $container;
public function __construct(Container $container) {
$this->container = $container;
}
public function registerHooks() {
// Example: Hooking into post save for a custom product post type
add_action('save_post_product', [$this, 'handleProductSave']);
}
public function handleProductSave(int $postId) {
// Prevent infinite loops and double saves
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (wp_is_post_revision($postId)) {
return;
}
// Check user permissions (important!)
if (!current_user_can('edit_post', $postId)) {
return;
}
// Retrieve data from $_POST (this is a simplified example)
$productName = $_POST['product_name'] ?? '';
$priceAmount = filter_input(INPUT_POST, 'product_price_amount', FILTER_VALIDATE_FLOAT);
$currency = $_POST['product_currency'] ?? 'USD';
$attributes = $_POST['product_attributes'] ?? []; // Assume this is an array of key/value pairs
if (empty($productName) || $priceAmount === false) {
// Handle validation errors, maybe add_settings_error()
return;
}
try {
// Create the command
$command = new CreateProductCommand($productName, $priceAmount, $currency, $attributes);
// Get the handler from the container
/** @var CreateProductCommandHandler $handler */
$handler = $this->container->get(CreateProductCommandHandler::class);
// Execute the command
$product = $handler->handle($command);
// Optionally, update post meta with the product ID or other info
update_post_meta($postId, '_ecommerce_product_id', $product->getId());
} catch (\Exception $e) {
// Log the error and potentially display a user-friendly message
error_log("Error saving product via hook: " . $e->getMessage());
// add_settings_error('my_ecommerce_plugin', 'save_error', 'Could not save product: ' . $e->getMessage(), 'error');
}
}
}
// In your main plugin file (my-enterprise-ecommerce.php):
// $container = new Container(); // Initialize your DIC
// (new CatalogServiceProvider())->register($container);
// (new OrderServiceProvider())->register($container); // Register other providers
// (new HookManager($container))->registerHooks();
Cross-Context Communication: Events and APIs
When Bounded Contexts need to interact, it should be done through well-defined interfaces. For asynchronous communication or broadcasting state changes, an event-driven approach is powerful. For synchronous requests, a simple API or service call can be used.
Example: Order Context Reacting to Product Update
When a product’s price changes in the `Catalog` context, the `Order` context might need to be notified. This can be achieved by the `Catalog` context publishing an event (e.g., `ProductPriceUpdatedEvent`) and the `Order` context subscribing to it.
// In Catalog\Domain\Services\ProductService.php (or similar)
// ... after updating price ...
$eventBus->publish(new ProductPriceUpdatedEvent($product->getId(), $newPrice));
// In Order\Infrastructure\EventSubscribers\ProductEventListener.php
// ... when registering event listeners ...
$eventBus->subscribe(ProductPriceUpdatedEvent::class, [$this, 'onProductPriceUpdated']);
// In Order\Infrastructure\EventSubscribers\ProductEventListener.php
public function onProductPriceUpdated(ProductPriceUpdatedEvent $event) {
// Find all carts/orders that contain this product and update their prices
// This would involve Order context's repositories and domain logic
$this->orderRepository->updatePricesForProductInCarts($event->getProductId(), $event->getNewPrice());
}
Benefits and Considerations
- Modularity: Easier to develop, test, and maintain individual components.
- Scalability: Individual contexts can be scaled independently.
- Team Autonomy: Teams can own specific Bounded Contexts.
- Testability: Unit and integration tests become more focused and manageable.
- Flexibility: Easier to swap out implementations (e.g., change payment gateway) within a context.
Considerations:
- Complexity: Initial setup can be more complex than a monolithic approach.
- Learning Curve: Requires understanding DDD concepts.
- Overhead: Managing inter-context communication adds overhead.
- Context Mapping: Defining clear boundaries and relationships between contexts is critical and can be challenging.
By applying DDD principles and structuring your WordPress plugin into distinct, well-defined Bounded Contexts, you can build more robust, maintainable, and scalable enterprise-level solutions. This approach moves away from the traditional “everything in one file” mentality towards a more organized and domain-centric architecture.