Skip to content

Latest commit

 

History

History
598 lines (456 loc) · 13.7 KB

File metadata and controls

598 lines (456 loc) · 13.7 KB

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?

// 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

declare(strict_types=1);

namespace App\Domain\Order;

/**
 * The Order aggregate - encapsulates all internal state
 */
class Order
{
    private float $totalAmount;
    private \DateTimeImmutable $dueDate;
    private bool $noticeSent;
    private bool $inCollection;

    public function __construct(
        float $totalAmount,
        \DateTimeImmutable $dueDate
    ) {
        $this->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

declare(strict_types=1);

namespace App\Domain\Order\Specifications;

use App\Domain\Order\Order;
use Phauthentic\Specification\AbstractSpecification;

/**
 * Specification that delegates to the aggregate's query method
 */
class MinimumOrderValueSpecification extends AbstractSpecification
{
    public function __construct(
        private float $minimumValue
    ) {
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof Order) {
            return false;
        }

        // Delegate to the aggregate - no direct property access
        return $candidate->hasTotalAmountOfAtLeast($this->minimumValue);
    }
}
<?php

declare(strict_types=1);

namespace App\Domain\Order\Specifications;

use App\Domain\Order\Order;
use Phauthentic\Specification\AbstractSpecification;

/**
 * Specification for checking if an order is overdue
 */
class OverdueSpecification extends AbstractSpecification
{
    public function __construct(
        private \DateTimeImmutable $referenceDate
    ) {
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof Order) {
            return false;
        }

        return $candidate->isOverdue($this->referenceDate);
    }
}

Usage

$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

declare(strict_types=1);

namespace App\ReadModel;

/**
 * Read model for orders - optimized for queries
 * This is a projection, not the aggregate
 */
class OrderReadModel
{
    public function __construct(
        public readonly string $orderId,
        public readonly float $totalAmount,
        public readonly \DateTimeImmutable $dueDate,
        public readonly bool $noticeSent,
        public readonly bool $inCollection,
        public readonly string $customerName,
        public readonly \DateTimeImmutable $createdAt
    ) {
    }

    public function isOverdue(\DateTimeImmutable $now): bool
    {
        return $this->dueDate < $now;
    }
}
<?php

declare(strict_types=1);

namespace App\ReadModel\Specifications;

use App\ReadModel\OrderReadModel;
use Phauthentic\Specification\AbstractSpecification;

/**
 * Specification operating on the read model
 */
class MinimumOrderValueSpecification extends AbstractSpecification
{
    public function __construct(
        private float $minimumValue
    ) {
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof OrderReadModel) {
            return false;
        }

        // Direct property access is fine on read models
        return $candidate->totalAmount >= $this->minimumValue;
    }
}
<?php

declare(strict_types=1);

namespace App\ReadModel\Specifications;

use App\ReadModel\OrderReadModel;
use Phauthentic\Specification\AbstractSpecification;

/**
 * Specification for overdue orders on read model
 */
class OverdueSpecification extends AbstractSpecification
{
    public function __construct(
        private \DateTimeImmutable $referenceDate
    ) {
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof OrderReadModel) {
            return false;
        }

        return $candidate->isOverdue($this->referenceDate);
    }
}

Usage

// 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

declare(strict_types=1);

namespace App\Domain\Invoice;

/**
 * Invoice aggregate with internal business rule
 */
class Invoice
{
    private float $amount;
    private \DateTimeImmutable $dueDate;
    private bool $noticeSent;
    private bool $inCollection;

    public function __construct(
        float $amount,
        \DateTimeImmutable $dueDate
    ) {
        $this->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

$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

declare(strict_types=1);

namespace App\Domain\Order;

/**
 * Order aggregate with readonly property access for queries
 */
class Order
{
    private bool $inCollection = false;

    public function __construct(
        public readonly string $id,
        public readonly float $totalAmount,
        public readonly \DateTimeImmutable $dueDate,
        private bool $noticeSent = false
    ) {
    }

    // Getter for mutable state
    public function isNoticeSent(): bool
    {
        return $this->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

declare(strict_types=1);

namespace App\Domain\Order\Specifications;

use App\Domain\Order\Order;
use Phauthentic\Specification\AbstractSpecification;

/**
 * Specification using readonly properties
 */
class MinimumOrderValueSpecification extends AbstractSpecification
{
    public function __construct(
        private float $minimumValue
    ) {
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof Order) {
            return false;
        }

        // Access readonly property directly
        return $candidate->totalAmount >= $this->minimumValue;
    }
}
<?php

declare(strict_types=1);

namespace App\Domain\Order\Specifications;

use App\Domain\Order\Order;
use Phauthentic\Specification\AbstractSpecification;

/**
 * Specification for overdue orders
 */
class OverdueSpecification extends AbstractSpecification
{
    public function __construct(
        private \DateTimeImmutable $referenceDate
    ) {
    }

    public function isSatisfiedBy(mixed $candidate): bool
    {
        if (!$candidate instanceof Order) {
            return false;
        }

        return $candidate->dueDate < $this->referenceDate;
    }
}

Usage

$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
// 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);
}