feat: module architecture improvements — session keys, dependency graph, event dispatcher, deactivation hooks
Six targeted improvements to the modular monolith platform: 1. Fix AddressBook session key prefix to follow module.<id>.* convention 2. Move ADDRESS_BOOK_VIEW permission constant from core PermissionService into module 3. Add declarative JSON schema for module manifests (.agents/contracts/) 4. Add `requires` field with missing-dependency and circular-dependency detection 5. Add lightweight fire-and-forget event dispatcher (user.created/deleted/login/logout) 6. Add module deactivation hook interface and CLI script (bin/module-deactivate.php) Includes 15 new architecture/unit tests covering all new functionality. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
18
lib/App/Module/Contracts/EventListener.php
Normal file
18
lib/App/Module/Contracts/EventListener.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
/**
|
||||
* Interface for module event listeners.
|
||||
*
|
||||
* Modules declare listeners in their manifest under 'event_listeners'.
|
||||
* The dispatcher resolves listeners from the container and calls handle().
|
||||
*/
|
||||
interface EventListener
|
||||
{
|
||||
/**
|
||||
* @param string $event Event name (e.g. 'user.deleted')
|
||||
* @param array<string, mixed> $payload Event-specific data
|
||||
*/
|
||||
public function handle(string $event, array $payload): void;
|
||||
}
|
||||
17
lib/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
17
lib/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Handles cleanup when a module is deactivated.
|
||||
*
|
||||
* Invoked by `bin/module-deactivate.php` after a module has been removed
|
||||
* from the enabled list. The handler receives the full app container and
|
||||
* can perform cleanup tasks like removing orphaned data or revoking permissions.
|
||||
*/
|
||||
interface ModuleDeactivationHandler
|
||||
{
|
||||
public function deactivate(AppContainer $container): void;
|
||||
}
|
||||
49
lib/App/Module/ModuleEventDispatcher.php
Normal file
49
lib/App/Module/ModuleEventDispatcher.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
|
||||
/**
|
||||
* Fire-and-forget event dispatcher for module lifecycle events.
|
||||
*
|
||||
* Listeners are declared in module manifests and lazy-resolved from the container.
|
||||
* A failing listener does not break the dispatch chain — the exception is swallowed
|
||||
* so that one misbehaving module cannot disrupt core flows.
|
||||
*/
|
||||
final class ModuleEventDispatcher
|
||||
{
|
||||
/**
|
||||
* @param array<string, list<array{class: class-string, method: string}>> $listenerMap
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $listenerMap,
|
||||
private readonly AppContainer $container
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function dispatch(string $event, array $payload): void
|
||||
{
|
||||
$listeners = $this->listenerMap[$event] ?? [];
|
||||
|
||||
foreach ($listeners as $descriptor) {
|
||||
try {
|
||||
$class = $descriptor['class'];
|
||||
$method = $descriptor['method'];
|
||||
|
||||
$listener = $this->container->get($class);
|
||||
if (!$listener instanceof EventListener) {
|
||||
$listener = new $class();
|
||||
}
|
||||
|
||||
$listener->{$method}($event, $payload);
|
||||
} catch (\Throwable) {
|
||||
// Swallow — one failing listener must not break the chain.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,15 @@ final class ModuleManifest
|
||||
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
|
||||
public readonly array $authorizationPolicies;
|
||||
|
||||
/** @var list<string> Module IDs required by this module */
|
||||
public readonly array $requires;
|
||||
|
||||
/** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
|
||||
public readonly array $eventListeners;
|
||||
|
||||
/** @var class-string|null ModuleDeactivationHandler FQCN */
|
||||
public readonly ?string $deactivationHandler;
|
||||
|
||||
public readonly ?string $migrationsPath;
|
||||
|
||||
public readonly string $basePath;
|
||||
@@ -107,6 +116,14 @@ final class ModuleManifest
|
||||
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
|
||||
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
|
||||
|
||||
$this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
|
||||
$this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);
|
||||
|
||||
$deactivationHandler = $raw['deactivation_handler'] ?? null;
|
||||
$this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
|
||||
? trim($deactivationHandler)
|
||||
: null;
|
||||
|
||||
$migrationsPath = $raw['migrations_path'] ?? null;
|
||||
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
||||
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
|
||||
@@ -332,6 +349,83 @@ final class ModuleManifest
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array{class: class-string, method: string}>>
|
||||
*/
|
||||
private static function normalizeEventListeners(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$listeners = [];
|
||||
foreach ($value as $eventName => $handlers) {
|
||||
$eventName = trim((string) $eventName);
|
||||
if ($eventName === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_array($handlers)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedHandlers = [];
|
||||
foreach (array_values($handlers) as $index => $handler) {
|
||||
if (!is_array($handler)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$class = trim((string) ($handler['class'] ?? ''));
|
||||
$method = trim((string) ($handler['method'] ?? ''));
|
||||
if ($class === '' || $method === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedHandlers[] = ['class' => $class, 'method' => $method];
|
||||
}
|
||||
|
||||
if ($normalizedHandlers !== []) {
|
||||
$listeners[$eventName] = $normalizedHandlers;
|
||||
}
|
||||
}
|
||||
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeRequires(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$requires = [];
|
||||
foreach (array_values($value) as $index => $item) {
|
||||
if (!is_string($item) || trim($item) === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
$requires[] = trim($item);
|
||||
}
|
||||
|
||||
return array_values(array_unique($requires));
|
||||
}
|
||||
|
||||
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
|
||||
@@ -87,6 +87,9 @@ final class ModuleRegistry
|
||||
/** @var array<string, string> scheduler job key => owning module id */
|
||||
private array $schedulerJobOwners = [];
|
||||
|
||||
/** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
|
||||
private array $mergedEventListeners = [];
|
||||
|
||||
/**
|
||||
* Boot the registry from a modules directory and activation config.
|
||||
*
|
||||
@@ -150,6 +153,7 @@ final class ModuleRegistry
|
||||
$registry->registerModule($manifest);
|
||||
}
|
||||
|
||||
$registry->validateDependencies();
|
||||
$registry->finalizeMergedContributions();
|
||||
|
||||
return $registry;
|
||||
@@ -354,6 +358,14 @@ final class ModuleRegistry
|
||||
$this->mergedAuthorizationPolicies[] = $policyClass;
|
||||
}
|
||||
|
||||
// — Merge event listeners ——————————————————————————
|
||||
foreach ($manifest->eventListeners as $eventName => $handlers) {
|
||||
if (!isset($this->mergedEventListeners[$eventName])) {
|
||||
$this->mergedEventListeners[$eventName] = [];
|
||||
}
|
||||
array_push($this->mergedEventListeners[$eventName], ...$handlers);
|
||||
}
|
||||
|
||||
// — Merge scheduler jobs —————————————————————————
|
||||
foreach ($manifest->schedulerJobs as $schedulerJob) {
|
||||
$jobKey = trim((string) $schedulerJob['job_key']);
|
||||
@@ -388,6 +400,52 @@ final class ModuleRegistry
|
||||
}
|
||||
}
|
||||
|
||||
private function validateDependencies(): void
|
||||
{
|
||||
$enabledIds = array_keys($this->modules);
|
||||
|
||||
// Check all required modules are enabled
|
||||
foreach ($this->modules as $manifest) {
|
||||
foreach ($manifest->requires as $requiredId) {
|
||||
if (!isset($this->modules[$requiredId])) {
|
||||
throw new RuntimeException(
|
||||
"Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for circular dependencies (DFS cycle detection)
|
||||
$visited = [];
|
||||
$inStack = [];
|
||||
|
||||
$visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
|
||||
if (isset($inStack[$moduleId])) {
|
||||
throw new RuntimeException(
|
||||
"Circular module dependency detected involving module '{$moduleId}'."
|
||||
);
|
||||
}
|
||||
if (isset($visited[$moduleId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inStack[$moduleId] = true;
|
||||
|
||||
if (isset($this->modules[$moduleId])) {
|
||||
foreach ($this->modules[$moduleId]->requires as $requiredId) {
|
||||
$visit($requiredId);
|
||||
}
|
||||
}
|
||||
|
||||
unset($inStack[$moduleId]);
|
||||
$visited[$moduleId] = true;
|
||||
};
|
||||
|
||||
foreach ($enabledIds as $moduleId) {
|
||||
$visit($moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private function finalizeMergedContributions(): void
|
||||
{
|
||||
$this->mergedPublicPaths = array_values(array_unique(array_filter(
|
||||
@@ -626,4 +684,10 @@ final class ModuleRegistry
|
||||
{
|
||||
return $this->mergedSchedulerJobs;
|
||||
}
|
||||
|
||||
/** @return array<string, list<array{class: class-string, method: string}>> */
|
||||
public function getEventListeners(): array
|
||||
{
|
||||
return $this->mergedEventListeners;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user