diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1390bb1..c58d772 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.1', '8.2', '8.3', '8.4'] + php-version: ['8.1', '8.2', '8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v4 diff --git a/docs/How-to-use-Specifications.md b/docs/How-to-use-Specifications.md new file mode 100644 index 0000000..a91a6ef --- /dev/null +++ b/docs/How-to-use-Specifications.md @@ -0,0 +1,598 @@ +# How to Use Specifications + +This document explains different approaches to using the Specification Pattern, particularly in the context of Domain-Driven Design (DDD) where aggregates should encapsulate their internal state. + +## The Challenge: Specifications and Encapsulation + +In strict DDD, aggregates should not expose their internal properties directly. This raises the question: how can a specification check conditions on an aggregate without breaking encapsulation? + +```php +// This violates encapsulation - directly accessing internal state +public function isSatisfiedBy(mixed $candidate): bool +{ + return $candidate->totalAmount >= $this->minimumValue; +} +``` + +There are several approaches to solve this, each with its own trade-offs. + +--- + +## Approach 1: Aggregate Exposes Query Methods + +Instead of exposing properties, the aggregate provides behavior-focused query methods. The specification delegates the actual check to the aggregate. + +**Pros:** + +- Maintains encapsulation +- Business logic stays in the aggregate +- Specification remains composable + +**Cons:** + +- Aggregate interface grows with each new specification need +- May lead to many single-purpose query methods + +### Example + +```php +totalAmount = $totalAmount; + $this->dueDate = $dueDate; + $this->noticeSent = false; + $this->inCollection = false; + } + + // Query methods - expose questions, not data + public function hasTotalAmountOfAtLeast(float $minimum): bool + { + return $this->totalAmount >= $minimum; + } + + public function isOverdue(\DateTimeImmutable $now): bool + { + return $this->dueDate < $now; + } + + public function hasNoticeSent(): bool + { + return $this->noticeSent; + } + + public function isInCollection(): bool + { + return $this->inCollection; + } + + // Command methods + public function markNoticeSent(): void + { + $this->noticeSent = true; + } + + public function sendToCollection(): void + { + $this->inCollection = true; + } +} +``` + +```php +hasTotalAmountOfAtLeast($this->minimumValue); + } +} +``` + +```php +isOverdue($this->referenceDate); + } +} +``` + +### Usage + +```php +$now = new \DateTimeImmutable(); + +$overdue = new OverdueSpecification($now); +$minimumValue = new MinimumOrderValueSpecification(100.00); +$noticeSent = new NoticeSentSpecification(); +$inCollection = new InCollectionSpecification(); + +// Compose specifications +$sendToCollection = $overdue + ->and($noticeSent) + ->and($minimumValue) + ->andNot($inCollection); + +foreach ($orders as $order) { + if ($sendToCollection->isSatisfiedBy($order)) { + $order->sendToCollection(); + } +} +``` + +--- + +## Approach 2: Specifications on Read Models (CQRS) + +In CQRS (Command Query Responsibility Segregation) architectures, specifications operate on read models or projections, not the aggregate itself. The aggregate remains fully encapsulated for write operations. + +**Pros:** + +- Complete separation of read and write concerns +- Read models can be optimized for queries +- Aggregate stays fully encapsulated + +**Cons:** + +- Requires maintaining separate read models +- Eventual consistency between write and read sides +- More infrastructure complexity + +### Example + +```php +dueDate < $now; + } +} +``` + +```php +totalAmount >= $this->minimumValue; + } +} +``` + +```php +isOverdue($this->referenceDate); + } +} +``` + +### Usage + +```php +// Read side - uses read models from a projection/query service +$orderReadModels = $orderQueryService->findAllPendingOrders(); + +$now = new \DateTimeImmutable(); +$sendToCollection = (new OverdueSpecification($now)) + ->and(new NoticeSentSpecification()) + ->andNot(new InCollectionSpecification()); + +// Filter read models +$ordersToCollect = array_filter( + $orderReadModels, + fn(OrderReadModel $order) => $sendToCollection->isSatisfiedBy($order) +); + +// Write side - load aggregates only for the ones that need action +foreach ($ordersToCollect as $orderReadModel) { + $order = $orderRepository->getById($orderReadModel->orderId); + $order->sendToCollection(); + $orderRepository->save($order); +} +``` + +--- + +## Approach 3: Specifications Inside the Aggregate + +Some DDD practitioners argue that complex business rules should live entirely inside the aggregate. The specification pattern is not used externally; instead, the aggregate exposes a single method that encapsulates the rule. + +**Pros:** + +- All business logic in one place +- Maximum encapsulation +- Easy to understand and maintain for simple cases + +**Cons:** + +- Loses composability of specifications +- Cannot easily reuse partial rules +- Aggregate becomes responsible for query logic + +### Example + +```php +amount = $amount; + $this->dueDate = $dueDate; + $this->noticeSent = false; + $this->inCollection = false; + } + + /** + * Business rule encapsulated inside the aggregate + */ + public function shouldBeSentToCollection(\DateTimeImmutable $now): bool + { + return $this->isOverdue($now) + && $this->noticeSent + && !$this->inCollection; + } + + private function isOverdue(\DateTimeImmutable $now): bool + { + return $this->dueDate < $now; + } + + public function markNoticeSent(): void + { + $this->noticeSent = true; + } + + public function sendToCollection(): void + { + if (!$this->shouldBeSentToCollection(new \DateTimeImmutable())) { + throw new \DomainException('Invoice cannot be sent to collection'); + } + + $this->inCollection = true; + } +} +``` + +### Usage + +```php +$now = new \DateTimeImmutable(); + +foreach ($invoices as $invoice) { + if ($invoice->shouldBeSentToCollection($now)) { + $invoice->sendToCollection(); + } +} +``` + +--- + +## Approach 4: Pragmatic Property Access + +Many real-world implementations take a pragmatic stance: specifications are used for query/filtering logic where exposing read-only properties is acceptable. The aggregate's invariants and mutation logic remain protected, but query-related properties can be exposed via getters or public readonly properties. + +**Pros:** + +- Simple and straightforward +- Works well for filtering/querying use cases +- Full composability of specifications + +**Cons:** + +- Some encapsulation is sacrificed +- Requires discipline to not misuse exposed properties +- Purists may object + +### Example + +```php +noticeSent; + } + + public function isInCollection(): bool + { + return $this->inCollection; + } + + // Commands remain protected + public function markNoticeSent(): void + { + $this->noticeSent = true; + } + + public function sendToCollection(): void + { + $this->inCollection = true; + } +} +``` + +```php +totalAmount >= $this->minimumValue; + } +} +``` + +```php +dueDate < $this->referenceDate; + } +} +``` + +### Usage + +```php +$now = new \DateTimeImmutable(); + +$sendToCollection = (new OverdueSpecification($now)) + ->and(new NoticeSentSpecification()) + ->andNot(new InCollectionSpecification()); + +foreach ($orders as $order) { + if ($sendToCollection->isSatisfiedBy($order)) { + $order->sendToCollection(); + } +} +``` + +--- + +## Choosing the Right Approach + +| Approach | Best For | Avoid When | +|----------|----------|------------| +| **Query Methods** | Strict DDD, complex aggregates | Many specifications needed (interface bloat) | +| **Read Models (CQRS)** | Large systems, complex queries, event sourcing | Simple applications, tight deadlines | +| **Internal to Aggregate** | Simple, non-composable rules | Rules need to be reused or combined | +| **Pragmatic Access** | Filtering, simple domains, rapid development | Strict encapsulation requirements | + +## Combining Approaches + +These approaches are not mutually exclusive. A common pattern is: + +1. Use **Read Models** for complex queries and filtering (e.g., search, reports) +2. Use **Query Methods** for domain-critical business rules +3. Use **Internal Aggregate Methods** for invariant checks before state changes + +```php +// Read side: filter candidates using specifications on read models +$candidates = $orderQueryService->findOverdueOrders(); +$eligibleForCollection = (new NoticeSentSpecification()) + ->andNot(new InCollectionSpecification()); + +$toProcess = array_filter( + $candidates, + fn($order) => $eligibleForCollection->isSatisfiedBy($order) +); + +// Write side: aggregate enforces its own invariants +foreach ($toProcess as $orderReadModel) { + $order = $orderRepository->getById($orderReadModel->orderId); + + // Aggregate validates internally before state change + $order->sendToCollection(); // May throw if invariants not met + + $orderRepository->save($order); +} +``` diff --git a/readme.md b/readme.md index 2d71a1a..9769c0a 100644 --- a/readme.md +++ b/readme.md @@ -19,27 +19,40 @@ Since a specification is an encapsulation of logic in a reusable form it is very * https://en.wikipedia.org/wiki/Specification_pattern * http://www.martinfowler.com/apsupp/spec.pdf +## How to use them + +When using specifications with Domain-Driven Design, a common question is how to check conditions on aggregates without breaking encapsulation. There are several approaches, from delegating checks to aggregate query methods, to using read models in CQRS architectures, to pragmatic property access for simpler use cases. + +See [How to use Specifications](docs/How-to-use-Specifications.md) for detailed guidance and complete examples. + ## Example +The following example demonstrates a debt collection business rule for invoices. + ```php +// Define specifications for invoice collection rules $overDue = new OverDueSpecification(); $noticeSent = new NoticeSentSpecification(); $inCollection = new InCollectionSpecification(); -// Example of specification pattern logic chaining +// Business Rule: Send to collection agency when invoice is: +// - Past due date AND +// - Customer has been notified AND +// - Not already in collection $sendToCollection = $overDue ->and($noticeSent) - ->and($inCollection->not()); - -$invoiceCollection = $service->getInvoices(); + ->andNot($inCollection); -foreach ($invoiceCollection as $invoice) { +// Apply the business rule to all invoices +foreach ($service->getInvoices() as $invoice) { if ($sendToCollection->isSatisfiedBy($invoice)) { $invoice->sendToCollection(); } } ``` +Each specification encapsulates a single business rule check (e.g., `OverDueSpecification` checks if `$invoice->dueDate < now()`). The pattern allows combining these atomic rules using boolean logic (`and`, `or`, `not`, `andNot`, `orNot`) to form complex, readable business rules that can be reused and unit tested independently. + ## License This library is under the MIT license.