Skip to content

Commit 9238bd6

Browse files
Refactor documentation on specification approaches
1 parent bab5f6f commit 9238bd6

File tree

1 file changed

+52
-92
lines changed

1 file changed

+52
-92
lines changed

docs/How-to-use-Specifications.md

Lines changed: 52 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -323,97 +323,7 @@ foreach ($ordersToCollect as $orderReadModel) {
323323

324324
---
325325

326-
## Approach 3: Specifications Inside the Aggregate
327-
328-
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.
329-
330-
**Pros:**
331-
332-
- All business logic in one place
333-
- Maximum encapsulation
334-
- Easy to understand and maintain for simple cases
335-
336-
**Cons:**
337-
338-
- Loses composability of specifications
339-
- Cannot easily reuse partial rules
340-
- Aggregate becomes responsible for query logic
341-
342-
### Example
343-
344-
```php
345-
<?php
346-
347-
declare(strict_types=1);
348-
349-
namespace App\Domain\Invoice;
350-
351-
/**
352-
* Invoice aggregate with internal business rule
353-
*/
354-
class Invoice
355-
{
356-
private float $amount;
357-
private \DateTimeImmutable $dueDate;
358-
private bool $noticeSent;
359-
private bool $inCollection;
360-
361-
public function __construct(
362-
float $amount,
363-
\DateTimeImmutable $dueDate
364-
) {
365-
$this->amount = $amount;
366-
$this->dueDate = $dueDate;
367-
$this->noticeSent = false;
368-
$this->inCollection = false;
369-
}
370-
371-
/**
372-
* Business rule encapsulated inside the aggregate
373-
*/
374-
public function shouldBeSentToCollection(\DateTimeImmutable $now): bool
375-
{
376-
return $this->isOverdue($now)
377-
&& $this->noticeSent
378-
&& !$this->inCollection;
379-
}
380-
381-
private function isOverdue(\DateTimeImmutable $now): bool
382-
{
383-
return $this->dueDate < $now;
384-
}
385-
386-
public function markNoticeSent(): void
387-
{
388-
$this->noticeSent = true;
389-
}
390-
391-
public function sendToCollection(): void
392-
{
393-
if (!$this->shouldBeSentToCollection(new \DateTimeImmutable())) {
394-
throw new \DomainException('Invoice cannot be sent to collection');
395-
}
396-
397-
$this->inCollection = true;
398-
}
399-
}
400-
```
401-
402-
### Usage
403-
404-
```php
405-
$now = new \DateTimeImmutable();
406-
407-
foreach ($invoices as $invoice) {
408-
if ($invoice->shouldBeSentToCollection($now)) {
409-
$invoice->sendToCollection();
410-
}
411-
}
412-
```
413-
414-
---
415-
416-
## Approach 4: Pragmatic Property Access
326+
## Approach 3: Pragmatic Property Access
417327

418328
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.
419329

@@ -556,6 +466,56 @@ foreach ($orders as $order) {
556466
}
557467
```
558468

469+
## Approach 4: Double Dispatch (The Purist Approach)
470+
471+
The aggregate remains completely "blind" to its properties from the outside. Instead, the aggregate accepts the specification and "feeds" it the necessary data through a specific internal interface.
472+
473+
**Pros:**
474+
475+
- Zero leakage: Properties remain private and no getters are created.
476+
- The aggregate controls exactly what data the specification is allowed to see.
477+
478+
**Cons:**
479+
480+
- Requires a custom interface for the specification.
481+
- Slightly higher cognitive complexity.
482+
483+
```php
484+
namespace App\Domain\Order;
485+
486+
/**
487+
* Interface that defines what data an Order Spec is allowed to see
488+
*/
489+
interface OrderDataInterface {
490+
public function getTotalAmount(): float;
491+
public function getDueDate(): \DateTimeImmutable;
492+
}
493+
494+
class Order implements OrderDataInterface
495+
{
496+
private float $totalAmount;
497+
private \DateTimeImmutable $dueDate;
498+
499+
// The aggregate "accepts" the spec and passes itself as the data provider
500+
public function satisfies(OrderSpecificationInterface $spec): bool
501+
{
502+
return $spec->isSatisfiedByOrder($this);
503+
}
504+
505+
public function getTotalAmount(): float { return $this->totalAmount; }
506+
public function getDueDate(): \DateTimeImmutable { return $this->dueDate; }
507+
}
508+
509+
// The Specification now depends on the Interface, not the Aggregate
510+
class MinimumOrderValueSpecification implements OrderSpecificationInterface
511+
{
512+
public function isSatisfiedByOrder(OrderDataInterface $order): bool
513+
{
514+
return $order->getTotalAmount() >= $this->minimumValue;
515+
}
516+
}
517+
```
518+
559519
---
560520

561521
## Choosing the Right Approach
@@ -564,8 +524,8 @@ foreach ($orders as $order) {
564524
|----------|----------|------------|
565525
| **Query Methods** | Strict DDD, complex aggregates | Many specifications needed (interface bloat) |
566526
| **Read Models (CQRS)** | Large systems, complex queries, event sourcing | Simple applications, tight deadlines |
567-
| **Internal to Aggregate** | Simple, non-composable rules | Rules need to be reused or combined |
568527
| **Pragmatic Access** | Filtering, simple domains, rapid development | Strict encapsulation requirements |
528+
| **Double Dispatch** | High encapsulation, shared logic | Overkill for simple logic; adds boilerplate |
569529

570530
## Combining Approaches
571531

0 commit comments

Comments
 (0)