How to design a modular Command Query Responsibility Segregation (CQRS) architecture for enterprise-level custom plugins
Deconstructing WordPress Plugin Architecture with CQRS
Enterprise-level WordPress plugins often grapple with complexity. As features proliferate, the monolithic structure of a single plugin file or a tightly coupled set of classes becomes a bottleneck for development, testing, and maintenance. Command Query Responsibility Segregation (CQRS) offers a powerful architectural pattern to address this by separating the concerns of modifying state (Commands) from those of reading state (Queries). This post details how to design and implement a modular CQRS architecture within a WordPress plugin context, focusing on practical code examples and architectural considerations.
Core CQRS Principles in a WordPress Context
At its heart, CQRS advocates for distinct models for updating and reading data. In WordPress, this translates to separating the logic that writes to the database (e.g., saving plugin settings, creating custom post types, updating user meta) from the logic that retrieves and displays data (e.g., fetching settings, querying posts, rendering reports). This separation isn’t about separate databases by default, but rather distinct code paths and potentially different data retrieval strategies.
For a WordPress plugin, this means:
- Commands: Operations that change the system’s state. These are typically imperative and result in side effects (e.g., saving data).
- Queries: Operations that retrieve data without altering the system’s state. These are declarative and focus on returning specific data structures.
Structuring the Plugin for Modularity
A well-structured plugin is foundational. We’ll adopt a directory structure that clearly delineates our CQRS components. This promotes discoverability and maintainability.
Consider the following directory layout for your plugin:
your-plugin-name/
├── src/
│ ├── Commands/
│ │ ├── SaveSettingsCommand.php
│ │ └── ProcessUserDataCommand.php
│ ├── Queries/
│ │ ├── GetSettingsQuery.php
│ │ └── GetUserDataQuery.php
│ ├── Handlers/
│ │ ├── SaveSettingsCommandHandler.php
│ │ └── ProcessUserDataCommandHandler.php
│ │ └── GetSettingsQueryHandler.php
│ │ └── GetUserDataQueryHandler.php
│ ├── DataAccess/
│ │ ├── SettingsRepository.php
│ │ └── UserRepository.php
│ ├── Plugin.php
│ └── bootstrap.php
├── vendor/
└── your-plugin-name.php
Implementing Commands and Handlers
Commands are simple data transfer objects (DTOs) that encapsulate the intent and data required to perform an action. Handlers contain the actual business logic to execute the command.
Let’s define a command for saving plugin settings:
src/Commands/SaveSettingsCommand.php
namespace YourPlugin\Commands;
class SaveSettingsCommand
{
private array $settings;
public function __construct(array $settings)
{
$this->settings = $settings;
}
public function getSettings(): array
{
return $this->settings;
}
}
src/Handlers/SaveSettingsCommandHandler.php
This handler will interact with a repository to persist the settings. We’ll define the repository later.
namespace YourPlugin\Handlers;
use YourPlugin\Commands\SaveSettingsCommand;
use YourPlugin\DataAccess\SettingsRepository;
class SaveSettingsCommandHandler
{
private SettingsRepository $settingsRepository;
public function __construct(SettingsRepository $settingsRepository)
{
$this->settingsRepository = $settingsRepository;
}
public function handle(SaveSettingsCommand $command): void
{
$settings = $command->getSettings();
// Basic validation could be added here before persisting
$this->settingsRepository->save($settings);
}
}
Implementing Queries and Handlers
Queries are also DTOs, but they represent a request for data. Query handlers fetch and return data, ensuring they don’t modify state.
src/Queries/GetSettingsQuery.php
namespace YourPlugin\Queries;
class GetSettingsQuery
{
// This query might accept parameters, e.g., a specific setting key
// For simplicity, we'll fetch all settings here.
public function __construct() {}
}
src/Handlers/GetSettingsQueryHandler.php
This handler retrieves settings using the repository.
namespace YourPlugin\Handlers;
use YourPlugin\Queries\GetSettingsQuery;
use YourPlugin\DataAccess\SettingsRepository;
class GetSettingsQueryHandler
{
private SettingsRepository $settingsRepository;
public function __construct(SettingsRepository $settingsRepository)
{
$this->settingsRepository = $settingsRepository;
}
public function handle(GetSettingsQuery $query): array
{
// The query object itself might contain criteria, but for GetSettingsQuery,
// we're simply asking for all settings.
return $this->settingsRepository->getAll();
}
}
Data Access Layer (Repositories)
The Data Access Layer abstracts the underlying storage mechanism. In WordPress, this typically means interacting with the WordPress database API ($wpdb), options API, or custom tables.
src/DataAccess/SettingsRepository.php
namespace YourPlugin\DataAccess;
use WP_Error;
class SettingsRepository
{
private string $option_name = 'your_plugin_settings';
public function save(array $settings): bool
{
// Sanitize settings before saving
$sanitized_settings = $this->sanitize_settings($settings);
return update_option($this->option_name, $sanitized_settings);
}
public function getAll(): array
{
$settings = get_option($this->option_name, []);
// Ensure it's an array, even if option is not set or corrupted
return is_array($settings) ? $settings : [];
}
public function get(string $key, $default = null)
{
$settings = $this->getAll();
return $settings[$key] ?? $default;
}
private function sanitize_settings(array $settings): array
{
// Implement robust sanitization based on expected setting types
// Example:
$sanitized = [];
foreach ($settings as $key => $value) {
switch ($key) {
case 'api_key':
$sanitized[$key] = sanitize_text_field($value);
break;
case 'enable_feature':
$sanitized[$key] = filter_var($value, FILTER_VALIDATE_BOOLEAN);
break;
case 'threshold':
$sanitized[$key] = absint($value);
break;
default:
// Handle unknown settings or use a generic sanitizer
$sanitized[$key] = sanitize_text_field($value);
break;
}
}
return $sanitized;
}
}
Dependency Injection and Service Container
To manage dependencies between commands, handlers, and repositories, a simple dependency injection (DI) container or service locator pattern is highly beneficial. This makes the system more testable and flexible.
src/bootstrap.php (Simplified DI Container)
namespace YourPlugin;
use YourPlugin\Handlers\SaveSettingsCommandHandler;
use YourPlugin\Handlers\GetSettingsQueryHandler;
use YourPlugin\DataAccess\SettingsRepository;
class ServiceContainer
{
private array $services = [];
public function __construct()
{
// Register services
$this->services[SettingsRepository::class] = function() {
return new SettingsRepository();
};
$this->services[SaveSettingsCommandHandler::class] = function($container) {
$settingsRepository = $container->get(SettingsRepository::class);
return new SaveSettingsCommandHandler($settingsRepository);
};
$this->services[GetSettingsQueryHandler::class] = function($container) {
$settingsRepository = $container->get(SettingsRepository::class);
return new GetSettingsQueryHandler($settingsRepository);
};
}
public function get(string $class_name)
{
if (!isset($this->services[$class_name])) {
throw new \InvalidArgumentException("Service {$class_name} not found.");
}
// Simple singleton-like behavior for registered services
if (!isset($this->services[$class_name]) || is_callable($this->services[$class_name])) {
$this->services[$class_name] = $this->services[$class_name]($this);
}
return $this->services[$class_name];
}
}
// In your main plugin file or an autoloader:
// require_once __DIR__ . '/src/bootstrap.php';
// $container = new ServiceContainer();
Dispatching Commands and Queries
A central dispatcher (or bus) is responsible for routing commands and queries to their respective handlers. For simplicity, we can implement this directly or use a dedicated library.
src/Plugin.php (Dispatcher Example)
namespace YourPlugin;
use YourPlugin\Commands\SaveSettingsCommand;
use YourPlugin\Queries\GetSettingsQuery;
use YourPlugin\Handlers\SaveSettingsCommandHandler;
use YourPlugin\Handlers\GetSettingsQueryHandler;
use Psr\Container\ContainerInterface; // Assuming PSR-11 compatible container
class Plugin
{
private ContainerInterface $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
public function handleCommand(object $command): void
{
if ($command instanceof SaveSettingsCommand) {
$handler = $this->container->get(SaveSettingsCommandHandler::class);
$handler->handle($command);
}
// Add other command types here
}
public function handleQuery(object $query): mixed
{
if ($query instanceof GetSettingsQuery) {
$handler = $this->container->get(GetSettingsQueryHandler::class);
return $handler->handle($query);
}
// Add other query types here
return null; // Or throw an exception
}
}
Integration with WordPress Hooks and Admin UI
The CQRS components are triggered via WordPress hooks, typically within admin pages or AJAX requests.
your-plugin-name.php (Main Plugin File)
use YourPlugin\ServiceContainer;
use YourPlugin\Commands\SaveSettingsCommand;
use YourPlugin\Queries\GetSettingsQuery;
use YourPlugin\Plugin;
// Ensure autoloader is set up (e.g., via Composer)
require_once __DIR__ . '/vendor/autoload.php';
// Initialize Service Container
$container = new ServiceContainer();
$plugin = new Plugin($container);
// Example: Hook into WordPress settings API or a custom admin page save action
add_action('admin_post_your_plugin_save_settings', function() use ($plugin) {
if (!current_user_can('manage_options')) {
wp_die('Unauthorized');
}
// Nonce verification is crucial here
check_admin_referer('your_plugin_save_settings_nonce');
$settings_data = $_POST['your_plugin_settings'] ?? [];
if (!empty($settings_data)) {
$command = new SaveSettingsCommand($settings_data);
$plugin->handleCommand($command);
// Redirect back to settings page with success message
wp_redirect(admin_url('admin.php?page=your-plugin-settings&message=settings_saved'));
exit;
}
});
// Example: Fetching settings for an admin page
function display_your_plugin_settings_page() {
global $container, $plugin; // Assuming container and plugin are globally accessible or passed differently
// If not global, initialize here or pass from a central point
if (!isset($container) || !isset($plugin)) {
$container = new ServiceContainer();
$plugin = new Plugin($container);
}
$query = new GetSettingsQuery();
$settings = $plugin->handleQuery($query);
// Render your settings form using $settings
?>
<div class="wrap">
<h1>Your Plugin Settings</h1>
<div class="notice notice-success is-dismissible"><p>Settings saved successfully.</p></div>
<form method="post" action="">
<input type="hidden" name="action" value="your_plugin_save_settings">
<table class="form-table">
<tr>
<th><label for="api_key">API Key</label></th>
<td><input type="text" id="api_key" name="your_plugin_settings[api_key]" value="" class="regular-text"></td>
</tr>
<tr>
<th><label for="enable_feature">Enable Feature</label></th>
<td>
<input type="checkbox" id="enable_feature" name="your_plugin_settings[enable_feature]" value="1" >
<p class="description">Enables the core feature.</p>
</td>
</tr>
<tr>
<th><label for="threshold">Threshold</label></th>
<td><input type="number" id="threshold" name="your_plugin_settings[threshold]" value="" class="regular-text"></td>
</tr>
</table>
<p class="submit"><input type="submit" name="submit" id="submit" class="button button-primary" value="Save Changes"></p>
</form>
</div>
Advanced Considerations: Event Sourcing and Domain Events
For highly complex systems, you might consider extending this pattern further:
- Event Sourcing: Instead of storing the current state, store a sequence of domain events that led to that state. Commands would generate events, and a separate process would build the current state from these events. This provides a full audit log and allows rebuilding state at any point.
- Domain Events: Publish domain-specific events when significant state changes occur (e.g., `UserRegistered`, `OrderShipped`). Other parts of the system can subscribe to these events to react, further decoupling components.
Testing Strategies
The modularity introduced by CQRS significantly improves testability:
- Unit Tests for Handlers: Mock repositories and test handlers in isolation, ensuring they correctly process commands/queries and interact with their dependencies.
- Unit Tests for Repositories: Test data access logic against a test database or using mocks.
- Integration Tests: Test the flow from command dispatch to data persistence and retrieval.
- End-to-End Tests: Simulate user interactions via the WordPress admin UI or API.
Conclusion
Adopting a CQRS-inspired architecture for your WordPress plugins can lead to more maintainable, scalable, and testable codebases. By clearly separating state modification (Commands) from state retrieval (Queries) and leveraging dependency injection, you can build robust enterprise-grade solutions that stand the test of time and feature growth.