Skip to content

Latest commit

 

History

History
719 lines (540 loc) · 20.7 KB

File metadata and controls

719 lines (540 loc) · 20.7 KB

CLAUDE.md - Backslash Codebase Knowledge

Documentation generated by Claude after complete exploration of the Backslash codebase

Overview

Backslash is a modern, opinionated PHP library designed to facilitate the integration of CQRS (Command Query Responsibility Segregation) and Event Sourcing patterns in PHP applications.

Key Characteristics

  • Framework-agnostic: Compatible with Laravel, Symfony, Slim, or standalone
  • Production-tested: Used in production for 7+ years at the First Nations of Quebec and Labrador Health and Social Services Commission
  • PHP 8.2+ with PDO support (MySQL/SQLite)
  • Open-source under MIT license
  • Zero external framework dependencies

Key Innovation: Dynamic Consistency Boundaries (DCB)

Unlike traditional Event Sourcing approaches based on predefined aggregates, Backslash uses Dynamic Consistency Boundaries that allow defining at runtime which events are relevant for each decision.

Overall Architecture

Write Side (Command → Event)

Command → Dispatcher → Handler → Repository.loadModel(Query)
→ EventStore → Model.applyEvents() → Business Logic → Model.record()
→ Repository.storeChanges() → EventStore.append() → EventBus.publish()

Read Side (Event → Projection)

EventBus.publish() → EventHandlers (Projectors)
→ ProjectionStore.find() → Update Projection → ProjectionStore.store()

Code Structure (/src)

23 Main Components

Write Side (Command Processing)

CommandDispatcher/ - Routes commands to handlers

  • Dispatcher.php - Main dispatcher with middleware support
  • HandlerInterface.php - Contract for command handlers
  • HandleCommandTrait.php - Auto-routes to handle{CommandName} methods

Repository/ - Loading and storing models

  • Repository.php - Repository with middleware
  • Core.php - Core implementation (event loading, model recreation, persistence)

Model/ - Base classes for models

  • AbstractModel.php - Base class for domain models
  • ModelTrait.php - Event recording, replay, change tracking
  • ModelInterface.php - Contract for models

Event Management

Event/ - Event classes and interfaces

  • EventInterface.php - Base contract with serialization
  • RecordedEvent.php - Wraps events with metadata and timestamp
  • RecordedEventStream.php - Collection of events
  • Identifiers.php - Multi-entity identification for consistency boundaries
  • Metadata.php - Metadata storage

EventNameResolver/ - Conversion between class names and short names

  • EventNameResolverInterface.php - Contract for name resolution

Read Side (Query Processing)

EventBus/ - Publishes events to handlers

  • EventBus.php - Main event bus with middleware
  • Publisher.php - Routes events to subscribed handlers
  • EventHandlerInterface.php - Contract for handlers
  • EventHandlerTrait.php - Auto-routes to handle{EventName} methods
  • EventHandlerProxy.php - Lazy-loads handlers

ProjectionStore/ - Persists read models

  • ProjectionStoreInterface.php - Contract for projection storage
  • ProjectionStore.php - Wrapper with middleware support
  • InMemoryProjectionStoreAdapter.php - In-memory storage for testing
  • PdoProjectionStoreAdapter.php - Database storage

Projection/ - Base classes for projections

  • ProjectionInterface.php - Contract for read models
  • AbstractProjection.php - Base projection class

Storage

EventStore/ - Event storage interface

  • EventStoreInterface.php - Contract for persistence
  • EventStore.php - Adapter wrapper
  • StoredRecordedEventStream.php - Events with sequence numbers
  • Query/ - Query interface for filtering

PdoEventStore/ - Database-backed EventStore implementation

  • PdoEventStoreAdapter.php - PDO implementation with concurrency control
  • Driver.php - Driver abstraction (MySQL, SQLite)
  • Config.php - Configuration for table/column names
  • JsonEventSerializer.php - JSON event serialization
  • JsonIdentifiersSerializer.php - Identifier serialization
  • JsonMetadataSerializer.php - Metadata serialization
  • QueryToWhereClause.php - Converts queries to SQL WHERE clauses

Infrastructure

Pdo/ - PDO utilities

  • PdoProxy.php - Lazy-loads PDO connections

Serializer/ - Event serialization

  • SerializerInterface.php - Serialization contract
  • ToArrayTrait.php - Auto-serialization using reflection

Clock/ - Time management

  • Clock.php - PSR Clock interface implementation

StreamEnricher/ - Event enrichment

  • StreamEnricherInterface.php - Adds metadata to events

Middleware

PdoTransactionCommandDispatcherMiddleware/ - Wraps dispatch in DB transaction ProjectionStoreTransactionCommandDispatcherMiddleware/ - Projection store transaction CacheProjectionStoreMiddleware/ - Caches projection lookups

Testing

Scenario/ - BDD-style testing component

  • Scenario.php - Test orchestrator with in-memory event store
  • Play.php - Individual test scenario definition
  • EventPublishingMode.php - Enum for controlling event publication behavior
  • AssertionsTrait.php - Assertion helpers
  • ScenarioEventBusMiddleware.php - Traces published events and controls publishing based on mode
  • ScenarioProjectionStoreMiddleware.php - Traces projection updates
  • PublishedEvents.php - Event assertion helper
  • UpdatedProjections.php - Projection assertion helper
  • Constraint/ - PHPUnit constraints for assertions

Inspection

EventStoreReductionInspection/ - Event store analysis StreamPublishingInspection/ - Event publishing analysis

Core Concepts

1. Events

Immutable facts about what happened.

readonly class StudentRegistered implements EventInterface
{
    public function __construct(
        public string $studentId,
        public string $name,
    ) {}

    public function getIdentifiers(): Identifiers
    {
        return new Identifiers(['studentId' => $this->studentId]);
    }

    // Implements toArray() and fromArray() for serialization
}

Characteristics:

  • Must implement EventInterface
  • Include getIdentifiers() for consistency boundaries
  • Serializable via toArray()/fromArray()
  • Recommended to use readonly classes

2. Models

Decision-making components, rebuilt from events.

class Student extends AbstractModel
{
    private string $studentId;
    private string $name;
    private bool $isRegistered = false;

    // State transitions (pure, no business logic)
    protected function applyStudentRegistered(StudentRegistered $event): void
    {
        $this->studentId = $event->studentId;
        $this->name = $event->name;
        $this->isRegistered = true;
    }

    // Business decisions (record events)
    public function register(string $studentId, string $name): void
    {
        if ($this->isRegistered) {
            throw new StudentAlreadyRegistered();
        }

        $this->record(new StudentRegistered($studentId, $name));
    }
}

Characteristics:

  • Extend AbstractModel
  • Rebuilt from events for each decision (not persisted)
  • Two types of methods:
    • apply{EventName}() - pure state transitions
    • Public methods - business logic that records events
  • Use $this->record() to create new events

3. Queries

Define consistency boundaries dynamically.

use Backslash\EventStore\Query\EventClass;
use Backslash\EventStore\Query\Identifier;

// Simple query
$query = EventClass::is(StudentRegistered::class);

// Query with identifier
$query = Identifier::is('studentId', $studentId);

// Complex query
$query = EventClass::in([StudentRegistered::class, StudentUpdated::class])
    ->and(Identifier::is('studentId', $studentId));

// Multi-entity boundary
$query = Identifier::is('courseId', $courseId)
    ->or(Identifier::is('studentId', $studentId));

Characteristics:

  • Implement QueryInterface
  • Filter by event class: EventClass::is() or EventClass::in()
  • Filter by identifier: Identifier::is('key', 'value')
  • Combine with ->and() and ->or()
  • Support multi-entity boundaries

4. Projections

Read-optimized views.

class StudentProjection extends AbstractProjection implements JsonSerializable
{
    public function __construct(
        private string $id,
        private string $name = '',
        private array $courses = [],
    ) {}

    public function getId(): string
    {
        return $this->id;
    }

    public function register(string $name): void
    {
        $this->name = $name;
    }

    public function enrollInCourse(string $courseId): void
    {
        $this->courses[] = $courseId;
    }

    public function jsonSerialize(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'courses' => $this->courses,
        ];
    }
}

Characteristics:

  • Implement ProjectionInterface with getId()
  • Persisted in ProjectionStore
  • Updated by event handlers (projectors)
  • Can implement JsonSerializable for API responses
  • Multiple projections per domain concept allowed

5. Commands

Intentions to change the system state.

readonly class RegisterStudentCommand
{
    public function __construct(
        public string $studentId,
        public string $name,
    ) {}
}

Characteristics:

  • Plain PHP objects, no interface required
  • Immutable data containers (use readonly)
  • Imperative names: RegisterStudentCommand
  • Carry all information needed for handlers

Design Patterns

1. Middleware Chain Pattern

The dispatcher and event bus use a middleware chain for cross-cutting concerns.

$dispatcher->addMiddleware(new LoggingMiddleware());
$dispatcher->addMiddleware(new PdoTransactionCommandDispatcherMiddleware($pdo));

// LIFO execution (last registered wraps first)

2. Trait-based Method Routing

Auto-routing of commands and events to appropriate methods.

class StudentCommandHandler implements HandlerInterface
{
    use HandleCommandTrait;

    // Automatically routed for RegisterStudentCommand
    private function handleRegisterStudent(RegisterStudentCommand $command): void
    {
        // ...
    }
}

class StudentProjector implements EventHandlerInterface
{
    use EventHandlerTrait;

    // Automatically routed for StudentRegistered
    private function handleStudentRegistered(
        StudentRegistered $event,
        RecordedEvent $recordedEvent
    ): void {
        // ...
    }
}

3. Repository with Caching

The repository caches loaded models and tracks queries for concurrency control.

// First load
$student = $repository->loadModel(Student::class, $query);

// Second load with same query = returns cache
$sameStudent = $repository->loadModel(Student::class, $query);

// Changes are tracked
$student->register($id, $name);

// Storage with optimistic locking
$repository->storeChanges($student);

4. Event Replay

Models are rebuilt by replaying all historical events.

// Load events
$stream = $eventStore->fetch($query);

// Create empty model
$student = new Student();

// Replay all events
$student->applyEvents($stream);

// Model is now in current state

5. Query as Consistency Boundary

Queries define what constitutes a consistency boundary.

// To register a student, we only look at their own events
$query = Identifier::is('studentId', $studentId);
$student = $repository->loadModel(Student::class, $query);

// To enroll in a course, we look at both course and student events
$query = Identifier::is('courseId', $courseId)
    ->or(Identifier::is('studentId', $studentId));
$enrollment = $repository->loadModel(Enrollment::class, $query);

6. Recorded Event Wrapping

Raw events are wrapped in RecordedEvent with metadata.

// Event handler receives both
private function handleStudentRegistered(
    StudentRegistered $event,        // Pure domain event
    RecordedEvent $recordedEvent     // With metadata and timestamp
): void {
    // Access metadata
    $correlationId = $recordedEvent->metadata->get('correlationId');
    $occurredAt = $recordedEvent->occurredAt;

    // Domain data
    $studentId = $event->studentId;
}

Main APIs

Repository API

// Load a model
$model = $repository->loadModel(ModelClass::class, $query);

// Store changes
$repository->storeChanges($model);

// Add middleware
$repository->addMiddleware($middleware);

Command Dispatcher

// Dispatch a command
$dispatcher->dispatch($command);

// Register a handler
$dispatcher->registerHandler(CommandClass::class, $handler);

// Add middleware
$dispatcher->addMiddleware($middleware);

Event Bus

// Publish events
$eventBus->publish($stream);

// Subscribe to an event
$eventBus->subscribe(EventClass::class, $handler);

// Add middleware
$eventBus->addMiddleware($middleware);

Projection Store

// Find a projection
$projection = $projectionStore->find($id, ProjectionClass::class);

// Store a projection
$projectionStore->store($projection);

// Check existence
$exists = $projectionStore->has($id, ProjectionClass::class);

// Remove
$projectionStore->remove($id, ProjectionClass::class);

// Transaction
$projectionStore->commit();
$projectionStore->rollback();

Event Store

// Fetch events
$stream = $eventStore->fetch($query, $fromSequence);

// Append events with optimistic locking
$eventStore->append($stream, $concurrencyCheck, $expectedSequence);

Concurrency Control

Backslash uses optimistic locking to prevent race conditions:

  1. Repository tracks the query and expected sequence on load
  2. Before append, the query is re-executed to detect concurrent modifications
  3. If the stream was modified by another process, ConcurrencyException is thrown
  4. Application can implement retry logic
try {
    $student = $repository->loadModel(Student::class, $query);
    $student->register($id, $name);
    $repository->storeChanges($student);
} catch (ConcurrencyException $e) {
    // Retry or handle conflict
}

Testing with Scenario

Backslash provides a BDD-style testing component with Given-When-Then syntax:

$scenario = new Scenario();

$scenario->play(
    new Play()
        // GIVEN - Setup initial state
        ->given(
            new StudentRegisteredEvent('123', 'Alice')
        )
        // WHEN - Execute action
        ->when(
            new EnrollInCourseCommand('123', 'MATH101')
        )
        // Or use a closure for direct model manipulation
        ->when(function (RepositoryInterface $repo): void {
            $student = $repo->loadModel(Student::class, Identifier::is('studentId', '123'));
            $student->enrollInCourse('MATH101');
            $repo->storeChanges($student);
        })
        // THEN - Assert results
        ->then(fn(PublishedEvents $events) =>
            $this->assertNotEmpty($events->getAllOf(StudentEnrolledInCourse::class))
        )
        ->then(fn(UpdatedProjections $projections) =>
            $projections->assertUpdated('123', StudentProjection::class)
        )
        ->then(fn(RepositoryInterface $repo) =>
            $this->assertCount(1, $repo->loadModel(Student::class, ...)->getCourses())
        )
);

Key features:

  • given() - accepts events or commands for setup
  • when() - accepts commands or closures with RepositoryInterface
  • then() - automatic parameter routing based on type hints

Event Publishing Modes:

By default, events generated in given() and when() are published to the event bus (EventPublishingMode::ALWAYS), ensuring backwards compatibility. This means projections are always updated during test setup and execution.

For more efficient testing, you can enable automatic projection detection using EventPublishingMode::DETECT:

// Apply to all tests in a test case
class StudentTest extends TestCase
{
    private Scenario $scenario;

    protected function setUp(): void
    {
        $this->scenario = new Scenario();
        $this->scenario->setEventPublishingMode(EventPublishingMode::DETECT);
    }
}

// Or apply to a specific test
$scenario = new Scenario();
$scenario->setEventPublishingMode(EventPublishingMode::DETECT);
$scenario->play(new Play()->given(...)->when(...)->then(...));

With EventPublishingMode::DETECT, the scenario automatically analyzes your test to determine if projections are needed:

  • Events in given() are NOT published (test setup only)
  • Events in when() ARE published only if assertions reference projections
  • More efficient as projections are only updated when actually needed for assertions

Key Features

1. Synchronous Projections

Projections are updated immediately within the same transaction as command execution, guaranteeing read-your-writes consistency.

2. Event Replay and Rebuilding

Ability to rebuild projections from scratch by replaying historical events.

3. Stream Enrichment

Enrich events with contextual metadata (correlation IDs, user context) as they flow through the system.

class CorrelationIdEnricher implements StreamEnricherInterface
{
    public function enrich(RecordedEventStream $stream): RecordedEventStream
    {
        return $stream->map(function (RecordedEvent $event) {
            return $event->withMetadata(
                $event->metadata->with('correlationId', $this->generateId())
            );
        });
    }
}

4. Complete Audit Trail

Every change is recorded as an immutable event, providing a complete audit trail of all system modifications.

5. Multiple Projections

Ability to build multiple specialized read models from the same event stream.

Complete Execution Flow

Example: Register a Student

1. User → API endpoint
2. API creates RegisterStudentCommand
3. Dispatcher.dispatch(command)
4. PdoTransactionMiddleware starts transaction
5. Handler receives command
6. Handler builds query: Identifier::is('studentId', $id)
7. Repository.loadModel(Student::class, query)
8. EventStore.fetch(query) → returns existing events (or empty)
9. Student model created and events replayed
10. Handler calls $student->register()
11. Model validates and records StudentRegistered event
12. Repository.storeChanges(student)
13. EventStore.append(changes, query, expectedSequence)
14. Optimistic locking check (ConcurrencyException if conflict)
15. Events persisted to database
16. EventBus.publish(events)
17. StudentProjector receives StudentRegistered
18. Projector loads StudentProjection from ProjectionStore
19. Projector updates projection
20. ProjectionStore.store(projection)
21. Transaction committed
22. Response sent to user

Conventions and Best Practices

Naming Conventions

  • Events: Past tense (StudentRegistered, CourseCreated)
  • Commands: Imperative (RegisterStudentCommand, CreateCourseCommand)
  • Models: Nouns (Student, Course)
  • Projections: Noun + Projection (StudentProjection, CourseListProjection)
  • Handlers: Noun + Handler type (StudentCommandHandler, StudentProjector)

Event Design

  • Use readonly classes for immutability
  • Include all relevant identifiers in getIdentifiers()
  • Keep events small and focused (Single Responsibility)
  • Never modify a published event (create a new event instead)

Model Design

  • apply* methods must be pure (no side effects)
  • Public decision methods contain business logic
  • Validate before recording events
  • Don't persist directly, use the repository

Projection Design

  • One projection = one specific read use case
  • Can be denormalized to optimize queries
  • Multiple projections for the same domain concept are OK
  • Implement JsonSerializable to facilitate API responses

Query Design

  • Define the minimal boundary necessary for the decision
  • Include all entities that affect the decision
  • Use and() for restrictive filters
  • Use or() for multi-entity boundaries

Important Files by Role

To define an event

Implement EventInterface in the domain

To create a model

Extend AbstractModel in the domain

To create a query

Use EventClass and Identifier in the domain

To create a command handler

Implement HandlerInterface with HandleCommandTrait

To create an event handler (projector)

Implement EventHandlerInterface with EventHandlerTrait

For bootstrapping

Create Repository, Dispatcher, EventBus, ProjectionStore in application bootstrap

Architecture Advantages

  1. Clear separation between write (business rules) and read (query optimization)
  2. Complete audit trail via immutable events
  3. Scalability: projections can be in separate databases
  4. Flexibility: add new projections without changing write side
  5. Testability: Scenario component with given-when-then syntax
  6. Debugging: ability to replay events to reproduce bugs
  7. Evolution: schema changes via new projections
  8. Performance: optimized queries via denormalized projections

Last updated: 2025-12-23 Version analyzed: branch 2.x-scenario