1982 lines
54 KiB
Markdown
1982 lines
54 KiB
Markdown
# Architecture Improvements
|
|
|
|
Comprehensive guide for improving the Custom PHP Framework architecture.
|
|
|
|
**Status**: Planning Phase
|
|
**Last Updated**: 2025-01-28
|
|
**Priority**: High Impact, Low Risk Improvements
|
|
|
|
---
|
|
|
|
## 📊 Current Architecture Assessment
|
|
|
|
### ✅ Strengths (Already Well Implemented)
|
|
|
|
- **No Inheritance Principle**: Composition over Inheritance consistently applied
|
|
- **Value Objects**: Extensive use of VOs (Email, Money, Duration, UserId, etc.)
|
|
- **Event System**: Event-driven architecture with `#[OnEvent]` attribute
|
|
- **Queue System**: Async job processing with 13-table database schema
|
|
- **MCP Integration**: AI-powered framework analysis and tooling
|
|
- **Attribute-based Discovery**: Convention over Configuration
|
|
- **Database Patterns**: EntityManager, UnitOfWork, Repository Pattern
|
|
- **Readonly Classes**: Immutable by design throughout framework
|
|
- **Type Safety**: Union types, strict types, no primitive obsession
|
|
|
|
### ⚠️ Areas for Improvement
|
|
|
|
1. Read and Write operations use same models (no CQRS)
|
|
2. External API calls scattered across services (no unified gateway)
|
|
3. Infrastructure coupled to domain logic (limited hexagonal architecture)
|
|
4. Complex reporting queries with performance bottlenecks
|
|
5. Domain events not fully leveraged as first-class citizens
|
|
|
|
---
|
|
|
|
## 🎯 Top 5 Architecture Improvements
|
|
|
|
### 1. CQRS Pattern for Performance-Critical Areas ⭐⭐⭐
|
|
|
|
**Priority**: High
|
|
**Complexity**: Medium
|
|
**Impact**: High Performance Gains (3-5x faster reads)
|
|
**Time Estimate**: 1-2 weeks
|
|
|
|
#### Problem Statement
|
|
|
|
Currently, read and write operations use the same models and queries through EntityManager:
|
|
|
|
```php
|
|
// Current approach - same model for read and write
|
|
final readonly class UserService
|
|
{
|
|
public function getUser(UserId $id): User
|
|
{
|
|
return $this->entityManager->find(User::class, $id); // Read
|
|
}
|
|
|
|
public function updateUser(User $user): void
|
|
{
|
|
$this->entityManager->save($user); // Write
|
|
}
|
|
}
|
|
```
|
|
|
|
**Issues**:
|
|
- Complex domain entities loaded for simple read operations
|
|
- Queries require multiple JOINs for denormalized data
|
|
- Write operations block read performance
|
|
- No optimization path for read-heavy operations
|
|
|
|
#### Solution: Command Query Responsibility Segregation
|
|
|
|
Separate read and write models with different optimization strategies.
|
|
|
|
**Command Side (Write Model)**:
|
|
```php
|
|
namespace App\Domain\User\Commands;
|
|
|
|
final readonly class CreateUserCommand
|
|
{
|
|
public function __construct(
|
|
public Email $email,
|
|
public UserName $name,
|
|
public Password $password
|
|
) {}
|
|
}
|
|
|
|
final readonly class CreateUserHandler
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManager $entityManager,
|
|
private readonly EventBus $eventBus
|
|
) {}
|
|
|
|
public function handle(CreateUserCommand $command): User
|
|
{
|
|
// Write model - full domain logic
|
|
$user = User::create($command);
|
|
$this->entityManager->save($user);
|
|
|
|
// Event triggers read model update
|
|
$this->eventBus->dispatch(new UserCreatedEvent($user));
|
|
|
|
return $user;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Query Side (Read Model)**:
|
|
```php
|
|
namespace App\Domain\User\Queries;
|
|
|
|
final readonly class UserQuery
|
|
{
|
|
public function __construct(
|
|
private readonly Connection $connection
|
|
) {}
|
|
|
|
public function findById(UserId $id): UserReadModel
|
|
{
|
|
// Optimized query directly on denormalized read model
|
|
$data = $this->connection->fetchOne(
|
|
'SELECT id, email, name, profile_data, last_login
|
|
FROM user_read_model
|
|
WHERE id = ?',
|
|
[$id->value]
|
|
);
|
|
|
|
return UserReadModel::fromArray($data);
|
|
}
|
|
|
|
public function findActiveUsers(int $limit = 50): array
|
|
{
|
|
// Denormalized data for fast reads
|
|
return $this->connection->fetchAll(
|
|
'SELECT * FROM user_read_model
|
|
WHERE active = 1
|
|
ORDER BY last_login DESC
|
|
LIMIT ?',
|
|
[$limit]
|
|
);
|
|
}
|
|
|
|
public function searchUsers(string $query): array
|
|
{
|
|
// Optimized full-text search on read model
|
|
return $this->connection->fetchAll(
|
|
'SELECT * FROM user_read_model
|
|
WHERE MATCH(name, email) AGAINST(? IN BOOLEAN MODE)
|
|
LIMIT 100',
|
|
[$query]
|
|
);
|
|
}
|
|
}
|
|
|
|
final readonly class UserReadModel
|
|
{
|
|
public function __construct(
|
|
public string $id,
|
|
public string $email,
|
|
public string $name,
|
|
public array $profileData,
|
|
public bool $active,
|
|
public ?\DateTimeImmutable $lastLogin,
|
|
public \DateTimeImmutable $createdAt
|
|
) {}
|
|
|
|
public static function fromArray(array $data): self
|
|
{
|
|
return new self(
|
|
id: $data['id'],
|
|
email: $data['email'],
|
|
name: $data['name'],
|
|
profileData: json_decode($data['profile_data'], true),
|
|
active: (bool) $data['active'],
|
|
lastLogin: $data['last_login'] ? new \DateTimeImmutable($data['last_login']) : null,
|
|
createdAt: new \DateTimeImmutable($data['created_at'])
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Read Model Projector (Async Update)**:
|
|
```php
|
|
namespace App\Domain\User\Projectors;
|
|
|
|
final readonly class UserReadModelProjector
|
|
{
|
|
public function __construct(
|
|
private readonly Connection $connection
|
|
) {}
|
|
|
|
#[OnEvent(priority: 50)]
|
|
public function onUserCreated(UserCreatedEvent $event): void
|
|
{
|
|
$this->connection->insert('user_read_model', [
|
|
'id' => $event->userId->value,
|
|
'email' => $event->email->value,
|
|
'name' => $event->name->value,
|
|
'profile_data' => json_encode([]),
|
|
'active' => 1,
|
|
'last_login' => null,
|
|
'created_at' => $event->occurredAt->format('Y-m-d H:i:s')
|
|
]);
|
|
}
|
|
|
|
#[OnEvent(priority: 50)]
|
|
public function onUserUpdated(UserUpdatedEvent $event): void
|
|
{
|
|
$this->connection->update('user_read_model', [
|
|
'name' => $event->newName->value,
|
|
'updated_at' => $event->occurredAt->format('Y-m-d H:i:s')
|
|
], [
|
|
'id' => $event->userId->value
|
|
]);
|
|
}
|
|
|
|
#[OnEvent(priority: 50)]
|
|
public function onUserLoggedIn(UserLoggedInEvent $event): void
|
|
{
|
|
$this->connection->update('user_read_model', [
|
|
'last_login' => $event->occurredAt->format('Y-m-d H:i:s')
|
|
], [
|
|
'id' => $event->userId->value
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Database Schema for Read Models
|
|
|
|
```sql
|
|
-- User Read Model (Denormalized)
|
|
CREATE TABLE user_read_model (
|
|
id VARCHAR(26) PRIMARY KEY,
|
|
email VARCHAR(255) NOT NULL,
|
|
name VARCHAR(255) NOT NULL,
|
|
profile_data JSON, -- Denormalized profile information
|
|
active BOOLEAN DEFAULT 1,
|
|
last_login DATETIME NULL,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NULL,
|
|
|
|
INDEX idx_active (active, last_login),
|
|
INDEX idx_created (created_at),
|
|
FULLTEXT idx_search (name, email)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Order Read Model (Denormalized with User Info)
|
|
CREATE TABLE order_read_model (
|
|
id VARCHAR(26) PRIMARY KEY,
|
|
user_id VARCHAR(26) NOT NULL,
|
|
user_name VARCHAR(255) NOT NULL, -- Denormalized
|
|
user_email VARCHAR(255) NOT NULL, -- Denormalized
|
|
total_cents INT NOT NULL,
|
|
currency VARCHAR(3) DEFAULT 'EUR',
|
|
status VARCHAR(20) NOT NULL,
|
|
items_count INT NOT NULL, -- Denormalized
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NULL,
|
|
|
|
INDEX idx_user_orders (user_id, created_at DESC),
|
|
INDEX idx_status (status),
|
|
INDEX idx_created (created_at DESC)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Product Read Model (Denormalized with Stock)
|
|
CREATE TABLE product_read_model (
|
|
id VARCHAR(26) PRIMARY KEY,
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
price_cents INT NOT NULL,
|
|
currency VARCHAR(3) DEFAULT 'EUR',
|
|
stock_available INT NOT NULL, -- Denormalized from inventory
|
|
category VARCHAR(100),
|
|
active BOOLEAN DEFAULT 1,
|
|
created_at DATETIME NOT NULL,
|
|
|
|
INDEX idx_active (active, created_at),
|
|
INDEX idx_category (category, active),
|
|
INDEX idx_stock (stock_available),
|
|
FULLTEXT idx_search (name, description)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
```
|
|
|
|
#### Migration Strategy
|
|
|
|
**Step 1: Create Read Model Tables**
|
|
```bash
|
|
php console.php make:migration CreateUserReadModelTable
|
|
php console.php make:migration CreateOrderReadModelTable
|
|
php console.php make:migration CreateProductReadModelTable
|
|
```
|
|
|
|
**Step 2: Initial Data Population**
|
|
```php
|
|
final readonly class PopulateUserReadModelJob
|
|
{
|
|
public function handle(
|
|
EntityManager $entityManager,
|
|
Connection $connection
|
|
): void {
|
|
$batchSize = 1000;
|
|
$offset = 0;
|
|
|
|
do {
|
|
$users = $entityManager->createQueryBuilder()
|
|
->select('*')
|
|
->from('users')
|
|
->limit($batchSize)
|
|
->offset($offset)
|
|
->fetchAll();
|
|
|
|
foreach ($users as $userData) {
|
|
$connection->insert('user_read_model', [
|
|
'id' => $userData['id'],
|
|
'email' => $userData['email'],
|
|
'name' => $userData['name'],
|
|
'profile_data' => $userData['profile_data'] ?? '{}',
|
|
'active' => $userData['active'],
|
|
'last_login' => $userData['last_login'],
|
|
'created_at' => $userData['created_at']
|
|
]);
|
|
}
|
|
|
|
$offset += $batchSize;
|
|
|
|
} while (count($users) === $batchSize);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Deploy Projectors**
|
|
- Event handlers automatically keep read models in sync
|
|
- Eventually consistent (async updates acceptable)
|
|
|
|
**Step 4: Gradual Migration**
|
|
```php
|
|
// Phase 1: Keep both approaches running
|
|
public function getUser(UserId $id): User
|
|
{
|
|
// Old approach still works
|
|
return $this->entityManager->find(User::class, $id);
|
|
}
|
|
|
|
public function getUserReadModel(UserId $id): UserReadModel
|
|
{
|
|
// New approach for reads
|
|
return $this->userQuery->findById($id);
|
|
}
|
|
|
|
// Phase 2: Switch consumers to read model
|
|
// Phase 3: Remove old read queries (keep write model)
|
|
```
|
|
|
|
#### Benefits
|
|
|
|
- ⚡ **Read Performance**: 3-5x faster through denormalization
|
|
- 🔧 **Query Optimization**: Independent read model schemas optimized for queries
|
|
- 📊 **Analytics**: Read models designed for reporting without impacting write model
|
|
- 🔄 **Eventual Consistency**: Write model immediately consistent, read model async (acceptable for most use cases)
|
|
- 🧪 **Testing**: Easier to test read and write logic independently
|
|
- 📈 **Scalability**: Read and write databases can scale independently
|
|
|
|
#### When to Use CQRS
|
|
|
|
**✅ Good Candidates**:
|
|
- User listings with filters/search
|
|
- Dashboard/analytics queries
|
|
- Product catalogs
|
|
- Order history
|
|
- Reports with multiple JOINs
|
|
- High read-to-write ratio (10:1 or higher)
|
|
|
|
**❌ Avoid CQRS for**:
|
|
- Simple CRUD with 1:1 read-write ratio
|
|
- Real-time data requirements (no eventual consistency acceptable)
|
|
- Small tables (< 1000 rows)
|
|
- Development/prototyping phase
|
|
|
|
#### Performance Comparison
|
|
|
|
```
|
|
Benchmark: Load User Dashboard (1000 users)
|
|
|
|
Before CQRS (EntityManager with JOINs):
|
|
Query: SELECT * FROM users u
|
|
LEFT JOIN profiles p ON p.user_id = u.id
|
|
LEFT JOIN settings s ON s.user_id = u.id
|
|
LEFT JOIN orders o ON o.user_id = u.id
|
|
Time: 850ms
|
|
|
|
After CQRS (Read Model):
|
|
Query: SELECT * FROM user_read_model
|
|
WHERE active = 1
|
|
ORDER BY last_login DESC
|
|
LIMIT 1000
|
|
Time: 120ms
|
|
|
|
Performance Gain: 7x faster
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Domain Events as First-Class Citizens ⭐⭐⭐
|
|
|
|
**Priority**: High
|
|
**Complexity**: Low
|
|
**Impact**: Better Audit Trail, Event Sourcing Foundation
|
|
**Time Estimate**: 3-5 days
|
|
|
|
#### Problem Statement
|
|
|
|
Domain events are currently dispatched from services after operations, but not truly part of the domain model lifecycle.
|
|
|
|
```php
|
|
// Current approach - events dispatched externally
|
|
final readonly class OrderService
|
|
{
|
|
public function createOrder(CreateOrderCommand $command): Order
|
|
{
|
|
$order = Order::create($command);
|
|
$this->entityManager->save($order);
|
|
|
|
// Event dispatched after the fact
|
|
$this->eventBus->dispatch(new OrderCreatedEvent($order));
|
|
|
|
return $order;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Issues**:
|
|
- Events not part of domain model behavior
|
|
- Easy to forget event dispatch
|
|
- No audit trail of domain changes
|
|
- Cannot replay events to reconstruct state
|
|
|
|
#### Solution: Event Recording in Domain Entities
|
|
|
|
**Domain Entity with Event Recording**:
|
|
```php
|
|
namespace App\Domain\Order;
|
|
|
|
final readonly class Order
|
|
{
|
|
private array $domainEvents = [];
|
|
|
|
private function __construct(
|
|
public OrderId $id,
|
|
public UserId $userId,
|
|
public OrderStatus $status,
|
|
public Money $total,
|
|
public Timestamp $createdAt
|
|
) {}
|
|
|
|
public static function create(CreateOrderCommand $command): self
|
|
{
|
|
$order = new self(
|
|
id: OrderId::generate(),
|
|
userId: $command->userId,
|
|
status: OrderStatus::PENDING,
|
|
total: $command->total,
|
|
createdAt: Timestamp::now()
|
|
);
|
|
|
|
// Record event as part of domain operation
|
|
$order->recordEvent(new OrderCreatedEvent(
|
|
orderId: $order->id,
|
|
userId: $order->userId,
|
|
total: $order->total,
|
|
occurredAt: $order->createdAt
|
|
));
|
|
|
|
return $order;
|
|
}
|
|
|
|
public function confirm(): self
|
|
{
|
|
if (!$this->canConfirm()) {
|
|
throw new OrderCannotBeConfirmedException($this->id);
|
|
}
|
|
|
|
$confirmed = new self(
|
|
id: $this->id,
|
|
userId: $this->userId,
|
|
status: OrderStatus::CONFIRMED,
|
|
total: $this->total,
|
|
createdAt: $this->createdAt
|
|
);
|
|
|
|
$confirmed->recordEvent(new OrderConfirmedEvent(
|
|
orderId: $confirmed->id,
|
|
confirmedAt: Timestamp::now()
|
|
));
|
|
|
|
return $confirmed;
|
|
}
|
|
|
|
public function cancel(string $reason): self
|
|
{
|
|
if (!$this->canCancel()) {
|
|
throw new OrderCannotBeCancelledException($this->id);
|
|
}
|
|
|
|
$cancelled = new self(
|
|
id: $this->id,
|
|
userId: $this->userId,
|
|
status: OrderStatus::CANCELLED,
|
|
total: $this->total,
|
|
createdAt: $this->createdAt
|
|
);
|
|
|
|
$cancelled->recordEvent(new OrderCancelledEvent(
|
|
orderId: $cancelled->id,
|
|
reason: $reason,
|
|
cancelledAt: Timestamp::now()
|
|
));
|
|
|
|
return $cancelled;
|
|
}
|
|
|
|
private function recordEvent(DomainEvent $event): void
|
|
{
|
|
$this->domainEvents[] = $event;
|
|
}
|
|
|
|
public function releaseEvents(): array
|
|
{
|
|
$events = $this->domainEvents;
|
|
$this->domainEvents = [];
|
|
return $events;
|
|
}
|
|
|
|
private function canConfirm(): bool
|
|
{
|
|
return $this->status->equals(OrderStatus::PENDING);
|
|
}
|
|
|
|
private function canCancel(): bool
|
|
{
|
|
return !$this->status->equals(OrderStatus::CANCELLED)
|
|
&& !$this->status->equals(OrderStatus::COMPLETED);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Service Layer Dispatches Events**:
|
|
```php
|
|
final readonly class OrderService
|
|
{
|
|
public function createOrder(CreateOrderCommand $command): Order
|
|
{
|
|
// Domain operation records events
|
|
$order = Order::create($command);
|
|
|
|
$this->entityManager->save($order);
|
|
|
|
// Release and dispatch all recorded events
|
|
foreach ($order->releaseEvents() as $event) {
|
|
$this->eventBus->dispatch($event);
|
|
}
|
|
|
|
return $order;
|
|
}
|
|
|
|
public function confirmOrder(OrderId $orderId): Order
|
|
{
|
|
$order = $this->entityManager->find(Order::class, $orderId->value);
|
|
|
|
if ($order === null) {
|
|
throw new OrderNotFoundException($orderId);
|
|
}
|
|
|
|
$confirmed = $order->confirm();
|
|
|
|
$this->entityManager->save($confirmed);
|
|
|
|
foreach ($confirmed->releaseEvents() as $event) {
|
|
$this->eventBus->dispatch($event);
|
|
}
|
|
|
|
return $confirmed;
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Event Store for Complete Audit Trail
|
|
|
|
```php
|
|
namespace App\Framework\EventStore;
|
|
|
|
final readonly class DomainEventStore
|
|
{
|
|
public function __construct(
|
|
private readonly Connection $connection
|
|
) {}
|
|
|
|
#[OnEvent(priority: 200)] // Highest priority - always store first
|
|
public function storeEvent(DomainEvent $event): void
|
|
{
|
|
$this->connection->insert('domain_events', [
|
|
'id' => Ulid::generate(),
|
|
'aggregate_type' => $event->getAggregateType(),
|
|
'aggregate_id' => $event->getAggregateId(),
|
|
'event_type' => get_class($event),
|
|
'event_data' => json_encode($event),
|
|
'event_version' => $event->getVersion(),
|
|
'occurred_at' => $event->occurredAt->format('Y-m-d H:i:s'),
|
|
'user_id' => $this->getCurrentUserId(),
|
|
'metadata' => json_encode($this->getMetadata())
|
|
]);
|
|
}
|
|
|
|
public function getEventsForAggregate(
|
|
string $aggregateType,
|
|
string $aggregateId
|
|
): array {
|
|
return $this->connection->fetchAll(
|
|
'SELECT * FROM domain_events
|
|
WHERE aggregate_type = ? AND aggregate_id = ?
|
|
ORDER BY occurred_at ASC',
|
|
[$aggregateType, $aggregateId]
|
|
);
|
|
}
|
|
|
|
public function replayEvents(string $aggregateType, string $aggregateId): object
|
|
{
|
|
$events = $this->getEventsForAggregate($aggregateType, $aggregateId);
|
|
|
|
// Rebuild aggregate state from events
|
|
$aggregate = null;
|
|
|
|
foreach ($events as $eventData) {
|
|
$event = $this->deserializeEvent($eventData);
|
|
$aggregate = $this->applyEvent($aggregate, $event);
|
|
}
|
|
|
|
return $aggregate;
|
|
}
|
|
|
|
public function getEventStream(
|
|
?\DateTimeImmutable $since = null,
|
|
?int $limit = null
|
|
): array {
|
|
$sql = 'SELECT * FROM domain_events WHERE 1=1';
|
|
$params = [];
|
|
|
|
if ($since !== null) {
|
|
$sql .= ' AND occurred_at >= ?';
|
|
$params[] = $since->format('Y-m-d H:i:s');
|
|
}
|
|
|
|
$sql .= ' ORDER BY occurred_at ASC';
|
|
|
|
if ($limit !== null) {
|
|
$sql .= ' LIMIT ?';
|
|
$params[] = $limit;
|
|
}
|
|
|
|
return $this->connection->fetchAll($sql, $params);
|
|
}
|
|
|
|
private function deserializeEvent(array $eventData): DomainEvent
|
|
{
|
|
$eventClass = $eventData['event_type'];
|
|
$eventPayload = json_decode($eventData['event_data'], true);
|
|
|
|
return $eventClass::fromArray($eventPayload);
|
|
}
|
|
|
|
private function applyEvent(?object $aggregate, DomainEvent $event): object
|
|
{
|
|
if ($aggregate === null) {
|
|
// First event - create aggregate
|
|
return $this->createAggregateFromEvent($event);
|
|
}
|
|
|
|
// Apply event to existing aggregate
|
|
return $aggregate->applyEvent($event);
|
|
}
|
|
|
|
private function getCurrentUserId(): ?string
|
|
{
|
|
// Get from authentication context
|
|
return $_SESSION['user_id'] ?? null;
|
|
}
|
|
|
|
private function getMetadata(): array
|
|
{
|
|
return [
|
|
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
|
|
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
|
'request_id' => $_SERVER['X_REQUEST_ID'] ?? null
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Database Schema
|
|
|
|
```sql
|
|
CREATE TABLE domain_events (
|
|
id VARCHAR(26) PRIMARY KEY,
|
|
aggregate_type VARCHAR(255) NOT NULL,
|
|
aggregate_id VARCHAR(26) NOT NULL,
|
|
event_type VARCHAR(255) NOT NULL,
|
|
event_data JSON NOT NULL,
|
|
event_version INT NOT NULL DEFAULT 1,
|
|
occurred_at DATETIME NOT NULL,
|
|
user_id VARCHAR(26) NULL,
|
|
metadata JSON NULL,
|
|
|
|
INDEX idx_aggregate (aggregate_type, aggregate_id, occurred_at),
|
|
INDEX idx_event_type (event_type, occurred_at),
|
|
INDEX idx_occurred (occurred_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
```
|
|
|
|
#### Benefits
|
|
|
|
- 📜 **Complete Audit Trail**: Every domain change recorded
|
|
- 🔄 **Event Replay**: Reconstruct aggregate state from events
|
|
- 🐛 **Time-Travel Debugging**: Replay events to specific point in time
|
|
- 📊 **Business Intelligence**: Event stream for analytics
|
|
- 🔍 **Compliance**: Full audit trail for regulatory requirements
|
|
- 🧪 **Testing**: Event-based assertions for behavior testing
|
|
|
|
#### Example: Event Replay for Debugging
|
|
|
|
```php
|
|
// Reproduce bug from production
|
|
$eventStore = $container->get(DomainEventStore::class);
|
|
|
|
// Get all events for problematic order
|
|
$events = $eventStore->getEventsForAggregate('Order', 'order-123');
|
|
|
|
// Replay events to see state at each step
|
|
foreach ($events as $eventData) {
|
|
$event = $eventStore->deserializeEvent($eventData);
|
|
echo "Event: " . get_class($event) . "\n";
|
|
echo "Occurred: " . $eventData['occurred_at'] . "\n";
|
|
echo "Data: " . json_encode($event, JSON_PRETTY_PRINT) . "\n\n";
|
|
}
|
|
|
|
// Reconstruct final state
|
|
$order = $eventStore->replayEvents('Order', 'order-123');
|
|
```
|
|
|
|
---
|
|
|
|
### 3. API Gateway Pattern for External Integrations ⭐⭐
|
|
|
|
**Priority**: High
|
|
**Complexity**: Low-Medium
|
|
**Impact**: Centralized Control, Better Monitoring
|
|
**Time Estimate**: 2-3 days
|
|
|
|
#### Problem Statement
|
|
|
|
External API calls are currently scattered across the codebase with inconsistent error handling, retry logic, and monitoring.
|
|
|
|
```php
|
|
// Current approach - direct API calls everywhere
|
|
final readonly class OrderService
|
|
{
|
|
public function processOrder(Order $order): void
|
|
{
|
|
// Direct Stripe call
|
|
$this->stripeClient->charge($order->total);
|
|
|
|
// Direct SendGrid call
|
|
$this->sendgridClient->sendEmail($order->user->email);
|
|
|
|
// Direct Inventory API call
|
|
$this->inventoryApi->reserve($order->items);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Issues**:
|
|
- No centralized monitoring of external calls
|
|
- Inconsistent retry strategies
|
|
- No circuit breaker protection
|
|
- Difficult to test (mocking multiple clients)
|
|
- No unified error handling
|
|
|
|
#### Solution: Unified API Gateway
|
|
|
|
**Gateway Interface**:
|
|
```php
|
|
namespace App\Framework\ApiGateway;
|
|
|
|
interface ApiGateway
|
|
{
|
|
public function call(ApiRequest $request): ApiResponse;
|
|
public function callAsync(ApiRequest $request): AsyncPromise;
|
|
}
|
|
```
|
|
|
|
**API Request Value Object**:
|
|
```php
|
|
namespace App\Framework\ApiGateway\ValueObjects;
|
|
|
|
final readonly class ApiRequest
|
|
{
|
|
public function __construct(
|
|
public ApiEndpoint $endpoint,
|
|
public HttpMethod $method,
|
|
public array $payload = [],
|
|
public array $headers = [],
|
|
public ?Duration $timeout = null,
|
|
public ?RetryStrategy $retryStrategy = null
|
|
) {}
|
|
|
|
public static function payment(string $action, array $data): self
|
|
{
|
|
return new self(
|
|
endpoint: ApiEndpoint::payment($action),
|
|
method: HttpMethod::POST,
|
|
payload: $data,
|
|
timeout: Duration::fromSeconds(30),
|
|
retryStrategy: new ExponentialBackoffStrategy(maxAttempts: 3)
|
|
);
|
|
}
|
|
|
|
public static function email(string $action, array $data): self
|
|
{
|
|
return new self(
|
|
endpoint: ApiEndpoint::email($action),
|
|
method: HttpMethod::POST,
|
|
payload: $data,
|
|
timeout: Duration::fromSeconds(10),
|
|
retryStrategy: new ExponentialBackoffStrategy(maxAttempts: 2)
|
|
);
|
|
}
|
|
|
|
public static function inventory(string $action, array $data): self
|
|
{
|
|
return new self(
|
|
endpoint: ApiEndpoint::inventory($action),
|
|
method: HttpMethod::POST,
|
|
payload: $data,
|
|
timeout: Duration::fromSeconds(15),
|
|
retryStrategy: new ExponentialBackoffStrategy(maxAttempts: 3)
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**API Endpoint Registry**:
|
|
```php
|
|
namespace App\Framework\ApiGateway\ValueObjects;
|
|
|
|
final readonly class ApiEndpoint
|
|
{
|
|
private function __construct(
|
|
public string $service,
|
|
public string $action,
|
|
public string $baseUrl,
|
|
public string $path
|
|
) {}
|
|
|
|
public static function payment(string $action): self
|
|
{
|
|
return new self(
|
|
service: 'payment',
|
|
action: $action,
|
|
baseUrl: $_ENV['PAYMENT_API_URL'],
|
|
path: "/api/v1/payments/{$action}"
|
|
);
|
|
}
|
|
|
|
public static function email(string $action): self
|
|
{
|
|
return new self(
|
|
service: 'email',
|
|
action: $action,
|
|
baseUrl: $_ENV['EMAIL_API_URL'],
|
|
path: "/api/v1/emails/{$action}"
|
|
);
|
|
}
|
|
|
|
public static function inventory(string $action): self
|
|
{
|
|
return new self(
|
|
service: 'inventory',
|
|
action: $action,
|
|
baseUrl: $_ENV['INVENTORY_API_URL'],
|
|
path: "/api/v1/inventory/{$action}"
|
|
);
|
|
}
|
|
|
|
public function getUrl(): string
|
|
{
|
|
return $this->baseUrl . $this->path;
|
|
}
|
|
|
|
public function toString(): string
|
|
{
|
|
return "{$this->service}.{$this->action}";
|
|
}
|
|
}
|
|
```
|
|
|
|
**Gateway Implementation**:
|
|
```php
|
|
namespace App\Framework\ApiGateway;
|
|
|
|
final readonly class DefaultApiGateway implements ApiGateway
|
|
{
|
|
public function __construct(
|
|
private readonly HttpClient $httpClient,
|
|
private readonly CircuitBreaker $circuitBreaker,
|
|
private readonly ApiMetricsCollector $metrics,
|
|
private readonly Logger $logger,
|
|
private readonly Queue $queue
|
|
) {}
|
|
|
|
public function call(ApiRequest $request): ApiResponse
|
|
{
|
|
$startTime = microtime(true);
|
|
|
|
try {
|
|
// Circuit Breaker Protection
|
|
if ($this->circuitBreaker->isOpen($request->endpoint)) {
|
|
throw new ServiceUnavailableException(
|
|
"Service {$request->endpoint->service} is currently unavailable"
|
|
);
|
|
}
|
|
|
|
// Execute Request with Retry
|
|
$response = $this->executeWithRetry($request);
|
|
|
|
// Record Success
|
|
$this->circuitBreaker->recordSuccess($request->endpoint);
|
|
$this->recordMetrics($request, $response, $startTime);
|
|
|
|
return $response;
|
|
|
|
} catch (\Throwable $e) {
|
|
// Record Failure
|
|
$this->circuitBreaker->recordFailure($request->endpoint);
|
|
|
|
$this->logger->error('[API Gateway] Request failed', [
|
|
'endpoint' => $request->endpoint->toString(),
|
|
'method' => $request->method->value,
|
|
'error' => $e->getMessage(),
|
|
'duration_ms' => (microtime(true) - $startTime) * 1000
|
|
]);
|
|
|
|
throw new ApiGatewayException(
|
|
"API call to {$request->endpoint->service} failed",
|
|
previous: $e
|
|
);
|
|
}
|
|
}
|
|
|
|
public function callAsync(ApiRequest $request): AsyncPromise
|
|
{
|
|
// Queue for async processing
|
|
$job = new ApiCallJob($request);
|
|
$this->queue->push(JobPayload::immediate($job));
|
|
|
|
return new AsyncPromise($job->getId());
|
|
}
|
|
|
|
private function executeWithRetry(ApiRequest $request): ApiResponse
|
|
{
|
|
$attempt = 0;
|
|
$lastException = null;
|
|
$maxAttempts = $request->retryStrategy?->getMaxAttempts() ?? 1;
|
|
|
|
while ($attempt < $maxAttempts) {
|
|
try {
|
|
return $this->httpClient->request(
|
|
method: $request->method,
|
|
url: $request->endpoint->getUrl(),
|
|
body: $request->payload,
|
|
headers: array_merge(
|
|
$this->getDefaultHeaders(),
|
|
$request->headers
|
|
),
|
|
timeout: $request->timeout
|
|
);
|
|
|
|
} catch (ApiException $e) {
|
|
$lastException = $e;
|
|
$attempt++;
|
|
|
|
if ($attempt < $maxAttempts && $request->retryStrategy?->shouldRetry($attempt)) {
|
|
$delay = $request->retryStrategy->getDelay($attempt);
|
|
|
|
$this->logger->debug('[API Gateway] Retrying request', [
|
|
'endpoint' => $request->endpoint->toString(),
|
|
'attempt' => $attempt,
|
|
'delay_ms' => $delay->toMilliseconds()
|
|
]);
|
|
|
|
usleep($delay->toMicroseconds());
|
|
}
|
|
}
|
|
}
|
|
|
|
throw $lastException;
|
|
}
|
|
|
|
private function recordMetrics(
|
|
ApiRequest $request,
|
|
ApiResponse $response,
|
|
float $startTime
|
|
): void {
|
|
$duration = (microtime(true) - $startTime) * 1000;
|
|
|
|
$this->metrics->record([
|
|
'endpoint' => $request->endpoint->toString(),
|
|
'method' => $request->method->value,
|
|
'status_code' => $response->statusCode,
|
|
'duration_ms' => $duration,
|
|
'timestamp' => time()
|
|
]);
|
|
}
|
|
|
|
private function getDefaultHeaders(): array
|
|
{
|
|
return [
|
|
'User-Agent' => 'CustomPHPFramework/2.0',
|
|
'X-Request-ID' => $this->generateRequestId(),
|
|
'Accept' => 'application/json',
|
|
'Content-Type' => 'application/json'
|
|
];
|
|
}
|
|
|
|
private function generateRequestId(): string
|
|
{
|
|
return Ulid::generate();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Circuit Breaker Implementation**:
|
|
```php
|
|
namespace App\Framework\ApiGateway;
|
|
|
|
final class CircuitBreaker
|
|
{
|
|
private const FAILURE_THRESHOLD = 5;
|
|
private const TIMEOUT = 60; // seconds
|
|
|
|
private array $failures = [];
|
|
private array $openUntil = [];
|
|
|
|
public function __construct(
|
|
private readonly Logger $logger
|
|
) {}
|
|
|
|
public function isOpen(ApiEndpoint $endpoint): bool
|
|
{
|
|
$key = $endpoint->toString();
|
|
|
|
if (isset($this->openUntil[$key])) {
|
|
if (time() < $this->openUntil[$key]) {
|
|
return true; // Still open
|
|
}
|
|
|
|
// Timeout expired, close circuit
|
|
unset($this->openUntil[$key]);
|
|
$this->failures[$key] = 0;
|
|
|
|
$this->logger->info('[Circuit Breaker] Circuit closed', [
|
|
'endpoint' => $key
|
|
]);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function recordFailure(ApiEndpoint $endpoint): void
|
|
{
|
|
$key = $endpoint->toString();
|
|
$this->failures[$key] = ($this->failures[$key] ?? 0) + 1;
|
|
|
|
if ($this->failures[$key] >= self::FAILURE_THRESHOLD) {
|
|
$this->openUntil[$key] = time() + self::TIMEOUT;
|
|
|
|
$this->logger->warning('[Circuit Breaker] Circuit opened', [
|
|
'endpoint' => $key,
|
|
'failures' => $this->failures[$key],
|
|
'timeout_seconds' => self::TIMEOUT
|
|
]);
|
|
}
|
|
}
|
|
|
|
public function recordSuccess(ApiEndpoint $endpoint): void
|
|
{
|
|
$key = $endpoint->toString();
|
|
$this->failures[$key] = 0;
|
|
}
|
|
|
|
public function getStatus(): array
|
|
{
|
|
$status = [];
|
|
|
|
foreach ($this->failures as $endpoint => $count) {
|
|
$status[$endpoint] = [
|
|
'failures' => $count,
|
|
'is_open' => isset($this->openUntil[$endpoint]),
|
|
'opens_until' => $this->openUntil[$endpoint] ?? null
|
|
];
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage in Services**:
|
|
```php
|
|
// Refactored OrderService using API Gateway
|
|
final readonly class OrderService
|
|
{
|
|
public function __construct(
|
|
private readonly ApiGateway $apiGateway,
|
|
private readonly EntityManager $entityManager
|
|
) {}
|
|
|
|
public function processPayment(Order $order): PaymentResult
|
|
{
|
|
$request = ApiRequest::payment('charge', [
|
|
'amount' => $order->total->toArray(),
|
|
'customer_id' => $order->user->id->value,
|
|
'idempotency_key' => $order->id->value
|
|
]);
|
|
|
|
$response = $this->apiGateway->call($request);
|
|
|
|
return PaymentResult::fromApiResponse($response);
|
|
}
|
|
|
|
public function sendOrderConfirmation(Order $order): void
|
|
{
|
|
$request = ApiRequest::email('send', [
|
|
'to' => $order->user->email->value,
|
|
'template' => 'order_confirmation',
|
|
'data' => [
|
|
'order_id' => $order->id->value,
|
|
'total' => $order->total->toDecimal()
|
|
]
|
|
]);
|
|
|
|
// Send async - no need to wait
|
|
$this->apiGateway->callAsync($request);
|
|
}
|
|
|
|
public function reserveInventory(Order $order): void
|
|
{
|
|
$request = ApiRequest::inventory('reserve', [
|
|
'order_id' => $order->id->value,
|
|
'items' => array_map(
|
|
fn($item) => $item->toArray(),
|
|
$order->items
|
|
)
|
|
]);
|
|
|
|
$response = $this->apiGateway->call($request);
|
|
|
|
if (!$response->isSuccess()) {
|
|
throw new InventoryReservationFailedException($order->id);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Benefits
|
|
|
|
- 🔒 **Centralized Control**: All external API calls through single gateway
|
|
- 📊 **Unified Monitoring**: Track all API calls, latency, errors
|
|
- 🔄 **Consistent Retry Logic**: Exponential backoff for all services
|
|
- 🛡️ **Circuit Breaker**: Automatic protection against failing services
|
|
- 🐛 **Easy Debugging**: All API calls logged with request IDs
|
|
- 🧪 **Testability**: Mock single gateway instead of multiple clients
|
|
- ⚡ **Async Support**: Queue background API calls without blocking
|
|
|
|
#### Monitoring Dashboard
|
|
|
|
```php
|
|
final readonly class ApiGatewayMonitoringController
|
|
{
|
|
#[Route('/admin/api-gateway/stats', method: Method::GET)]
|
|
public function getStats(): JsonResult
|
|
{
|
|
return new JsonResult([
|
|
'circuit_breaker' => $this->circuitBreaker->getStatus(),
|
|
'metrics' => $this->metrics->getSummary(),
|
|
'recent_calls' => $this->metrics->getRecentCalls(limit: 100)
|
|
]);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### 4. Hexagonal Architecture for Better Testability ⭐⭐
|
|
|
|
**Priority**: Medium
|
|
**Complexity**: Medium
|
|
**Impact**: Fast Unit Tests, Infrastructure Independence
|
|
**Time Estimate**: 1 week
|
|
|
|
#### Problem Statement
|
|
|
|
Infrastructure concerns (database, external APIs) are tightly coupled to domain logic, making unit tests slow and complex.
|
|
|
|
```php
|
|
// Current approach - infrastructure in domain service
|
|
final readonly class UserService
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManager $entityManager // Infrastructure dependency
|
|
) {}
|
|
|
|
public function registerUser(RegisterCommand $command): User
|
|
{
|
|
$user = User::create($command);
|
|
$this->entityManager->save($user); // Database call
|
|
return $user;
|
|
}
|
|
}
|
|
|
|
// Test requires database setup
|
|
it('registers user', function () {
|
|
$entityManager = setupTestDatabase(); // Slow!
|
|
$service = new UserService($entityManager);
|
|
|
|
$user = $service->registerUser($command);
|
|
|
|
expect($user)->not->toBeNull();
|
|
});
|
|
```
|
|
|
|
**Issues**:
|
|
- Unit tests require database setup (slow: seconds instead of milliseconds)
|
|
- Domain logic coupled to infrastructure implementation
|
|
- Cannot easily switch infrastructure (e.g., API to Database)
|
|
- Mocking EntityManager is complex and brittle
|
|
|
|
#### Solution: Ports & Adapters Pattern
|
|
|
|
**Port (Domain Interface)**:
|
|
```php
|
|
namespace App\Domain\User\Ports;
|
|
|
|
interface UserRepository
|
|
{
|
|
public function findById(UserId $id): ?User;
|
|
public function save(User $user): void;
|
|
public function findByEmail(Email $email): ?User;
|
|
public function findAll(): array;
|
|
public function delete(User $user): void;
|
|
}
|
|
```
|
|
|
|
**Domain Service Uses Only Port**:
|
|
```php
|
|
namespace App\Domain\User\Services;
|
|
|
|
final readonly class UserService
|
|
{
|
|
public function __construct(
|
|
private readonly UserRepository $users, // Port, not implementation
|
|
private readonly EventBus $events
|
|
) {}
|
|
|
|
public function registerUser(RegisterCommand $command): User
|
|
{
|
|
// Pure domain logic - no infrastructure
|
|
if ($this->users->findByEmail($command->email) !== null) {
|
|
throw new EmailAlreadyExistsException($command->email);
|
|
}
|
|
|
|
$user = User::create($command);
|
|
$this->users->save($user);
|
|
$this->events->dispatch(new UserRegisteredEvent($user));
|
|
|
|
return $user;
|
|
}
|
|
|
|
public function updateUserProfile(UserId $id, UpdateProfileCommand $command): User
|
|
{
|
|
$user = $this->users->findById($id);
|
|
|
|
if ($user === null) {
|
|
throw new UserNotFoundException($id);
|
|
}
|
|
|
|
$updated = $user->updateProfile($command);
|
|
$this->users->save($updated);
|
|
$this->events->dispatch(new UserProfileUpdatedEvent($updated));
|
|
|
|
return $updated;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Adapter 1: Database Implementation (Production)**:
|
|
```php
|
|
namespace App\Infrastructure\Persistence\User;
|
|
|
|
final readonly class DatabaseUserRepository implements UserRepository
|
|
{
|
|
public function __construct(
|
|
private readonly EntityManager $entityManager
|
|
) {}
|
|
|
|
public function findById(UserId $id): ?User
|
|
{
|
|
return $this->entityManager->find(User::class, $id->value);
|
|
}
|
|
|
|
public function save(User $user): void
|
|
{
|
|
$this->entityManager->save($user);
|
|
}
|
|
|
|
public function findByEmail(Email $email): ?User
|
|
{
|
|
return $this->entityManager->findOneBy(User::class, [
|
|
'email' => $email->value
|
|
]);
|
|
}
|
|
|
|
public function findAll(): array
|
|
{
|
|
return $this->entityManager->findAll(User::class);
|
|
}
|
|
|
|
public function delete(User $user): void
|
|
{
|
|
$this->entityManager->delete($user);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Adapter 2: In-Memory Implementation (Tests)**:
|
|
```php
|
|
namespace Tests\Infrastructure\User;
|
|
|
|
final class InMemoryUserRepository implements UserRepository
|
|
{
|
|
private array $users = [];
|
|
|
|
public function findById(UserId $id): ?User
|
|
{
|
|
return $this->users[$id->value] ?? null;
|
|
}
|
|
|
|
public function save(User $user): void
|
|
{
|
|
$this->users[$user->id->value] = $user;
|
|
}
|
|
|
|
public function findByEmail(Email $email): ?User
|
|
{
|
|
foreach ($this->users as $user) {
|
|
if ($user->email->equals($email)) {
|
|
return $user;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public function findAll(): array
|
|
{
|
|
return array_values($this->users);
|
|
}
|
|
|
|
public function delete(User $user): void
|
|
{
|
|
unset($this->users[$user->id->value]);
|
|
}
|
|
|
|
// Test helper methods
|
|
public function clear(): void
|
|
{
|
|
$this->users = [];
|
|
}
|
|
|
|
public function count(): int
|
|
{
|
|
return count($this->users);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Adapter 3: API Client Implementation (Legacy System Migration)**:
|
|
```php
|
|
namespace App\Infrastructure\External\User;
|
|
|
|
final readonly class ApiUserRepository implements UserRepository
|
|
{
|
|
public function __construct(
|
|
private readonly ApiGateway $gateway
|
|
) {}
|
|
|
|
public function findById(UserId $id): ?User
|
|
{
|
|
$request = ApiRequest::get("/legacy/users/{$id->value}");
|
|
|
|
try {
|
|
$response = $this->gateway->call($request);
|
|
return User::fromApiResponse($response);
|
|
} catch (NotFoundException $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public function save(User $user): void
|
|
{
|
|
$request = ApiRequest::post('/legacy/users', [
|
|
'id' => $user->id->value,
|
|
'email' => $user->email->value,
|
|
'name' => $user->name->value
|
|
]);
|
|
|
|
$this->gateway->call($request);
|
|
}
|
|
|
|
public function findByEmail(Email $email): ?User
|
|
{
|
|
$request = ApiRequest::get("/legacy/users?email={$email->value}");
|
|
|
|
try {
|
|
$response = $this->gateway->call($request);
|
|
$users = User::collectionFromApiResponse($response);
|
|
return $users[0] ?? null;
|
|
} catch (NotFoundException $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ... weitere Methoden
|
|
}
|
|
```
|
|
|
|
**Dependency Injection Configuration**:
|
|
```php
|
|
namespace App\Infrastructure\DI;
|
|
|
|
final readonly class UserRepositoryInitializer
|
|
{
|
|
#[Initializer]
|
|
public function initialize(Container $container): void
|
|
{
|
|
// Environment-based adapter selection
|
|
$repository = match ($_ENV['USER_REPOSITORY_TYPE'] ?? 'database') {
|
|
'database' => new DatabaseUserRepository(
|
|
$container->get(EntityManager::class)
|
|
),
|
|
'api' => new ApiUserRepository(
|
|
$container->get(ApiGateway::class)
|
|
),
|
|
'memory' => new InMemoryUserRepository(),
|
|
default => throw new \RuntimeException('Unknown repository type')
|
|
};
|
|
|
|
$container->singleton(UserRepository::class, $repository);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Fast Unit Tests**:
|
|
```php
|
|
// Unit test - NO database, NO Docker, milliseconds!
|
|
it('registers new user', function () {
|
|
$repository = new InMemoryUserRepository();
|
|
$events = new InMemoryEventBus();
|
|
|
|
$service = new UserService($repository, $events);
|
|
|
|
$command = new RegisterCommand(
|
|
email: new Email('test@example.com'),
|
|
name: new UserName('Test User'),
|
|
password: new Password('secret123')
|
|
);
|
|
|
|
$user = $service->registerUser($command);
|
|
|
|
expect($repository->findById($user->id))->toBe($user);
|
|
expect($events->dispatchedEvents())->toHaveCount(1);
|
|
expect($events->dispatchedEvents()[0])->toBeInstanceOf(UserRegisteredEvent::class);
|
|
});
|
|
|
|
it('throws exception when email already exists', function () {
|
|
$repository = new InMemoryUserRepository();
|
|
$events = new InMemoryEventBus();
|
|
|
|
$service = new UserService($repository, $events);
|
|
|
|
// Pre-existing user
|
|
$existingUser = User::create(new RegisterCommand(
|
|
email: new Email('test@example.com'),
|
|
name: new UserName('Existing'),
|
|
password: new Password('pass')
|
|
));
|
|
$repository->save($existingUser);
|
|
|
|
// Try to register with same email
|
|
$command = new RegisterCommand(
|
|
email: new Email('test@example.com'),
|
|
name: new UserName('New User'),
|
|
password: new Password('secret')
|
|
);
|
|
|
|
$service->registerUser($command);
|
|
})->throws(EmailAlreadyExistsException::class);
|
|
```
|
|
|
|
**Performance Comparison**:
|
|
```
|
|
Unit Test Performance:
|
|
|
|
With Database (EntityManager):
|
|
Setup: 200-500ms (database connection, migrations)
|
|
Execution: 50-100ms per test
|
|
Total: 250-600ms per test
|
|
|
|
With In-Memory Adapter:
|
|
Setup: 0ms (instant object creation)
|
|
Execution: 1-5ms per test
|
|
Total: 1-5ms per test
|
|
|
|
Performance Gain: 50-600x faster!
|
|
```
|
|
|
|
#### Benefits
|
|
|
|
- 🧪 **Fast Tests**: Unit tests run in milliseconds (no database)
|
|
- 🔄 **Easy Mocking**: Simple in-memory adapters for tests
|
|
- 🔧 **Flexible Infrastructure**: Swap implementations without changing domain
|
|
- 📦 **Domain Isolation**: Business logic completely independent from infrastructure
|
|
- 🚀 **TDD-Friendly**: Write tests before infrastructure is ready
|
|
- 🔌 **Legacy Integration**: Gradually migrate from old systems using API adapter
|
|
|
|
#### Additional Ports
|
|
|
|
**Email Port**:
|
|
```php
|
|
namespace App\Domain\Notifications\Ports;
|
|
|
|
interface EmailSender
|
|
{
|
|
public function send(Email $to, EmailTemplate $template, array $data): void;
|
|
}
|
|
|
|
// Adapters: SmtpEmailSender, SendGridEmailSender, InMemoryEmailSender (tests)
|
|
```
|
|
|
|
**File Storage Port**:
|
|
```php
|
|
namespace App\Domain\Storage\Ports;
|
|
|
|
interface FileStorage
|
|
{
|
|
public function store(string $path, string $contents): void;
|
|
public function retrieve(string $path): string;
|
|
public function delete(string $path): void;
|
|
public function exists(string $path): bool;
|
|
}
|
|
|
|
// Adapters: LocalFileStorage, S3FileStorage, InMemoryFileStorage (tests)
|
|
```
|
|
|
|
**Payment Gateway Port**:
|
|
```php
|
|
namespace App\Domain\Payment\Ports;
|
|
|
|
interface PaymentGateway
|
|
{
|
|
public function charge(Money $amount, PaymentMethod $method): PaymentResult;
|
|
public function refund(PaymentId $id, Money $amount): RefundResult;
|
|
}
|
|
|
|
// Adapters: StripePaymentGateway, PayPalPaymentGateway, FakePaymentGateway (tests)
|
|
```
|
|
|
|
---
|
|
|
|
### 5. Materialized Views for Analytics Performance ⭐
|
|
|
|
**Priority**: Medium
|
|
**Complexity**: Low
|
|
**Impact**: 50-100x Faster Analytics Queries
|
|
**Time Estimate**: 2-3 days
|
|
|
|
#### Problem Statement
|
|
|
|
Complex analytics queries with multiple JOINs are slow and block database resources.
|
|
|
|
```php
|
|
// Current approach - complex JOIN query (500-1000ms)
|
|
$stats = $this->connection->fetchOne("
|
|
SELECT
|
|
u.id,
|
|
u.name,
|
|
COUNT(DISTINCT o.id) AS total_orders,
|
|
SUM(o.total_cents) AS lifetime_value,
|
|
MAX(o.created_at) AS last_order_date,
|
|
AVG(o.total_cents) AS avg_order_value,
|
|
COUNT(DISTINCT p.id) AS total_payments
|
|
FROM users u
|
|
LEFT JOIN orders o ON o.user_id = u.id
|
|
LEFT JOIN payments p ON p.order_id = o.id
|
|
WHERE u.id = ?
|
|
GROUP BY u.id, u.name
|
|
", [$userId]);
|
|
```
|
|
|
|
**Issues**:
|
|
- Slow query execution (500-1000ms for complex aggregations)
|
|
- Multiple JOINs across large tables
|
|
- Blocks database for write operations
|
|
- Not scalable for reporting dashboards
|
|
|
|
#### Solution: Materialized Views with Auto-Refresh
|
|
|
|
**Create Materialized View**:
|
|
```sql
|
|
-- User Dashboard Statistics (Denormalized Aggregates)
|
|
CREATE TABLE user_dashboard_stats (
|
|
user_id VARCHAR(26) PRIMARY KEY,
|
|
user_name VARCHAR(255),
|
|
user_email VARCHAR(255),
|
|
total_orders INT DEFAULT 0,
|
|
lifetime_value_cents INT DEFAULT 0,
|
|
last_order_date DATETIME NULL,
|
|
avg_order_value_cents INT DEFAULT 0,
|
|
total_payments INT DEFAULT 0,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
|
INDEX idx_lifetime_value (lifetime_value_cents DESC),
|
|
INDEX idx_last_order (last_order_date DESC),
|
|
INDEX idx_updated (updated_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
|
|
-- Initial population
|
|
INSERT INTO user_dashboard_stats
|
|
SELECT
|
|
u.id AS user_id,
|
|
u.name AS user_name,
|
|
u.email AS user_email,
|
|
COUNT(DISTINCT o.id) AS total_orders,
|
|
COALESCE(SUM(o.total_cents), 0) AS lifetime_value_cents,
|
|
MAX(o.created_at) AS last_order_date,
|
|
COALESCE(AVG(o.total_cents), 0) AS avg_order_value_cents,
|
|
COUNT(DISTINCT p.id) AS total_payments,
|
|
NOW() AS updated_at
|
|
FROM users u
|
|
LEFT JOIN orders o ON o.user_id = u.id
|
|
LEFT JOIN payments p ON p.order_id = o.id
|
|
GROUP BY u.id, u.name, u.email;
|
|
```
|
|
|
|
**Auto-Refresh via Events**:
|
|
```php
|
|
namespace App\Infrastructure\MaterializedViews;
|
|
|
|
final readonly class UserDashboardStatsRefresher
|
|
{
|
|
public function __construct(
|
|
private readonly Connection $connection,
|
|
private readonly Queue $queue
|
|
) {}
|
|
|
|
#[OnEvent(priority: 10)] // Low priority - async update
|
|
public function onOrderCreated(OrderCreatedEvent $event): void
|
|
{
|
|
// Queue async refresh for affected user
|
|
$job = new RefreshUserStatsJob($event->userId);
|
|
$this->queue->push(JobPayload::background($job));
|
|
}
|
|
|
|
#[OnEvent(priority: 10)]
|
|
public function onPaymentCompleted(PaymentCompletedEvent $event): void
|
|
{
|
|
$job = new RefreshUserStatsJob($event->order->userId);
|
|
$this->queue->push(JobPayload::background($job));
|
|
}
|
|
|
|
#[OnEvent(priority: 10)]
|
|
public function onOrderCancelled(OrderCancelledEvent $event): void
|
|
{
|
|
$job = new RefreshUserStatsJob($event->order->userId);
|
|
$this->queue->push(JobPayload::background($job));
|
|
}
|
|
}
|
|
|
|
final readonly class RefreshUserStatsJob
|
|
{
|
|
public function __construct(
|
|
private readonly UserId $userId
|
|
) {}
|
|
|
|
public function handle(Connection $connection): void
|
|
{
|
|
// Incremental update for single user
|
|
$connection->execute("
|
|
INSERT INTO user_dashboard_stats
|
|
SELECT
|
|
u.id AS user_id,
|
|
u.name AS user_name,
|
|
u.email AS user_email,
|
|
COUNT(DISTINCT o.id) AS total_orders,
|
|
COALESCE(SUM(o.total_cents), 0) AS lifetime_value_cents,
|
|
MAX(o.created_at) AS last_order_date,
|
|
COALESCE(AVG(o.total_cents), 0) AS avg_order_value_cents,
|
|
COUNT(DISTINCT p.id) AS total_payments,
|
|
NOW() AS updated_at
|
|
FROM users u
|
|
LEFT JOIN orders o ON o.user_id = u.id
|
|
LEFT JOIN payments p ON p.order_id = o.id
|
|
WHERE u.id = ?
|
|
GROUP BY u.id, u.name, u.email
|
|
ON DUPLICATE KEY UPDATE
|
|
total_orders = VALUES(total_orders),
|
|
lifetime_value_cents = VALUES(lifetime_value_cents),
|
|
last_order_date = VALUES(last_order_date),
|
|
avg_order_value_cents = VALUES(avg_order_value_cents),
|
|
total_payments = VALUES(total_payments),
|
|
updated_at = NOW()
|
|
", [$this->userId->value]);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Fast Query After Materialization**:
|
|
```php
|
|
// ✅ After: Simple SELECT from materialized view (5-10ms)
|
|
$stats = $this->connection->fetchOne("
|
|
SELECT
|
|
user_id,
|
|
user_name,
|
|
total_orders,
|
|
lifetime_value_cents,
|
|
last_order_date,
|
|
avg_order_value_cents,
|
|
total_payments
|
|
FROM user_dashboard_stats
|
|
WHERE user_id = ?
|
|
", [$userId]);
|
|
```
|
|
|
|
**Performance Comparison**:
|
|
```
|
|
Query Performance:
|
|
|
|
Before (Complex JOIN):
|
|
Execution Time: 500-1000ms
|
|
CPU Usage: High
|
|
Blocks: Write operations
|
|
|
|
After (Materialized View):
|
|
Execution Time: 5-10ms
|
|
CPU Usage: Minimal
|
|
Blocks: None
|
|
|
|
Performance Gain: 50-100x faster!
|
|
```
|
|
|
|
#### Additional Materialized Views
|
|
|
|
**Product Sales Statistics**:
|
|
```sql
|
|
CREATE TABLE product_sales_stats (
|
|
product_id VARCHAR(26) PRIMARY KEY,
|
|
product_name VARCHAR(255),
|
|
total_sales_count INT DEFAULT 0,
|
|
total_revenue_cents INT DEFAULT 0,
|
|
avg_sale_price_cents INT DEFAULT 0,
|
|
last_sale_date DATETIME NULL,
|
|
top_customer_id VARCHAR(26) NULL,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
|
INDEX idx_revenue (total_revenue_cents DESC),
|
|
INDEX idx_sales (total_sales_count DESC)
|
|
);
|
|
```
|
|
|
|
**Monthly Revenue Report**:
|
|
```sql
|
|
CREATE TABLE monthly_revenue_stats (
|
|
year_month VARCHAR(7) PRIMARY KEY, -- Format: 2025-01
|
|
total_orders INT DEFAULT 0,
|
|
total_revenue_cents INT DEFAULT 0,
|
|
avg_order_value_cents INT DEFAULT 0,
|
|
new_customers INT DEFAULT 0,
|
|
returning_customers INT DEFAULT 0,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
|
|
INDEX idx_year_month (year_month DESC)
|
|
);
|
|
```
|
|
|
|
#### Benefits
|
|
|
|
- ⚡ **50-100x Faster**: Pre-computed aggregates eliminate runtime cost
|
|
- 📊 **Complex Analytics**: Multi-table aggregations without performance penalty
|
|
- 🔄 **Eventually Consistent**: Async refresh acceptable for dashboards/reports
|
|
- 💾 **Reduced Database Load**: No complex JOINs during peak traffic
|
|
- 📈 **Scalable Reporting**: Add more materialized views without impacting writes
|
|
|
|
#### When to Use Materialized Views
|
|
|
|
**✅ Good Candidates**:
|
|
- Dashboard statistics
|
|
- Analytics reports
|
|
- Aggregation queries (SUM, AVG, COUNT)
|
|
- Historical data analysis
|
|
- Read-heavy workloads with eventual consistency acceptable
|
|
|
|
**❌ Avoid for**:
|
|
- Real-time data requirements (use CQRS instead)
|
|
- Frequently changing data
|
|
- Simple queries without aggregations
|
|
- Small tables (< 1000 rows)
|
|
|
|
---
|
|
|
|
## 🏗️ Implementation Roadmap
|
|
|
|
### Phase 1: Quick Wins (1-2 weeks)
|
|
|
|
**Week 1**:
|
|
1. ✅ **API Gateway** (2-3 days)
|
|
- Implement ApiRequest/ApiResponse value objects
|
|
- Create DefaultApiGateway with CircuitBreaker
|
|
- Refactor 2-3 services to use gateway
|
|
- Add monitoring dashboard
|
|
|
|
2. ✅ **Domain Event Store** (1-2 days)
|
|
- Create domain_events table
|
|
- Implement DomainEventStore
|
|
- Add event recording to 2-3 aggregates
|
|
|
|
**Week 2**:
|
|
3. ✅ **Materialized Views** (2-3 days)
|
|
- Create user_dashboard_stats view
|
|
- Implement auto-refresh jobs
|
|
- Refactor dashboard queries
|
|
|
|
4. ✅ **Documentation** (1 day)
|
|
- Update architecture docs
|
|
- Add code examples
|
|
- Team training session
|
|
|
|
### Phase 2: Medium-Term (2-3 weeks)
|
|
|
|
**Week 3-4**:
|
|
5. ✅ **Hexagonal Architecture** (1 week)
|
|
- Define Port interfaces for repositories
|
|
- Create In-Memory adapters
|
|
- Refactor unit tests for speed
|
|
|
|
6. ✅ **More Materialized Views** (3-4 days)
|
|
- Product sales statistics
|
|
- Monthly revenue reports
|
|
- Order analytics
|
|
|
|
### Phase 3: Long-Term (4-6 weeks)
|
|
|
|
**Week 5-8**:
|
|
7. ✅ **CQRS Implementation** (2-3 weeks)
|
|
- Create read model tables
|
|
- Implement projectors
|
|
- Migrate performance-critical queries
|
|
- A/B test performance improvements
|
|
|
|
8. ✅ **Complete Port Coverage** (1 week)
|
|
- Email, Storage, Payment ports
|
|
- Additional adapters
|
|
- Full test coverage
|
|
|
|
---
|
|
|
|
## 📊 Success Metrics
|
|
|
|
### Performance Targets
|
|
|
|
**API Gateway**:
|
|
- All external API calls centralized: 100%
|
|
- Circuit breaker downtime prevention: > 99%
|
|
- Average API call latency reduction: < 10%
|
|
|
|
**CQRS**:
|
|
- Read query performance improvement: 3-5x
|
|
- Database CPU usage reduction: 30-50%
|
|
- Write operation latency: No regression
|
|
|
|
**Materialized Views**:
|
|
- Dashboard query performance: 50-100x faster
|
|
- Analytics query execution time: < 100ms
|
|
- Database load reduction: 40-60%
|
|
|
|
**Hexagonal Architecture**:
|
|
- Unit test execution time: < 10ms per test
|
|
- Test suite total time: < 10 seconds
|
|
- Test coverage: > 80%
|
|
|
|
### Quality Metrics
|
|
|
|
- Code maintainability increase: Subjective but measurable via team surveys
|
|
- Onboarding time for new developers: 30% reduction
|
|
- Bug fix time: 25% reduction (better testability)
|
|
- Production incidents: 40% reduction (circuit breaker + event store)
|
|
|
|
---
|
|
|
|
## 🔍 Monitoring & Validation
|
|
|
|
### API Gateway Monitoring
|
|
|
|
```php
|
|
// Metrics to track
|
|
- Total API calls per endpoint
|
|
- Average latency per endpoint
|
|
- Error rate per endpoint
|
|
- Circuit breaker open/close events
|
|
- Retry attempts per call
|
|
```
|
|
|
|
### CQRS Validation
|
|
|
|
```php
|
|
// Compare performance before/after
|
|
$before = microtime(true);
|
|
$oldResult = $this->entityManager->complexQuery();
|
|
$oldTime = microtime(true) - $before;
|
|
|
|
$before = microtime(true);
|
|
$newResult = $this->userQuery->findById($id);
|
|
$newTime = microtime(true) - $before;
|
|
|
|
assert($oldTime / $newTime >= 3); // At least 3x faster
|
|
```
|
|
|
|
### Materialized View Freshness
|
|
|
|
```php
|
|
// Monitor staleness
|
|
SELECT
|
|
user_id,
|
|
TIMESTAMPDIFF(SECOND, updated_at, NOW()) AS staleness_seconds
|
|
FROM user_dashboard_stats
|
|
WHERE TIMESTAMPDIFF(SECOND, updated_at, NOW()) > 300
|
|
ORDER BY staleness_seconds DESC
|
|
LIMIT 10;
|
|
```
|
|
|
|
---
|
|
|
|
## 💡 Next Steps
|
|
|
|
1. **Review this document** with the team
|
|
2. **Prioritize improvements** based on current pain points
|
|
3. **Create Jira tickets** for Phase 1 tasks
|
|
4. **Setup monitoring** for baseline metrics
|
|
5. **Start with API Gateway** (quick win, high impact)
|
|
|
|
---
|
|
|
|
## 📚 References
|
|
|
|
- [CQRS Pattern - Martin Fowler](https://martinfowler.com/bliki/CQRS.html)
|
|
- [Hexagonal Architecture - Alistair Cockburn](https://alistair.cockburn.us/hexagonal-architecture/)
|
|
- [Event Sourcing - Greg Young](https://www.eventstore.com/blog/what-is-event-sourcing)
|
|
- [API Gateway Pattern - Chris Richardson](https://microservices.io/patterns/apigateway.html)
|
|
- Framework Documentation: `docs/claude/`
|
|
|
|
---
|
|
|
|
**Last Updated**: 2025-01-28
|
|
**Document Version**: 1.0
|
|
**Status**: Ready for Implementation
|