О разделе

Паттерны и принципы — не самоцель. Junior часто заучивает 23 GoF-паттерна и применяет их где попало, делая код хуже. Senior знает те же паттерны, но применяет осознанно: видит, какую конкретную проблему решает каждый паттерн, и не использует его, если проблемы нет. Этот раздел даёт операционное понимание: когда паттерн оправдан, когда overkill, и как сводить теорию с практикой Laravel.

Принципы раздела:
  • Паттерны — решение, не цель. Сначала проблема, потом инструмент;
  • Простой код > «правильный» код. Преждевременная абстракция дороже дублирования;
  • SOLID — эвристика, не закон. Понимание «почему» важнее буквы;
  • DDD — для сложных доменов. На CRUD-приложении создаёт лишний слой;
  • Clean Architecture — полезна, дорога в реализации. Не для каждого проекта.
Пререквизиты
  • KB_1 — интерфейсы, абстрактные классы, traits;
  • KB_3 — controllers, services, dependency injection;
  • KB_13 — Service Container (для понимания DI).
Карта раздела
БлокЧто разбирается
ПринципыSOLID (5 принципов), GRASP, DRY, KISS, YAGNI
GoFCreational, Structural, Behavioral — самые используемые в backend
Web/AppRepository, Service Layer, DTO — на каждый день
DDD/CleanEntity, Value Object, Aggregate, Hexagonal
ПрактикаРефакторинг 200-строчного контроллера до читаемого кода
SOLID
Назначение

SOLID — пять принципов ООП-дизайна, сформулированных Робертом Мартином в 2000-х. Это эвристики, не строгие законы. Цель — уменьшить связанность, увеличить переиспользование, сделать код терпимым к изменениям. Senior следует SOLID не догматически: иногда нарушение принципа — правильное решение.

Пять принципов

S — Single Responsibility Principle

Класс должен иметь одну причину для изменения. Не «делать одну вещь», а служить одной группе stakeholders. OrderService, который считает скидки + пишет в БД + отправляет уведомления — нарушает SRP: каждое требование может измениться независимо. Лекарство: DiscountCalculator, OrderRepository, OrderNotifier.

O — Open/Closed Principle

Сущности открыты для расширения, закрыты для модификации. Добавление платёжного провайдера не должно требовать правок PaymentService. Решение — интерфейс PaymentGateway + новые реализации (Stripe, Paddle, Fake).

L — Liskov Substitution Principle

Объекты подкласса взаимозаменяемы с родительскими без поломки поведения. Классика: Square extends Rectangle — нарушение, потому что setWidth(5); setHeight(4) даёт площадь 20 у Rectangle и 16 у Square. Иерархия должна отражать поведение, не семантику.

I — Interface Segregation Principle

Клиенты не зависят от методов, которые не используют. Worker с методами work/eat/sleep — робот вынужден реализовать eat. Лекарство: разделить на Workable, Eatable, Sleepable.

D — Dependency Inversion Principle

Высокоуровневые модули не зависят от низкоуровневых. Оба зависят от абстракций. OrderService не импортирует MySqlOrderRepository — только OrderRepositoryInterface. Реализация подменяется через Service Container.

Практика: SRP в контроллере
// Контроллер делает всё
public function store(Request $request)
{
    $validated = $request->validate(['email' => 'required|email']);
    if ($validated['country'] === 'KZ') $tax = $validated['amount'] * 0.12;
    $order = Order::create([...]);
    Mail::to($order->email)->send(new OrderConfirmation($order));
    return response()->json($order);
}

// Каждый класс — одна ответственность
public function store(StoreOrderRequest $request, CreateOrder $action): JsonResponse
{
    $order = $action($request->validated());
    return response()->json(new OrderResource($order), 201);
}
Особые случаи
1. «Класс должен делать одну вещь». Слишком узко. UserService::create() может делать 5 шагов — это одна ответственность: создание пользователя.
2. SOLID на CRUD. Простой CRUD не требует абстракций. BookController с прямыми Book::create() — читается лучше, чем три уровня сервисов.
3. OCP через наследование. Глубокая иерархия — кошмар. Композиция (несколько зависимостей в конструкторе) — обычно лучше.
4. LSP через NotImplementedException. Класс наследует интерфейс, бросая throw new NotImplementedException() — это ISP-нарушение.
5. ISP с микро-интерфейсами. Если каждый метод — отдельный интерфейс, проект тонет в файлах. Группируйте методы, которые используются вместе.
6. DIP без Service Container. Инжектировать интерфейсы вручную в каждом месте — boilerplate. Laravel Container делает это автоматически.
7. «Я переписал на SOLID, теперь идеально». Дизайн эволюционирует. Не бойтесь рефакторить.
8. SOLID как аргумент в code review. «Здесь не SOLID» — слабо. Конкретная проблема (трудно тестировать, дублирование) — сильно.
GRASP, DRY, KISS, YAGNI
Назначение

Дополнительные эвристики. GRASP (General Responsibility Assignment Software Patterns) — более общие принципы распределения ответственности (Крэг Ларман). DRY, KISS, YAGNI — короткие правила-якоря для повседневных решений.

Ключевые принципы

DRY — Don't Repeat Yourself

Каждый кусок знания имеет одно представление. Знание, не код: одинаково выглядящие фрагменты по разным причинам — не DRY-нарушение. Преждевременная унификация — типичная ошибка.

KISS — Keep It Simple, Stupid

Простое решение — лучшее, при прочих равных. Один класс на 100 строк часто проще пяти классов по 20 строк со связями.

YAGNI — You Aren't Gonna Need It

Не пишите функциональность «на будущее». 80% такого кода никогда не используется. Добавляйте абстракцию при третьем случае (rule of three), не первом.

GRASP: Information Expert

Ответственность принадлежит классу с большим количеством информации. Расчёт суммы заказа — в Order, не в OrderController.

GRASP: Low Coupling, High Cohesion

Coupling — зависимость одного модуля от другого, должна быть низкой. Cohesion — связность элементов внутри модуля, должна быть высокой.

GRASP: Controller

Объект, обрабатывающий system event. В Laravel это контроллеры. Не путать с GoF Controller. Должен быть тонким.

DRY vs YAGNI
// Два метода, похожих на 90%, разная семантика
public function calculateOrderTax(Order $order): int {
    return (int) ($order->total * 0.12);
}
public function calculateRefundTax(Refund $refund): int {
    return (int) ($refund->total * 0.12);
}

// Преждевременное DRY: единая абстракция.
// Если правила для refund изменятся (другие ставки) — придётся ломать.
public function calculateTax(int $amount): int {
    return (int) ($amount * 0.12);
}

// KISS / YAGNI: оставить два метода до третьего случая.
// Эволюция: дублирование → паттерн → абстракция.
Особые случаи
1. DRY ради DRY. Объединение двух похожих фрагментов под одной абстракцией, когда требования эволюционируют по-разному — ломает оба.
2. KISS как отказ от абстракций. «Просто — значит без интерфейсов» — неправильно. Простое не равно примитивному.
3. YAGNI на инфраструктуре. «Логирование — YAGNI» — пока инцидент не случился. Инфраструктурные вещи почти никогда не YAGNI.
4. Premature optimization. «Эта функция должна быть быстрой» без замера — источник нечитаемого кода.
5. Cohesion vs Coupling неверно. Одна гигантская функция — не «высокая когезия». Когезия о смысле, не о близости в файле.
6. «Используй паттерн X». Без проблемы — cargo cult.
7. Игнор «Rule of three». Первое дублирование — OK. Второе — подозрительно. Третье — абстракция.
8. KISS для senior vs junior. Простое для senior может быть непонятным для junior. Учитывайте уровень команды.
Creational паттерны
Назначение

Паттерны создания объектов. Решают «как получить экземпляр», когда простой new не подходит: валидация в конструкторе, выбор класса по параметру, тяжёлая инициализация, кеширование.

Основные

Factory Method

Делегирование создания. NotificationFactory::create('sms') возвращает SmsNotification. Решает: выбор реализации в runtime.

interface Notification { public function send(string $to, string $text): void; }

final class NotificationFactory
{
    public static function create(string $channel): Notification
    {
        return match ($channel) {
            'sms'   => new SmsNotification(),
            'email' => new EmailNotification(),
            'push'  => new PushNotification(),
            default => throw new InvalidArgumentException("Unknown: {$channel}"),
        };
    }
}

Abstract Factory

Семейство связанных фабрик. В backend применяется реже.

Builder

Пошаговое построение сложного объекта. Eloquent Query Builder — классика: User::where(...)->orderBy(...)->limit(10)->get().

Singleton

В Laravel — через Service Container ($app->singleton(...)), не ручная реализация. Прямой Singleton — антипаттерн: hidden dependency, проблемы с тестами.

Prototype

Создание клонированием. PHP имеет clone и __clone. Полезно когда создание with-scratch дорого.

Особые случаи
1. Static factory без причины. UserFactory::create($data) вместо new User($data) — лишний indirection.
2. Singleton-антипаттерн. Прямой getInstance() — глобальные скрытые зависимости. Service Container.
3. Builder с обязательными полями. Обязательные — в конструктор, опциональные — через with-методы.
4. __clone и shallow copy. По умолчанию вложенные объекты shared. Deep clone — явно через __clone.
5. Factory без default. Закрытый match без default — нельзя расширить без правки factory.
6. Factory вместо контейнера. Если зависимости разрешаются через DI, factory избыточен.
7. Builder, мутирующий self. Запутывает. Лучше immutable: каждый with возвращает новый instance.
8. Static-методы как factory в моделях. Eloquent Model::create, ::firstOrCreate — валидно в контексте ORM.
Structural паттерны
Назначение

Большие структуры из меньших объектов. Adapter — согласовать несовместимые интерфейсы; Decorator — добавить функциональность не меняя класс; Facade — упростить сложный API.

Основные

Adapter

Оборачивает чужой класс под собственный интерфейс. Обёртка вокруг внешнего SDK (Stripe SDK → наш PaymentGateway). Позволяет заменить SDK без переписывания основного кода.

interface PaymentGateway {
    public function charge(int $amount, string $currency): string;
}

final class StripeAdapter implements PaymentGateway
{
    public function __construct(private StripeClient $client) {}

    public function charge(int $amount, string $currency): string
    {
        $intent = $this->client->paymentIntents->create([
            'amount' => $amount, 'currency' => $currency,
        ]);
        return $intent->id;
    }
}

Decorator

Добавляет поведение, оборачивая объект. CachingPaymentGateway декорирует обычный кешем. Composable: можно цеплять цепочкой.

Facade

Простой интерфейс к сложной подсистеме. Laravel-фасады — буквально этот паттерн (хотя реализованы через Service Container). Cache::get() прячет драйвер, репозиторий, конфиг.

Proxy

Заместитель реального объекта. Виды: virtual (ленивая инициализация), protection (проверка прав), remote (удалённый доступ). Eloquent lazy-loaded relations — virtual proxy.

Composite

Деревья объектов с единым интерфейсом для листа и узла. Меню сайта (пункт vs группа) — типичный пример.

Bridge

Разделение абстракции и реализации в две иерархии. Редко в обычном backend.

Особые случаи
1. Adapter «потому что красиво». Если SDK уже имеет удобный API — обёртка только индирекция.
2. Decorator поверх несовместимого интерфейса. Должен реализовать тот же интерфейс. Иначе это обёртка.
3. Facade, скрывающий критичные детали. Если прячет timeout, retry — пользователь не сможет настроить.
4. Глубокая цепочка декораторов. 5 декораторов вокруг одного объекта — трудно дебагать.
5. Eager proxy. Proxy, сразу загружающий данные — не proxy.
6. Composite на одноуровневой структуре. Нет вложенности — зачем абстракция?
7. Bridge как «правильный». Bridge оправдан на 1-2% случаев.
8. Facade как god class. Facade с 50 методами — делите по доменам.
Behavioral паттерны
Назначение

Взаимодействие между объектами. Strategy — подмена алгоритма; Observer — уведомление подписчиков; Command — инкапсуляция действия; Template Method — скелет с переопределяемыми шагами.

Основные

Strategy

Семейство взаимозаменяемых алгоритмов. OrderShipper принимает ShippingStrategy (DHL, FedEx, Local). Replace switch → объекты.

Observer

Один объект уведомляет N подписчиков. Laravel Events & Listeners. Eloquent observers — тоже Observer.

Command

Действие как объект. CreateOrderCommand. Удобно для undo/redo, очередей (Laravel Job — это Command).

Template Method

Абстрактный класс задаёт скелет; подклассы переопределяют шаги. ImportFile с parse/validate/save — конкретные импортёры переопределяют parse.

Chain of Responsibility

Цепочка обработчиков; запрос проходит, пока один не обработает. Laravel middleware — чистый пример.

State

Объект меняет поведение в зависимости от состояния. Order в Pending разрешает cancel, в Paid — нет. Через классы-состояния.

Mediator

Объект-координатор между компонентами. В backend реже; популярен в UI.

Visitor

Отделяет алгоритм от структуры данных. Сложный, редко в PHP — double dispatch не PHP-friendly.

Практика: Strategy вместо switch
// Switch на тип — нарушение Open/Closed
public function ship(Order $order, string $carrier): string
{
    return match ($carrier) {
        'dhl'   => $this->shipViaDHL($order),
        'fedex' => $this->shipViaFedEx($order),
        'local' => $this->shipViaLocal($order),
    };
}

// Strategy
interface ShippingStrategy { public function ship(Order $o): string; }
final class DhlStrategy   implements ShippingStrategy { ... }
final class FedExStrategy implements ShippingStrategy { ... }

final class OrderShipper
{
    public function ship(Order $o, ShippingStrategy $s): string
    { return $s->ship($o); }
}
Особые случаи
1. Strategy на 2 случая. Перебор. Используйте простой if/match.
2. Observer без unsubscribe. Listeners накапливаются. В Laravel-FPM не проблема, в Octane — критично.
3. Command, дублирующий метод. Если есть OrderService::cancel($id), а Command просто его вызывает — нет смысла.
4. Template Method с 10 hooks. Запутанная иерархия. Лучше Strategy + Composition.
5. Chain без явного конца. Если никто не обработал — что произошло? Default-обработчик или явная ошибка.
6. State через if. Большой if ($status === 'pending' && !$paid) — запах. Извлеките в классы.
7. Mediator с раздутой ответственностью. Mediator всех событий — god object.
8. Observer вместо явного вызова. Если один listener и так будет всегда — прямой вызов читается лучше.
Repository
Назначение

Repository — абстракция хранилища: «дай мне сущность по критериям». Скрывает детали БД от бизнес-логики. В DDD — обязательный паттерн. В Laravel — спорный, поскольку Eloquent уже реализует Active Record + Query Builder.

Repository в Laravel: за и против
ЗаПротив
Изоляция бизнес-логики от EloquentEloquent уже — абстракция над PDO
Подмена реализации в тестахModel::factory() + RefreshDatabase делает то же
Единая точка для сложных запросовEloquent scopes
Готовность к смене ORMСмена ORM — редкое событие; YAGNI
Чистая архитектура (Hexagonal)Для большинства проектов overkill

Практический критерий: если в проекте сложный домен (DDD) или multiple data sources — Repository оправдан. В обычном CRUD — антипаттерн.

Практика: интерфейс + Eloquent-реализация
// Интерфейс — что нужно бизнес-логике
interface OrderRepository
{
    public function find(int $id): ?Order;
    public function save(Order $order): void;
    public function findUnpaidOlderThan(Carbon $date): Collection;
}

// Eloquent-реализация
final class EloquentOrderRepository implements OrderRepository
{
    public function find(int $id): ?Order
    { return Order::find($id); }

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

    public function findUnpaidOlderThan(Carbon $date): Collection
    {
        return Order::where('status', 'pending')
            ->where('created_at', '<', $date)->get();
    }
}

// Bind в Service Provider
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
Особые случаи
1. Repository, дублирующий Eloquent. Тонкие обёртки — лучше Model напрямую.
2. Generic Repository<T>. Универсальный CRUD-Repository — антипаттерн. Repository должен отражать domain-specific нужды.
3. Repository, возвращающий Eloquent. Абстракция протекает. Хочется — pure entities или DTO.
4. Repository с pagination. Ломает абстракцию. Лучше отдельный Specification/Query-object.
5. Repository в простом CRUD. Излишество.
6. Тесты через мок Repository. Часто — антипаттерн. Используйте in-memory implementation.
7. Repository без транзакций. Transaction boundary — это Service, не Repository.
8. Action как репозиторий. Action оркестрирует Repository, не должен быть БД-кодом.
Service Layer + DTO
Назначение

Service Layer — место бизнес-логики. Контроллер тонкий; Service делает оркестрацию между Repositories, внешними API, событиями. DTO — неизменяемая структура для передачи данных между слоями.

Структура

Action vs Service

Action (Single-Action Class) — класс с одним публичным методом (CreateOrder::__invoke()). Service — класс с несколькими методами вокруг одной области. Action удобнее для тестирования.

DTO — иммутабельные структуры

Простой класс с публичными свойствами + конструктор. Никакого поведения. Передаёт валидированные данные от Controller к Action.

Form Request → DTO → Action

Поток: HTTP → FormRequest валидирует → собираем DTO → передаём в Action. Action возвращает результат → Resource в ответ.

Практика
// DTO
final class CreateOrderData
{
    public function __construct(
        public readonly int    $userId,
        public readonly array  $items,
        public readonly string $currency,
    ) {}

    public static function fromRequest(StoreOrderRequest $r): self
    {
        return new self(
            userId:   $r->user()->id,
            items:    $r->input('items'),
            currency: $r->string('currency')->value(),
        );
    }
}

// Action
final class CreateOrder
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentGateway  $gateway,
    ) {}

    public function __invoke(CreateOrderData $data): Order
    {
        return DB::transaction(function () use ($data) {
            $order = new Order([...]);
            $this->orders->save($order);
            $this->gateway->charge($order->total, $data->currency);
            return $order;
        });
    }
}

// Controller — тонкий
public function store(StoreOrderRequest $r, CreateOrder $action): JsonResponse
{
    $order = $action(CreateOrderData::fromRequest($r));
    return response()->json(new OrderResource($order), 201);
}
Особые случаи
1. Service с 30 методами. God-сервис. Делите по сущностям или операциям.
2. DTO с публичными сеттерами. Mutable DTO теряет смысл. Используйте readonly (PHP 8.1+).
3. DTO с бизнес-логикой. calculateDiscount() в DTO — это уже не DTO.
4. Массивы вместо DTO. Нетипизированный, легко передать неправильное.
5. Action, вызывающий другие Action. Через DI — OK. Через new — нарушение DI.
6. Transaction в Controller. Транзакция внутри Action, не Controller.
7. Возврат Eloquent из Action. OK для веба, плохо для библиотек.
8. Action без DI. Прямой new ServiceA — не подменить в тестах.
DDD — тактический уровень
Назначение

Domain-Driven Design (Эрик Эванс, 2003). Стратегический DDD — о структуре больших систем (Bounded Context). Тактический — о шаблонах внутри одного контекста: Entity, Value Object, Aggregate, Domain Service, Repository, Domain Event.

Building blocks

Entity

Объект с идентичностью. Два User с одинаковыми именами — разные сущности при разных id. Идентичность сохраняется через жизнь объекта.

Value Object

Без идентичности, определяется только значениями. Money(100, USD) и другой Money(100, USD) — эквивалентны. Иммутабелен.

final class Money
{
    public function __construct(
        public readonly int    $amountMinor,
        public readonly string $currency,
    ) {
        if ($amountMinor < 0) throw new InvalidArgumentException('Negative');
    }

    public function add(Money $other): self
    {
        if ($other->currency !== $this->currency) {
            throw new CurrencyMismatchException();
        }
        return new self($this->amountMinor + $other->amountMinor, $this->currency);
    }

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

Aggregate

Группа Entity и VO, единое целое. Aggregate Root — единственная точка доступа извне. Order — root, OrderItem — часть aggregate. Все изменения — через Order.

Domain Service

Логика, не принадлежащая одной сущности. CurrencyConverter работает с Money разных валют, но не «принадлежит» одной.

Domain Event

Факт, произошедший в домене. OrderPaid. Эмитится из Aggregate, обрабатывается асинхронно. В Laravel — Events & Listeners.

Ubiquitous Language

Единый язык между бизнесом и разработчиками. Customer вместо UserRow; activate() вместо setStatusActive().

Особые случаи
1. DDD на простом CRUD. Aggregate, VO, Repository вокруг Categories — overkill. DDD оправдан на сложных доменах.
2. Eloquent как Entity. Смешивает данные и persistence. Чистый DDD требует отдельных Entity. На практике — компромисс.
3. VO как массив. ['amount' => 100, 'currency' => 'USD'] вместо Money — теряете валидацию и type safety.
4. Aggregate без границ. God aggregate — тяжёлые транзакции. Aggregate должен быть small.
5. Cross-aggregate транзакция. Eventual consistency между aggregate'ами — через Domain Events.
6. Domain Service для всего. 80% логики в Services — процедурный код. Логика должна быть в Entity/VO.
7. UL для галочки. Код User, разговоры Member — UL не работает. Переименовывайте.
8. Заимствование терминов из контекстов. Order в e-commerce и restaurant — разные вещи. Bounded Context.
Clean Architecture и Hexagonal
Назначение

Clean Architecture (Боб Мартин) и Hexagonal (Алистер Кокберн) ставят в центр бизнес-логику, изолированную от инфраструктуры. БД, веб, очереди — детали, легко заменяемые. Цель: код, тестируемый без БД, переносимый между фреймворками.

Слои Clean Architecture

1. Entities

Доменные сущности с бизнес-правилами. Не зависят ни от чего внешнего.

2. Use Cases

Оркестрация: CreateOrder, CancelOrder. Знают о Entities, не о БД/HTTP.

3. Interface Adapters

Controllers, Presenters, Repositories. Преобразование данных между слоями.

4. Frameworks & Drivers

Laravel, Eloquent, Stripe SDK, Redis. Самый внешний слой.

Правило зависимости

Зависимости направлены внутрь. Внутренние слои не знают о внешних.

Hexagonal (Ports & Adapters)

Альтернативный взгляд: центр — домен; снаружи — адаптеры через порты (интерфейсы). Каждое внешнее взаимодействие — отдельный port + adapter. Терминологически проще Clean Architecture, идея та же.

// Port — интерфейс, определённый доменом
interface OrderRepository { // в src/Domain/
    public function save(Order $order): void;
}

// Adapter — реализация во внешнем слое
final class EloquentOrderRepository implements OrderRepository
{ // в src/Infrastructure/Persistence/
    public function save(Order $order): void { ... }
}

// Use Case зависит только от Port (инверсия зависимости)
final class CreateOrder
{
    public function __construct(private OrderRepository $repo) {}
}
Особые случаи
1. Clean на маленьком проекте. 4 слоя для CRUD-приложения — убийца производительности команды.
2. Eloquent внутри Entity. Если Entity наследует Model — нарушение правила зависимостей.
3. Заимствование структуры папок. Domain/, Application/ без следования правилу — ярлыки.
4. Транзакции в Use Case. Use Case не должен знать про БД-транзакции. В Laravel часто компромисс.
5. Слишком много DTO. Преобразование между слоями через 3-4 копии данных — лишняя работа.
6. Тестирование Use Case моками. Use Case с 5 моков — тест проверяет реализацию. Используйте in-memory implementations.
7. Hexagonal без портов. «У нас Hexagonal» — но интерфейсы Repository отсутствуют. Это не Hexagonal.
8. Postel's Law нарушение. Принимать строгие типы, возвращать лояльные. Часто наоборот — контракт асимметричен.
Микросервисы и монолит
Определения

Микросервис — небольшая автономная программа, отвечающая за одну бизнес-возможность и общающаяся с остальными по сети: HTTP/REST, gRPC или сообщения.

Микросервисная архитектура — построение приложения как набора таких независимых сервисов вместо одного большого.

Аналогия. Монолит — один большой завод, где всё производство в одном здании. Микросервисы — сеть небольших цехов: каждый делает свою деталь и общается с остальными по договорённостям.
Чем микросервис отличается от модуля

Модуль внутри монолита — часть одного процесса и одной кодовой базы. Микросервис — отдельно развёртываемый компонент.

КритерийМонолитМикросервисы
РазвёртываниеВсё приложение целикомКаждый сервис отдельно
База данныхОбычно общаяУ каждого своя
МасштабированиеМасштабируется всёТолько нагруженный сервис
ТехнологииОдин стекМожно разные языки и БД
СбоиПадение может уронить всёИзолируются, но появляются сетевые отказы
СложностьПроще разрабатывать и отлаживатьСложнее инфраструктура и наблюдаемость
ТранзакцииОбычные транзакции БДСаги и eventual consistency
Главное заблуждение. Микросервисы — это не «разбить код на папки» и не «поставить Docker и Kubernetes». Это архитектурный выбор, который повышает гибкость ценой роста операционной сложности. Docker и K8s лишь помогают эту сложность обслуживать, но сами микросервисами не делают.
Ключевые принципы
ПринципЧто означает на практике
Одна бизнес-возможностьКорзина, оплата, каталог, уведомления, аутентификация — по одному сервису
Независимое развёртываниеОбновить оплату, не пересобирая весь сайт
Изоляция данныхУ сервиса заказов своя БД. В чужие таблицы напрямую не лезут никогда
Связь по сетиAPI или сообщения. Слабая связанность ценой задержек и отказов
Автономность командКоманда владеет сервисом целиком: код, деплой, база, мониторинг
ОтказоустойчивостьТаймауты, ретраи, circuit breaker, очереди, health checks
НаблюдаемостьЛоги, метрики, распределённые трассировки
Изоляция данных — самый нарушаемый принцип. Соблазн сходить в чужую таблицу напрямую велик: это быстрее и проще, чем звать API. Но ровно в этот момент независимое развёртывание кончается — теперь схема чужой БД стала твоим контрактом, и её нельзя поменять без согласования. Получается распределённый монолит: сложность микросервисов без их преимуществ.
Пример разбиения
┌─────────────┐ Клиент ────────→ │ API Gateway │ └──────┬──────┘ ┌──────────┬───────┼───────┬──────────┐ ▼ ▼ ▼ ▼ ▼ ┌───────┐ ┌────────┐ ┌────┐ ┌──────┐ ┌─────────┐ │ Users │ │Catalog │ │Cart│ │Orders│ │Payments │ └───┬───┘ └───┬────┘ └─┬──┘ └──┬───┘ └────┬────┘ │ │ │ │ │ БД БД БД БД БД ← у каждого своя │ │ асинхронно, через очередь ▼ ┌────────────────┐ │ Notifications │ └────────────────┘

Сервис заказов при оформлении может синхронно спросить оплату — ответ нужен сразу. А уведомление отправить асинхронно через очередь: пользователь не должен ждать, пока уйдёт письмо.

Правило выбора связи. Синхронно — когда без ответа нельзя продолжать. Асинхронно — во всех остальных случаях. Каждый лишний синхронный вызов складывает задержки и превращает чужую недоступность в твою.
Плюсы и минусы
ПлюсыМинусы
Независимые деплои и релизыСложная инфраструктура: оркестрация, service discovery, gateway
Точечное масштабированиеСетевые задержки и частичные отказы
Изоляция сбоевОтладка и трассировка через несколько сервисов
Технология под задачуСогласованность данных: саги вместо транзакций
Команды меньше зависят друг от другаДорого на старте и для маленьких команд
Когда оправданы, а когда нет
Оправданы, если естьНе стоит, если
Несколько командКоманда из двух-трёх человек
Высокая нагрузка на отдельные частиНагрузка равномерная и небольшая
Нужны частые независимые релизыРелизы и так редкие
Зрелая DevOps-культураНет мониторинга и автодеплоя
Понятные границы доменаДомен ещё не устоялся и меняется
Практический совет. Новый небольшой проект не начинают с микросервисов. Разумнее начать с модульного монолита — одно развёртывание, но чёткие границы модулей и запрет на обращение к чужим таблицам. Когда границы подтвердятся практикой, нужный модуль выносится в сервис почти механически. Обратный путь — собрать монолит из преждевременно нарезанных сервисов — гораздо больнее.
Границы режут по домену, а не по слоям. Сервисы «контроллеры», «сервисы», «репозитории» — это не микросервисы, а монолит, разорванный по сети: любое изменение задевает все три. Резать надо по бизнес-возможностям — «оплата», «каталог», — чтобы изменение укладывалось в один сервис.
Связь с другими разделами
ТемаГде разобрано
Надёжная публикация событий между сервисамиTransactional Outbox — следующий раздел
Границы домена и контекстыDDD тактика
Изоляция данных между клиентамиМультитенантность
Таймауты, идемпотентность, ретраиNode.js и NestJS → Паттерны надёжности
Transactional Outbox — надёжная публикация событий
Проблема: dual-write (двойная запись)

Классический сценарий в микросервисах: сервис заказов должен одновременно:

  • Сохранить заказ в свою БД (orders).
  • Опубликовать событие OrderCreated в брокер (Kafka / RabbitMQ / SNS).

Это две разные системы. Атомарной транзакции между ними не бывает без сложных протоколов вроде 2PC. Значит возможны рассогласованные состояния:

Что произошлоПоследствие
Заказ сохранён, событие не отправлено (упал брокер / сеть)Другие сервисы никогда не узнают о заказе. Оплата не спишется, склад не зарезервирует, письмо не уйдёт.
Событие отправлено, транзакция откачена (упал SQL после публикации)Downstream начнёт обрабатывать несуществующий заказ — грязные данные в других сервисах.
Наивное «решение», которое НЕ работает: DB::transaction(fn () => { $order->save(); Kafka::publish(...); });. Publish не в БД — rollback Laravel не откатит уже отправленное сообщение. Аналогично «сначала опубликую, потом сохраню» — брокер не откатывается по сигналу приложения.
Решение: паттерн Transactional Outbox

Идея: перенести публикацию в БД. В той же транзакции, что сохраняет заказ, пишем ещё одну строку — в служебную таблицу outbox. Отдельный процесс (relay) читает outbox и публикует события в брокер уже вне транзакции.

┌─────────────────────────────────────────────────┐ │ Сервис заказов (одна транзакция БД) │ │ │ │ BEGIN │ │ INSERT INTO orders (id, total, ...) │ │ INSERT INTO outbox (event_type, │ │ payload, ...) │ │ COMMIT │ │ │ └──────────┬──────────────────────────────────────┘ │ (данные и событие сохранены атомарно) ▼ ┌───────────────────┐ │ Relay worker │ постоянно опрашивает outbox │ (отдельный │ WHERE published_at IS NULL │ процесс) │ FOR UPDATE SKIP LOCKED └────────┬──────────┘ │ publish ▼ ┌───────────────────┐ │ Broker │ Kafka / RabbitMQ / SNS │ (Kafka/RMQ/...) │ └───────────────────┘ │ успех → UPDATE outbox SET published_at = now() ▼ downstream сервисы
Шаги паттерна
  1. BEGIN транзакции БД.
  2. Сохранение бизнес-данных. INSERT INTO orders ...
  3. Сохранение события в outbox. INSERT INTO outbox (event_type, payload_json, aggregate_id, created_at).
  4. COMMIT. Либо всё сохранилось, либо ничего — атомарно.
  5. Relay-процесс опрашивает outbox, забирает не-отправленные события.
  6. Publish в брокер. Успешно → пометить published_at = now() (или удалить строку). Неуспешно → оставить как есть, следующий цикл повторит.
Схема таблицы outbox
CREATE TABLE outbox (
    id            BIGSERIAL PRIMARY KEY,
    aggregate_type VARCHAR(64)  NOT NULL,   -- 'Order', 'Payment', ...
    aggregate_id   VARCHAR(64)  NOT NULL,   -- для routing и порядка
    event_type     VARCHAR(64)  NOT NULL,   -- 'OrderCreated', 'OrderPaid'
    payload        JSONB        NOT NULL,   -- сериализованное событие
    created_at     TIMESTAMPTZ  NOT NULL DEFAULT now(),
    published_at   TIMESTAMPTZ  NULL              -- NULL = ещё не отправлено
);

-- Индекс для быстрого поиска не-отправленных
CREATE INDEX ON outbox (created_at) WHERE published_at IS NULL;

Индекс частичный — по «горячей» части (не-опубликованные): выборка воркера всегда быстрая, даже когда в таблице миллион старых записей.

Laravel — реализация в Action
final class PlaceOrderAction
{
    public function handle(array $data): Order
    {
        return DB::transaction(function () use ($data) {
            // 1. Основные данные
            $order = Order::create($data);

            // 2. Событие в outbox — та же транзакция
            DB::table('outbox')->insert([
                'aggregate_type' => 'Order',
                'aggregate_id'   => $order->id,
                'event_type'     => 'OrderCreated',
                'payload'        => json_encode([
                    'order_id' => $order->id,
                    'total'    => $order->total,
                    'items'    => $order->items->toArray(),
                ]),
                'created_at'     => now(),
            ]);

            return $order;
            // COMMIT — обе записи атомарно
        });
    }
}

Обрати внимание: никакого Kafka::publish() внутри транзакции. Публикация — забота отдельного воркера. Даже если Kafka лежит — заказ создан, событие ждёт в БД.

Relay — отдельный воркер
// app/Console/Commands/RelayOutboxCommand.php
final class RelayOutboxCommand extends Command
{
    protected $signature = 'outbox:relay';

    public function handle(KafkaProducer $kafka): int
    {
        while (true) {
            DB::transaction(function () use ($kafka) {
                // SKIP LOCKED — параллельные воркеры не мешают друг другу
                $events = DB::select("
                    SELECT * FROM outbox
                    WHERE published_at IS NULL
                    ORDER BY id
                    LIMIT 100
                    FOR UPDATE SKIP LOCKED
                ");

                foreach ($events as $e) {
                    $kafka->publish($e->event_type, $e->aggregate_id, $e->payload);
                    DB::table('outbox')->where('id', $e->id)
                        ->update(['published_at' => now()]);
                }
            });

            if (empty($events)) sleep(1);   // нечего делать — пауза
        }
    }
}

Запуск: supervisord / systemd держит команду постоянно. Несколько воркеров можно запустить параллельно — SKIP LOCKED гарантирует, что каждое событие возьмёт только один.

Ключевые преимущества

Надёжность и атомарность

Событие создаётся тогда и только тогда, когда данные сохранены. Не бывает «заказ есть, а событие потерялось».

At-Least-Once delivery

Даже если relay упал после publish, но до обновления published_at — следующий запуск повторит. Гарантия «хотя бы один раз».

Без 2PC / XA

Distributed transactions сложны, медленны, требуют координатора. Outbox достигает того же эффекта только средствами обычной SQL-транзакции.

Отладка через SQL

Все события в таблице — можно посмотреть SELECT * FROM outbox WHERE published_at IS NULL и понять, что застряло. С Kafka log ты этого не увидишь напрямую.

Важные нюансы
1. Потребители обязаны быть идемпотентными. «At-least-once» означает: событие может прийти 2+ раз (relay упал после publish, но до update). Consumer должен корректно обработать дубли — обычно по event_id в отдельной таблице processed_events.
2. Порядок событий. Для одного aggregate_id порядок должен сохраняться. Один relay-процесс на partition/aggregate решает это; распараллеливание — по aggregate_id (Kafka partition key).
3. Разрастание таблицы. После publish можно либо удалять, либо чистить cron'ом старше N дней (DELETE FROM outbox WHERE published_at < now() - interval '7 days'). Иначе таблица становится терабайтной.
4. Полётов транзакции нет. Если Kafka::publish внутри DB::transaction — паттерн сломан. Publish всегда отдельно, после COMMIT'а бизнес-транзакции.
5. Latency: событие появится через ~1 сек. Между COMMIT'ом заказа и приходом в consumer — задержка на цикл relay. Обычно приемлема, но для «мгновенных» сценариев (real-time уведомления в браузере) — сомнительно.
6. Change Data Capture (CDC) как альтернатива. Debezium читает WAL PostgreSQL и публикует изменения напрямую — своя реализация Outbox не нужна. Плюс: даже прямые UPDATE в БД порождают события. Минус: инфраструктура сложнее.
Когда применять
  • Микросервисы, где надо гарантированно уведомить downstream о доменных событиях.
  • Модульные монолиты с eventual consistency между модулями (даже без брокера — тот же паттерн для отложенных Jobs).
  • Интеграция с внешними API: платёжный вебхук, отправка в CRM, синхронизация с 1С. Всё, где потеря = деньги/данные.

Когда не нужен: монолит, всё в одной БД, обработка синхронная — просто дёргай event listener напрямую. Для маленьких проектов outbox — оверинжиниринг.

Итог
Transactional Outbox — стандартный способ надёжной коммуникации между сервисами без распределённых транзакций. Гарантирует, что событие уйдёт тогда и только тогда, когда бизнес-данные закоммичены. Плата: at-least-once вместо exactly-once (лечится идемпотентностью consumer'ов), задержка ~1 сек, нужен relay-процесс и таблица outbox. На типовом Laravel — реализуется за один вечер, но экономит недели отладки «где мои события».
Мультитенантность (Multi-tenancy)
Определение

Мультитенантность — архитектура, при которой одно развёрнутое приложение обслуживает несколько независимых клиентов (тенантов), а данные каждого изолированы от остальных. Один код, одна инфраструктура, N клиентов, которые друг о друге не знают.

Типичные примеры: SaaS-сервис, где каждая компания-клиент видит только свои данные; платформа интернет-магазинов, где в одной базе живут десятки брендов; биллинговая система, обслуживающая сотни организаций.

Ключевая мысль: мультитенантность — это граница безопасности, а не способ разложить данные по папкам. Всё проектирование крутится вокруг одного вопроса: «может ли тенант A при любом стечении обстоятельств увидеть данные тенанта B?»
Чем мультитенантность НЕ является

Самая частая путаница: мультитенантность отождествляют с привязкой пользователей к организациям (компания → филиал → отдел). Это два разных слоя, которые решают разные задачи.

МультитенантностьОргструктура внутри клиента
ЗадачаИзоляция данных независимых клиентовМоделирование структуры одного клиента
ПриродаИнфраструктурный слой, граница безопасностиПредметная область + авторизация
Где живётGlobal scope, middleware, выбор соединенияОбычные таблицы, связи, Policy / Gate
Как работаетНеявно и всегда — разработчик о ней не думаетЯвно — проверка пишется руками в каждом месте
Цена ошибкиУтечка данных чужой компании, нарушение GDPRСотрудник увидел лишний отчёт внутри своей же компании
Главный вопрос«Чьи это данные?»«Что этому сотруднику разрешено?»
Как отличить за 10 секунд. Задай два вопроса:
1. «Может ли пользователь легально переключиться между этими сущностями?» Сотрудник переходит из отдела продаж в маркетинг — это оргструктура. ООО «Ромашка» никогда не станет ООО «Лютик» — это тенанты.
2. «Должен ли я удалить все данные одной сущности одной операцией при расторжении договора?» Если да — это тенант.
Терминология
ТерминЗначение
TenantАрендатор: клиент, организация, аккаунт — единица изоляции
Multi-tenancyАрхитектура с разделением данных между тенантами в общем приложении
Single-tenancyОтдельное развёртывание под каждого клиента: максимальная изоляция, максимальная стоимость эксплуатации
Tenant isolationСтепень изоляции: общая БД → отдельные схемы → отдельные БД
Tenant contextТекущий тенант в рамках запроса или job'а
Tenant provisioningСоздание нового тенанта: миграции, сиды, служебные записи
Noisy neighborТенант, выжирающий общие ресурсы и роняющий производительность остальным
Три уровня изоляции

Выбор уровня — центральное архитектурное решение. Это компромисс между стоимостью эксплуатации и жёсткостью границы.

УровеньКак устроеноПлюсыМинусы
Колонка tenant_id
shared database
Все тенанты в одних таблицах, фильтрация по колонкеДёшево, одна миграция на всех, простые агрегаты по всей платформеОдна забытая фильтрация = утечка. Общие индексы растут. Шумный сосед бьёт по всем
Схема БД
schema-per-tenant
Своя schema в одной СУБД (PostgreSQL), переключение search_pathДанные физически разделены, бэкап на тенантаМиграции × N схем. Тысячи схем тормозят каталог СУБД
Отдельная БД
database-per-tenant
Своё соединение на тенантаЖёсткая граница, можно развезти по серверам, изоляция нагрузкиДорого. Миграции, бэкапы, мониторинг — кратно сложнее. Кросс-тенантная аналитика превращается в ETL
Практическое правило. Начинай с tenant_id — этот вариант закрывает большинство B2B SaaS. Переходи на отдельные БД, когда этого требует регуляторика (HIPAA, банковский сектор, требование хранить данные клиента в его юрисдикции) или когда один крупный клиент по нагрузке сопоставим со всеми остальными.
Оба слоя вместе: схема данных

На практике тенант и оргструктура сосуществуют: тенант — это клиент, а внутри него разворачивается иерархия подразделений.

tenants ← клиент платформы (ООО «Ромашка») │ ├── users ← сотрудники, tenant_id обязателен │ └── organizations ← структура клиента, tenant_id обязателен │ ├── «Головной офис» type = company │ ├── «Филиал Алматы» type = branch │ │ └── «Отдел продаж» type = department │ └── «Филиал Астана» type = branch │ └── связь с users через organization_user (many-to-many)
CREATE TABLE tenants (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(255) NOT NULL,
    slug        VARCHAR(64)  NOT NULL UNIQUE   -- поддомен: romashka.app.com
);

CREATE TABLE organizations (
    id          BIGSERIAL PRIMARY KEY,
    tenant_id   BIGINT       NOT NULL REFERENCES tenants(id),
    parent_id   BIGINT       NULL REFERENCES organizations(id),
    type        VARCHAR(32)  NOT NULL,   -- company | branch | department
    name        VARCHAR(255) NOT NULL,
    path        VARCHAR(255) NOT NULL    -- '1/5/12' — materialized path
);

CREATE TABLE organization_user (
    organization_id BIGINT NOT NULL REFERENCES organizations(id),
    user_id         BIGINT NOT NULL REFERENCES users(id),
    role            VARCHAR(32) NOT NULL,
    PRIMARY KEY (organization_id, user_id)
);

-- Уникальность ВСЕГДА в паре с tenant_id
CREATE UNIQUE INDEX users_tenant_email_uniq ON users (tenant_id, email);
Почему type колонкой, а не тремя таблицами. Отдельные companies, branches, departments ломаются на четвёртом уровне: появится «дивизион», «проектная команда» или «рабочая группа» — придётся менять схему и все запросы. Иерархия одна, тип узла — её атрибут.
Слой 1: тенант — автоматическая изоляция

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

// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        // 1. Чтение: любой запрос автоматически сужается до текущего тенанта
        static::addGlobalScope('tenant', function (Builder $q) {
            if ($id = app(TenantContext::class)->id()) {
                $q->where($q->getModel()->qualifyColumn('tenant_id'), $id);
            }
        });

        // 2. Запись: tenant_id проставляется сам, его нельзя забыть
        static::creating(function (Model $model) {
            $model->tenant_id ??= app(TenantContext::class)->id();
        });
    }
}

Контекст тенанта определяется один раз в middleware — по поддомену, заголовку или самому пользователю — и живёт в контейнере на время запроса:

class ResolveTenant
{
    public function handle(Request $request, Closure $next)
    {
        $tenant = Tenant::where('slug', explode('.', $request->getHost())[0])
            ->firstOrFail();

        // Пользователь из другого тенанта не должен сюда попасть
        abort_if(auth()->user()?->tenant_id !== $tenant->id, 403);

        app(TenantContext::class)->set($tenant);

        return $next($request);
    }
}
Слой 2: оргструктура — явная авторизация

Здесь всё наоборот: правила пишутся руками и живут в Policy. Типичное требование — «руководитель видит свою орг.единицу и все вложенные». Materialized path (path = '1/5/12') решает это одним LIKE, без рекурсивных запросов.

class ReportPolicy
{
    public function view(User $user, Report $report): bool
    {
        // tenant_id уже отфильтрован global scope'ом — здесь только оргструктура
        return $user->organizations()
            ->where(function ($q) use ($report) {
                // своя единица ИЛИ любой её предок
                $q->whereRaw('? LIKE organizations.path || \'%\'', [$report->organization->path]);
            })
            ->exists();
    }
}
Разница видна прямо в коде. Тенант отсекается до того, как выполнится запрос, и без участия разработчика. Оргструктура проверяется явно, в конкретном месте, и её легко забыть — поэтому она и не годится на роль границы безопасности.
Подводные камни
1. Глобальные UNIQUE-индексы. users.email UNIQUE означает, что один и тот же человек не сможет быть зарегистрирован у двух клиентов. Любая уникальность в мультитенантной схеме — составная: UNIQUE (tenant_id, email), UNIQUE (tenant_id, order_number).
2. Очереди и планировщик. Job сериализуется и выполняется в другом процессе, где контекста тенанта нет. Global scope тихо перестанет фильтровать — и worker обработает данные всех тенантов сразу. tenant_id нужно класть в job явно и восстанавливать контекст в начале handle(). То же касается команд по расписанию и консольных команд.
3. Raw-запросы мимо Eloquent. DB::table('orders'), DB::select(...) и любой join через query builder не знают про global scope. Один такой запрос в выгрузке отчёта — и в CSV клиента попадают чужие строки.
4. Забытый withoutGlobalScopes(). Добавляется «на минутку» при отладке и остаётся в проде навсегда. Такие вызовы стоит запрещать линтером и явно разрешать только в административном коде.
5. Кэш без префикса тенанта. Cache::remember('dashboard_stats', ...) — один ключ на всю платформу. Первый зашедший клиент нагреет кэш, остальные увидят его цифры. Ключ обязан содержать tenant_id.
6. Файловое хранилище и полнотекстовый поиск. Загруженные файлы, индексы Elasticsearch/Meilisearch, сгенерированные PDF — всё это тоже данные тенанта, и на них global scope не распространяется. Изоляция нужна отдельно: свой каталог, свой индекс или обязательный фильтр в поисковом запросе.
Готовые пакеты в Laravel
ПакетЧто берёт на себя
stancl/tenancyНаиболее полный. Поддерживает и multi-database, и single-database с автоматическим скоупом. Определение тенанта по домену/поддомену/пути, изоляция кэша, очередей, файлового хранилища, provisioning с миграциями
spatie/laravel-multitenancyЛёгкий и намеренно минималистичный. Отвечает за определение текущего тенанта и переключение окружения под него (соединение с БД, префикс кэша, storage) через механизм tasks
Своя реализацияТрейт с global scope + middleware + tenant_id в таблицах. Для single-database это 100–150 строк кода и полный контроль
Частое заблуждение. Пакет Spatie нередко описывают как «разделение по колонке». Это неточно: фильтрацию по tenant_id он из коробки не даёт — глобальный scope пишется самостоятельно. Пакет решает задачу «определить тенанта и настроить под него окружение». Корректная формулировка: способ изоляции (колонка / схема / БД) — архитектурное решение, а пакет лишь обслуживает уже выбранный вариант.
Итог
  • Мультитенантность — изоляция данных независимых клиентов внутри одного приложения. Это граница безопасности.
  • Три уровня изоляции: колонка tenant_id, отдельная схема, отдельная БД. Выбор — компромисс между стоимостью эксплуатации и жёсткостью границы.
  • Оргструктура (компания → филиал → отдел) — это не мультитенантность, а модель предметной области с иерархией и авторизацией.
  • Изоляция тенанта должна быть неявной (global scope), авторизация внутри тенанта — явной (Policy). Перепутать слои местами означает построить безопасность на том, что кто-то не забудет дописать where.
  • Утечки живут не в Eloquent, а по краям: очереди, raw-запросы, кэш, файлы, поисковые индексы.
Практика: рефакторинг bad → good
Постановка

Дано: 200-строчный UserController, написанный в стиле «всё в одном». Задача — пошагово привести к читаемому и тестируемому виду без изменения поведения.

Шаг 1: исходный код (упрощённо)
class UserController extends Controller
{
    public function register(Request $request)
    {
        $data = $request->validate([
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8',
            'country' => 'required|string|size:2',
        ]);

        if (! in_array($data['country'], ['KZ', 'RU', 'US', 'DE'])) {
            return response()->json(['error' => 'Country not supported'], 422);
        }

        $user = User::create([
            'email' => $data['email'],
            'password' => Hash::make($data['password']),
            'country' => $data['country'],
        ]);

        Log::info('user.registered', ['user_id' => $user->id]);

        if ($request->has('promo_code')) {
            $promo = Promo::where('code', $request->input('promo_code'))->first();
            if ($promo && $promo->is_active) {
                $user->update(['credit' => $promo->amount]);
            }
        }

        Mail::to($user->email)->send(new WelcomeMail($user));
        Http::post('https://analytics.example.com/event', ['event' => 'signup']);

        return response()->json($user);
    }
}
Шаги рефакторинга

Шаг 1: FormRequest

RegisterUserRequest с правилами и кастомным правилом для страны. Контроллер уменьшается на 10 строк.

Шаг 2: Action

RegisterUser action — принимает DTO, делает всё что в контроллере.

Шаг 3: Зависимости через DI

UserRepository, PromoApplier, Notifier, Analytics — через конструктор Action. Каждая — интерфейс.

Шаг 4: Отдельный Action для промокода

ApplyPromoCode action. Если невалидный — молча пропускается (поведение сохранено).

Шаг 5: Mail и Analytics через события

Action эмитит UserRegistered event. Listeners: SendWelcomeMail (ShouldQueue), NotifyAnalytics.

Шаг 6: Транзакция

DB::transaction(fn () => ...) в Action оборачивает создание + промокод.

Результат
// Controller — 5 строк
final class UserController
{
    public function register(RegisterUserRequest $r, RegisterUser $action): JsonResponse
    {
        $user = $action(RegisterUserData::fromRequest($r));
        return response()->json(new UserResource($user), 201);
    }
}

// Action — 12 строк, оркестрация
final class RegisterUser
{
    public function __construct(
        private UserRepository  $users,
        private ApplyPromoCode $promo,
    ) {}

    public function __invoke(RegisterUserData $data): User
    {
        return DB::transaction(function () use ($data) {
            $user = $this->users->create($data);
            if ($data->promoCode) ($this->promo)($user, $data->promoCode);
            UserRegistered::dispatch($user)->afterCommit();
            return $user;
        });
    }
}

Метрики до/после:

  • Controller: 200 строк → 6 строк × 6 методов = 36 строк;
  • Тестируемость: невозможно (мок 5 фасадов) → легко (DI, фейк-репозитории);
  • Добавить новый источник промокодов: правка Controller → новая стратегия в ApplyPromoCode;
  • Бизнес-логика отделена от инфраструктуры (mail, analytics — через события).
Сводные подводные камни
Топ ошибок дизайна
1. Паттерны ради паттернов. Не можете назвать конкретную проблему — вы добавляете сложность без выгоды.
2. Premature abstraction. Интерфейс для класса с одной реализацией.
3. Big-up-front design. Месяц на UML перед написанием кода — требования меняются в первую неделю.
4. Anemic Domain Model. Entity без поведения, только данные. Вся логика в сервисах. Это процедурный код.
5. God class. 1000+ строк с десятком ответственностей. Разбивайте по SRP.
6. Циклические зависимости. A зависит от B, B от A. Нужен третий компонент или event-based.
7. Static everywhere. Static-методы повсюду — глобальное состояние, не мокаются.
8. Inheritance over composition. Глубокая иерархия (4+ уровня) почти всегда хуже композиции.
9. Магические числа и строки. if ($status === 1) вместо StatusEnum::Active.
10. Тестирование реализации, не поведения. Тесты ломаются при безопасном рефакторинге.
11. «Best practices» из соцсетей. Без контекста — вред. Контекст важнее моды.
12. Игнор domain expert. Архитектура без понимания бизнеса — технически красивая, неподходящая.
Вопросы для самопроверки
SOLID и принципы

1. Расскажите про SOLID на примере из своего кода

Готовьте 1-2 истории: была проблема (трудно тестировать, дублирование) → применили принцип → результат. Не «S означает Single Responsibility».

2. Когда вы НЕ применяете SOLID?

Простой CRUD, тестовый код, прототип. Показывает понимание trade-offs.

3. Разница между DRY и YAGNI?

DRY — не повторять знание. YAGNI — не делать преждевременно. Противоречие: два похожих фрагмента — объединить (DRY) или оставить (YAGNI). Rule of three.

Паттерны

4. Паттерн, который реально использовали недавно

Конкретный кейс. Стратегия для разных отчётов, Adapter для нового SDK, Decorator для кеширования.

5. Adapter vs Decorator?

Adapter изменяет интерфейс (приводит несовместимый к нужному). Decorator сохраняет интерфейс, добавляет функциональность.

6. Что плохого в Singleton?

Скрытая глобальная зависимость, невозможность мокать, неявное состояние. Решение в Laravel — Service Container с singleton-биндингом.

Repository и Service

7. Нужен ли Repository в Laravel?

«Зависит». Eloquent уже — абстракция. Добавление Repository в простом проекте — overengineering. В DDD/Hexagonal — обязателен. Объясните trade-offs.

8. Action vs Service?

Action — один публичный метод (одно действие), Service — несколько связанных. Action лучше для CQRS-стиля.

9. Зачем DTO, если есть массивы?

Type safety, валидация в конструкторе, документация, readonly — нельзя случайно изменить.

DDD и Clean

10. Что такое Aggregate?

Группа Entity и VO с одной точкой доступа (Aggregate Root). Гарантирует консистентность. Order + OrderItems.

11. Entity vs Value Object?

Entity имеет идентичность (id). Value Object — без идентичности, иммутабелен, определяется значениями.

12. Что такое Clean Architecture?

Подход с бизнес-логикой в центре, инфраструктура снаружи. Зависимости направлены внутрь. Полезна для сложных доменов. На CRUD — излишество.

13. Что такое Anemic Domain Model?

Модель только с данными, логика в сервисах. Процедурный код в ООП-обёртке. Логика разбросана, легко нарушить инварианты.

14. Hexagonal vs Clean Architecture?

Концептуально одно: домен в центре, внешние взаимодействия через интерфейсы. Hexagonal проще терминологически. Clean — более явная иерархия слоёв.

15. Расскажите про самый неудачный архитектурный выбор

Senior — человек, у которого есть провалы. Конкретно: «применили микросервисы для маленькой команды, потеряли год на инфраструктуру, вернулись к монолиту». Что научились — критичная часть.