HardТеория13 min

Современные паттерны

DTO, Value Object, Repository, Specification, CQRS, Event-driven архитектура в PHP 8.4

Современные паттерны в PHP

Эти паттерны не входят в классическую книгу GoF, но стали стандартом в современных PHP-приложениях на Symfony и Laravel.

Data Transfer Object (DTO)

Проблема

Данные передаются между слоями приложения через массивы -- нет типобезопасности, автодополнения, валидации. Массив $data['usre_name'] (опечатка) -- ошибка обнаружится только в рантайме.

Решение

<?php
declare(strict_types=1);

// Immutable DTO with PHP 8.4 features
final readonly class CreateUserDto
{
    public function __construct(
        public string $name,
        public string $email,
        public string $password,
        public UserRole $role = UserRole::User,
    ) {}

    // Named constructor from request data
    public static function fromRequest(array $data): self
    {
        return new self(
            name: $data['name'] ?? throw new \InvalidArgumentException('Name required'),
            email: $data['email'] ?? throw new \InvalidArgumentException('Email required'),
            password: $data['password'] ?? throw new \InvalidArgumentException('Password required'),
            role: isset($data['role']) ? UserRole::from($data['role']) : UserRole::User,
        );
    }
}

final readonly class UserResponseDto
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public UserRole $role,
        public \DateTimeImmutable $createdAt,
    ) {}

    // Build from entity
    public static function fromEntity(User $user): self
    {
        return new self(
            id: $user->getId(),
            name: $user->getName(),
            email: $user->getEmail(),
            role: $user->getRole(),
            createdAt: $user->getCreatedAt(),
        );
    }

    public function toArray(): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'email' => $this->email,
            'role' => $this->role->value,
            'created_at' => $this->createdAt->format(\DateTimeInterface::ATOM),
        ];
    }
}

enum UserRole: string
{
    case Admin = 'admin';
    case User = 'user';
    case Moderator = 'moderator';

    public function hasPermission(string $permission): bool
    {
        $permissions = match ($this) {
            self::Admin => ['read', 'write', 'delete', 'manage_users'],
            self::Moderator => ['read', 'write', 'delete'],
            self::User => ['read', 'write'],
        };

        return in_array($permission, $permissions, true);
    }
}

PHP 8.4: DTO с property hooks

<?php
declare(strict_types=1);

// DTO with validation via property hooks
final readonly class CreateOrderDto
{
    public string $email {
        set(string $value) {
            if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                throw new \InvalidArgumentException("Invalid email: {$value}");
            }
            $this->email = strtolower($value);
        }
    }

    public int $quantity {
        set(int $value) {
            if ($value < 1 || $value > 1000) {
                throw new \InvalidArgumentException('Quantity must be 1-1000');
            }
            $this->quantity = $value;
        }
    }

    public function __construct(
        public int $productId,
        string $email,
        int $quantity,
        public ?string $comment = null,
    ) {
        $this->email = $email;
        $this->quantity = $quantity;
    }
}

// Validation happens at construction time
$dto = new CreateOrderDto(
    productId: 42,
    email: '[email protected]', // Normalized to lowercase
    quantity: 5,
);

echo $dto->email; // "[email protected]"

Когда использовать

  • Передача данных между слоями (Controller -> Service -> Repository)
  • API request/response объекты
  • Замена ассоциативных массивов типобезопасными объектами

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Attribute\SerializedName;

// Symfony DTO with validation attributes
final readonly class RegistrationDto
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 2, max: 100)]
        public string $name,

        #[Assert\NotBlank]
        #[Assert\Email]
        public string $email,

        #[Assert\NotBlank]
        #[Assert\Length(min: 8)]
        #[Assert\PasswordStrength]
        public string $password,

        #[SerializedName('phone_number')]
        #[Assert\Regex('/^\+\d{10,15}$/')]
        public ?string $phone = null,
    ) {}
}

// In controller: automatic deserialization + validation
// #[MapRequestPayload] RegistrationDto $dto

Value Object

Проблема

Примитивные типы (string, int, float) не несут бизнес-смысла. string $email может содержать что угодно. Невалидные данные проникают в домен.

Решение

<?php
declare(strict_types=1);

// Value Object: immutable, compared by value, self-validating
final readonly class Email
{
    public string $value;

    public function __construct(string $value)
    {
        $normalized = strtolower(trim($value));

        if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException("Invalid email: {$value}");
        }

        $this->value = $normalized;
    }

    public function domain(): string
    {
        return substr($this->value, strpos($this->value, '@') + 1);
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

// Money Value Object -- prevents currency mixing bugs
final readonly class Money
{
    public function __construct(
        public int $amount, // Always in smallest unit (cents)
        public Currency $currency,
    ) {}

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \LogicException(
                "Cannot add {$this->currency->value} and {$other->currency->value}",
            );
        }

        return new self($this->amount + $other->amount, $this->currency);
    }

    public function subtract(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \LogicException('Currency mismatch');
        }

        return new self($this->amount - $other->amount, $this->currency);
    }

    public function multiply(int $factor): self
    {
        return new self($this->amount * $factor, $this->currency);
    }

    public function isPositive(): bool
    {
        return $this->amount > 0;
    }

    public function format(): string
    {
        $decimal = number_format($this->amount / 100, 2, '.', ',');
        return "{$this->currency->symbol()}{$decimal}";
    }

    public function equals(self $other): bool
    {
        return $this->amount === $other->amount
            && $this->currency === $other->currency;
    }
}

enum Currency: string
{
    case USD = 'USD';
    case EUR = 'EUR';
    case RUB = 'RUB';

    public function symbol(): string
    {
        return match ($this) {
            self::USD => '$',
            self::EUR => "\u{20AC}",
            self::RUB => "\u{20BD}",
        };
    }
}

// Address Value Object
final readonly class Address
{
    public function __construct(
        public string $street,
        public string $city,
        public string $postalCode,
        public string $country,
    ) {
        if (trim($street) === '' || trim($city) === '') {
            throw new \InvalidArgumentException('Street and city are required');
        }
    }

    public function oneLine(): string
    {
        return "{$this->street}, {$this->city}, {$this->postalCode}, {$this->country}";
    }

    public function equals(self $other): bool
    {
        return $this->street === $other->street
            && $this->city === $other->city
            && $this->postalCode === $other->postalCode
            && $this->country === $other->country;
    }
}

// Usage: impossible to create invalid data
$email = new Email('[email protected]');
echo $email->value; // "[email protected]"

$price = new Money(1999, Currency::USD);
$tax = new Money(200, Currency::USD);
$total = $price->add($tax);
echo $total->format(); // "$21.99"

Когда использовать

  • Примитив несёт бизнес-смысл (email, money, phone, address)
  • Нужна самовалидация при создании
  • Сравнение по значению, а не по ссылке
  • Иммутабельность -- гарантия целостности

В Symfony / Doctrine

<?php
declare(strict_types=1);

use Doctrine\ORM\Mapping as ORM;

// Doctrine embeddable for Value Objects
#[ORM\Embeddable]
final readonly class Money
{
    public function __construct(
        #[ORM\Column(type: 'integer')]
        public int $amount,

        #[ORM\Column(type: 'string', length: 3, enumType: Currency::class)]
        public Currency $currency,
    ) {}
}

// In entity
#[ORM\Entity]
final class Product
{
    #[ORM\Embedded(class: Money::class)]
    private Money $price;
}

Repository

Проблема

Доменный слой не должен знать о базе данных. Прямые запросы в сервисах делают код нетестируемым и привязанным к ORM.

Решение

<?php
declare(strict_types=1);

// Repository interface in Domain layer
interface OrderRepository
{
    public function findById(OrderId $id): ?Order;
    public function findByCustomer(CustomerId $customerId): array;
    public function save(Order $order): void;
    public function remove(Order $order): void;
    public function nextId(): OrderId;
}

// Value Object for ID
final readonly class OrderId
{
    public function __construct(
        public string $value,
    ) {
        if (trim($value) === '') {
            throw new \InvalidArgumentException('OrderId cannot be empty');
        }
    }

    public function equals(self $other): bool
    {
        return $this->value === $other->value;
    }

    public function __toString(): string
    {
        return $this->value;
    }
}

final readonly class CustomerId
{
    public function __construct(
        public string $value,
    ) {}
}

// Infrastructure implementation with Doctrine
final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private readonly EntityManagerInterface $em,
    ) {}

    public function findById(OrderId $id): ?Order
    {
        return $this->em->find(Order::class, $id->value);
    }

    public function findByCustomer(CustomerId $customerId): array
    {
        return $this->em->createQueryBuilder()
            ->select('o')
            ->from(Order::class, 'o')
            ->where('o.customerId = :customerId')
            ->setParameter('customerId', $customerId->value)
            ->orderBy('o.createdAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    public function save(Order $order): void
    {
        $this->em->persist($order);
        $this->em->flush();
    }

    public function remove(Order $order): void
    {
        $this->em->remove($order);
        $this->em->flush();
    }

    public function nextId(): OrderId
    {
        return new OrderId(\Symfony\Component\Uid\Uuid::v7()->toRfc4122());
    }
}

// In-memory implementation for tests
final class InMemoryOrderRepository implements OrderRepository
{
    /** @var array<string, Order> */
    private array $orders = [];

    public function findById(OrderId $id): ?Order
    {
        return $this->orders[$id->value] ?? null;
    }

    public function findByCustomer(CustomerId $customerId): array
    {
        return array_filter(
            $this->orders,
            fn(Order $o): bool => $o->getCustomerId()->equals($customerId),
        );
    }

    public function save(Order $order): void
    {
        $this->orders[$order->getId()->value] = $order;
    }

    public function remove(Order $order): void
    {
        unset($this->orders[$order->getId()->value]);
    }

    public function nextId(): OrderId
    {
        return new OrderId(uniqid('order_', true));
    }
}

// Service depends on interface -- testable and flexible
final readonly class OrderService
{
    public function __construct(
        private OrderRepository $orders,
    ) {}

    public function getOrder(string $id): ?Order
    {
        return $this->orders->findById(new OrderId($id));
    }
}

Когда использовать

  • Разделение домена и инфраструктуры
  • Нужны разные хранилища (БД, кеш, файлы, API)
  • Тестирование без базы данных (in-memory реализация)

В Symfony

<?php
declare(strict_types=1);

// Symfony: auto-register repository implementation
// services.yaml
// App\Domain\Repository\OrderRepository:
//     class: App\Infrastructure\Repository\DoctrineOrderRepository

// Or via interface binding
// _defaults:
//   bind:
//     App\Domain\Repository\OrderRepository: '@App\Infrastructure\Repository\DoctrineOrderRepository'

Specification

Проблема

Сложные бизнес-правила дублируются: в контроллере для валидации, в репозитории для фильтрации, в сервисе для авторизации. Правила должны быть выражены один раз как объекты.

Решение

<?php
declare(strict_types=1);

// Generic Specification interface
interface Specification
{
    public function isSatisfiedBy(mixed $candidate): bool;
}

// Base class with combinators
abstract class CompositeSpecification implements Specification
{
    public function and(Specification $other): Specification
    {
        return new AndSpecification($this, $other);
    }

    public function or(Specification $other): Specification
    {
        return new OrSpecification($this, $other);
    }

    public function not(): Specification
    {
        return new NotSpecification($this);
    }
}

final readonly class AndSpecification extends CompositeSpecification
{
    public function __construct(
        private Specification $left,
        private Specification $right,
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->left->isSatisfiedBy($candidate)
            && $this->right->isSatisfiedBy($candidate);
    }
}

final readonly class OrSpecification extends CompositeSpecification
{
    public function __construct(
        private Specification $left,
        private Specification $right,
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->left->isSatisfiedBy($candidate)
            || $this->right->isSatisfiedBy($candidate);
    }
}

final readonly class NotSpecification extends CompositeSpecification
{
    public function __construct(
        private Specification $inner,
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return !$this->inner->isSatisfiedBy($candidate);
    }
}

// Business specifications
final class IsActiveUser extends CompositeSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate instanceof UserEntity && $candidate->isActive();
    }
}

final class HasVerifiedEmail extends CompositeSpecification
{
    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate instanceof UserEntity && $candidate->isEmailVerified();
    }
}

final readonly class HasMinimumOrders extends CompositeSpecification
{
    public function __construct(
        private int $minimum,
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $candidate instanceof UserEntity
            && $candidate->getOrderCount() >= $this->minimum;
    }
}

// Compose complex rules from simple ones
$eligibleForPromotion = (new IsActiveUser())
    ->and(new HasVerifiedEmail())
    ->and(new HasMinimumOrders(5));

// Reuse in different contexts
$users = [/* ... */];
$eligible = array_filter($users, $eligibleForPromotion->isSatisfiedBy(...));

PHP 8.4: Specification с first-class callables

<?php
declare(strict_types=1);

// Modern approach: closures as specifications
final readonly class Spec
{
    /** @param \Closure(mixed): bool $predicate */
    public function __construct(
        private \Closure $predicate,
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return ($this->predicate)($candidate);
    }

    public function and(self $other): self
    {
        return new self(
            fn(mixed $c): bool => $this->isSatisfiedBy($c) && $other->isSatisfiedBy($c),
        );
    }

    public function or(self $other): self
    {
        return new self(
            fn(mixed $c): bool => $this->isSatisfiedBy($c) || $other->isSatisfiedBy($c),
        );
    }

    public function not(): self
    {
        return new self(
            fn(mixed $c): bool => !$this->isSatisfiedBy($c),
        );
    }
}

// Concise specification definitions
$isActive = new Spec(fn(UserEntity $u): bool => $u->isActive());
$isVerified = new Spec(fn(UserEntity $u): bool => $u->isEmailVerified());
$hasOrders = new Spec(fn(UserEntity $u): bool => $u->getOrderCount() >= 5);

$canGetDiscount = $isActive->and($isVerified)->and($hasOrders);

Когда использовать

  • Сложные бизнес-правила, используемые в нескольких местах
  • Правила комбинируются динамически
  • Фильтрация коллекций по бизнес-критериям
  • Валидация в доменном слое

CQRS (Command Query Responsibility Segregation)

Проблема

Один и тот же сервис/репозиторий обслуживает и чтение, и запись. Оптимизация одного ухудшает другое. Разные требования к консистентности, кешированию, масштабированию.

Решение

<?php
declare(strict_types=1);

// ===== COMMAND SIDE (Write) =====

// Command -- intent to change state
final readonly class PlaceOrderCommand
{
    public function __construct(
        public string $customerId,
        /** @var list<array{productId: int, quantity: int}> */
        public array $items,
        public string $shippingAddress,
    ) {}
}

// Command handler -- executes business logic
final readonly class PlaceOrderHandler
{
    public function __construct(
        private OrderRepository $orders,
        private ProductRepository $products,
        private EventDispatcherInterface $events,
    ) {}

    public function __invoke(PlaceOrderCommand $command): string
    {
        // Validate business rules
        $orderId = $this->orders->nextId();
        $order = Order::create(
            id: $orderId,
            customerId: new CustomerId($command->customerId),
            address: $command->shippingAddress,
        );

        foreach ($command->items as $item) {
            $product = $this->products->findById($item['productId']);

            if ($product === null) {
                throw new ProductNotFoundException($item['productId']);
            }

            $order->addItem($product, $item['quantity']);
        }

        $this->orders->save($order);

        // Dispatch domain events
        $this->events->dispatch(new OrderPlacedEvent(
            orderId: $orderId->value,
            customerId: $command->customerId,
            total: $order->getTotal()->amount,
        ));

        return $orderId->value;
    }
}

// ===== QUERY SIDE (Read) =====

// Query -- request for data (no side effects)
final readonly class GetOrderQuery
{
    public function __construct(
        public string $orderId,
    ) {}
}

// Read model -- optimized for display
final readonly class OrderView
{
    public function __construct(
        public string $id,
        public string $customerName,
        public string $status,
        public string $total,
        public string $createdAt,
        /** @var list<OrderItemView> */
        public array $items,
    ) {}
}

final readonly class OrderItemView
{
    public function __construct(
        public string $productName,
        public int $quantity,
        public string $unitPrice,
        public string $subtotal,
    ) {}
}

// Query handler -- reads from optimized store
final readonly class GetOrderHandler
{
    public function __construct(
        private \PDO $readDb, // Can be a read replica
    ) {}

    public function __invoke(GetOrderQuery $query): ?OrderView
    {
        $stmt = $this->readDb->prepare(<<<'SQL'
            SELECT o.id, c.name as customer_name, o.status,
                   o.total_amount, o.created_at
            FROM order_views o
            JOIN customers c ON c.id = o.customer_id
            WHERE o.id = :id
        SQL);

        $stmt->execute(['id' => $query->orderId]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);

        if ($row === false) {
            return null;
        }

        return new OrderView(
            id: $row['id'],
            customerName: $row['customer_name'],
            status: $row['status'],
            total: $row['total_amount'],
            createdAt: $row['created_at'],
            items: $this->loadItems($query->orderId),
        );
    }

    private function loadItems(string $orderId): array
    {
        // Load from denormalized view table
        return [];
    }
}

// ===== MESSAGE BUS (Dispatcher) =====

interface CommandBus
{
    public function dispatch(object $command): mixed;
}

interface QueryBus
{
    public function ask(object $query): mixed;
}

// Controller uses separate buses
final readonly class OrderController
{
    public function __construct(
        private CommandBus $commandBus,
        private QueryBus $queryBus,
    ) {}

    public function create(array $requestData): string
    {
        $orderId = $this->commandBus->dispatch(
            new PlaceOrderCommand(
                customerId: $requestData['customer_id'],
                items: $requestData['items'],
                shippingAddress: $requestData['address'],
            ),
        );

        return $orderId;
    }

    public function show(string $id): ?OrderView
    {
        return $this->queryBus->ask(new GetOrderQuery($id));
    }
}

Когда использовать

  • Разные требования к чтению и записи (нагрузка, кеш)
  • Сложные write-модели и простые read-модели
  • Масштабирование чтения отдельно от записи
  • Event Sourcing (CQRS -- естественный компаньон)

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;

// Symfony Messenger as Command/Query Bus
// config/packages/messenger.yaml
// framework:
//   messenger:
//     buses:
//       command.bus: ~
//       query.bus: ~

#[AsMessageHandler(bus: 'command.bus')]
final readonly class PlaceOrderSymfonyHandler
{
    public function __invoke(PlaceOrderCommand $command): string
    {
        // Handle command...
        return 'order-id';
    }
}

#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetOrderSymfonyHandler
{
    public function __invoke(GetOrderQuery $query): ?OrderView
    {
        // Return read model...
        return null;
    }
}

Event-Driven Architecture

Проблема

Компоненты системы тесно связаны. Изменение в одном модуле каскадно ломает другие. Сложно добавлять новую логику на существующие действия.

Решение

<?php
declare(strict_types=1);

// Domain event -- immutable fact that happened
final readonly class OrderPlacedEvent
{
    public function __construct(
        public string $orderId,
        public string $customerId,
        public int $totalCents,
        public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
    ) {}
}

final readonly class PaymentProcessedEvent
{
    public function __construct(
        public string $orderId,
        public string $transactionId,
        public int $amountCents,
        public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
    ) {}
}

// Aggregate root that produces events
final class Order
{
    /** @var list<object> */
    private array $domainEvents = [];

    private function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId,
        private OrderStatus $status,
        private Money $total,
    ) {}

    public static function place(
        OrderId $id,
        CustomerId $customerId,
        Money $total,
    ): self {
        $order = new self($id, $customerId, OrderStatus::Draft, $total);

        // Record domain event
        $order->recordEvent(new OrderPlacedEvent(
            orderId: $id->value,
            customerId: $customerId->value,
            totalCents: $total->amount,
        ));

        return $order;
    }

    public function markPaid(string $transactionId): void
    {
        if ($this->status !== OrderStatus::Draft) {
            throw new \LogicException('Only draft orders can be paid');
        }

        $this->status = OrderStatus::Confirmed;

        $this->recordEvent(new PaymentProcessedEvent(
            orderId: $this->id->value,
            transactionId: $transactionId,
            amountCents: $this->total->amount,
        ));
    }

    /** @return list<object> */
    public function releaseEvents(): array
    {
        $events = $this->domainEvents;
        $this->domainEvents = [];
        return $events;
    }

    private function recordEvent(object $event): void
    {
        $this->domainEvents[] = $event;
    }

    public function getId(): OrderId { return $this->id; }
    public function getCustomerId(): CustomerId { return $this->customerId; }
    public function getTotal(): Money { return $this->total; }
}

// Event listeners -- decoupled reactions
final readonly class SendOrderConfirmationEmail
{
    public function __construct(
        private MailerInterface $mailer,
        private CustomerRepository $customers,
    ) {}

    public function __invoke(OrderPlacedEvent $event): void
    {
        $customer = $this->customers->findById(
            new CustomerId($event->customerId),
        );

        // Send confirmation email
    }
}

final readonly class UpdateSalesAnalytics
{
    public function __construct(
        private AnalyticsService $analytics,
    ) {}

    public function __invoke(OrderPlacedEvent $event): void
    {
        $this->analytics->recordSale(
            orderId: $event->orderId,
            amount: $event->totalCents,
            date: $event->occurredAt,
        );
    }
}

final readonly class ReserveInventory
{
    public function __invoke(OrderPlacedEvent $event): void
    {
        // Reserve stock for order items
    }
}

// Dispatch events after persisting aggregate
final readonly class EventDispatchingOrderRepository implements OrderRepository
{
    public function __construct(
        private OrderRepository $inner,
        private EventDispatcherInterface $events,
    ) {}

    public function save(Order $order): void
    {
        $this->inner->save($order);

        // Dispatch events AFTER successful persistence
        foreach ($order->releaseEvents() as $event) {
            $this->events->dispatch($event);
        }
    }

    // ... delegate other methods
}

Когда использовать

  • Слабое связывание между модулями
  • Несколько реакций на одно действие
  • Асинхронная обработка (через message queue)
  • Аудит и логирование бизнес-событий

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

// Sync: EventDispatcher
#[AsEventListener(event: OrderPlacedEvent::class)]
final readonly class LogOrderListener
{
    public function __invoke(OrderPlacedEvent $event): void
    {
        // Synchronous logging
    }
}

// Async: Messenger (event goes to RabbitMQ/Redis)
#[AsMessageHandler]
final readonly class SendEmailAsync
{
    public function __invoke(OrderPlacedEvent $event): void
    {
        // Processed asynchronously by worker
    }
}

// Routing in messenger.yaml:
// framework:
//   messenger:
//     routing:
//       App\Event\OrderPlacedEvent: async

Сравнение современных паттернов

Паттерн Ключевая идея Слой
DTO Типобезопасная передача данных Application
Value Object Самовалидирующиеся доменные значения Domain
Repository Абстракция хранилища Domain + Infrastructure
Specification Бизнес-правила как объекты Domain
CQRS Разделение чтения и записи Architecture
Event-Driven Реакция на факты вместо вызовов Architecture

Проверь себя

Когда НЕ стоит использовать Event-Driven Architecture?

Зачем Repository определяется как интерфейс в доменном слое?

Какой паттерн лучше выразит бизнес-правило 'VIP-клиент с подтверждённым email и более 10 заказов'?

Что такое CQRS?

Чем DTO отличается от Value Object?