Архитектура и паттерны
SOLID и эвристики дизайна, классические GoF-паттерны с PHP-примерами, веб-паттерны (Repository, Service, DTO), DDD-тактика (Entity, Value Object, Aggregate), Clean Architecture и Hexagonal. Все примеры на Laravel, с реальными bad-vs-good рефакторингами.
Паттерны и принципы — не самоцель. 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 |
| GoF | Creational, Structural, Behavioral — самые используемые в backend |
| Web/App | Repository, Service Layer, DTO — на каждый день |
| DDD/Clean | Entity, Value Object, Aggregate, Hexagonal |
| Практика | Рефакторинг 200-строчного контроллера до читаемого кода |
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.
// Контроллер делает всё
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);
}
UserService::create() может делать 5 шагов — это одна ответственность: создание пользователя.BookController с прямыми Book::create() — читается лучше, чем три уровня сервисов.throw new NotImplementedException() — это ISP-нарушение.Дополнительные эвристики. 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. Должен быть тонким.
// Два метода, похожих на 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: оставить два метода до третьего случая.
// Эволюция: дублирование → паттерн → абстракция.
Паттерны создания объектов. Решают «как получить экземпляр», когда простой 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 дорого.
UserFactory::create($data) вместо new User($data) — лишний indirection.getInstance() — глобальные скрытые зависимости. Service Container.__clone и shallow copy. По умолчанию вложенные объекты shared. Deep clone — явно через __clone.Model::create, ::firstOrCreate — валидно в контексте ORM.Большие структуры из меньших объектов. 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.
Взаимодействие между объектами. 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.
// 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); }
}
OrderService::cancel($id), а Command просто его вызывает — нет смысла.if ($status === 'pending' && !$paid) — запах. Извлеките в классы.Repository — абстракция хранилища: «дай мне сущность по критериям». Скрывает детали БД от бизнес-логики. В DDD — обязательный паттерн. В Laravel — спорный, поскольку Eloquent уже реализует Active Record + Query Builder.
| За | Против |
|---|---|
| Изоляция бизнес-логики от Eloquent | Eloquent уже — абстракция над PDO |
| Подмена реализации в тестах | Model::factory() + RefreshDatabase делает то же |
| Единая точка для сложных запросов | Eloquent scopes |
| Готовность к смене ORM | Смена ORM — редкое событие; YAGNI |
| Чистая архитектура (Hexagonal) | Для большинства проектов overkill |
Практический критерий: если в проекте сложный домен (DDD) или multiple data sources — Repository оправдан. В обычном CRUD — антипаттерн.
// Интерфейс — что нужно бизнес-логике
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);
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);
}
readonly (PHP 8.1+).calculateDiscount() в DTO — это уже не DTO.new — нарушение DI.new ServiceA — не подменить в тестах.Domain-Driven Design (Эрик Эванс, 2003). Стратегический DDD — о структуре больших систем (Bounded Context). Тактический — о шаблонах внутри одного контекста: Entity, Value Object, Aggregate, Domain Service, Repository, Domain Event.
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().
['amount' => 100, 'currency' => 'USD'] вместо Money — теряете валидацию и type safety.User, разговоры Member — UL не работает. Переименовывайте.Order в e-commerce и restaurant — разные вещи. Bounded Context.Clean Architecture (Боб Мартин) и Hexagonal (Алистер Кокберн) ставят в центр бизнес-логику, изолированную от инфраструктуры. БД, веб, очереди — детали, легко заменяемые. Цель: код, тестируемый без БД, переносимый между фреймворками.
1. Entities
Доменные сущности с бизнес-правилами. Не зависят ни от чего внешнего.
2. Use Cases
Оркестрация: CreateOrder, CancelOrder. Знают о Entities, не о БД/HTTP.
3. Interface Adapters
Controllers, Presenters, Repositories. Преобразование данных между слоями.
4. Frameworks & Drivers
Laravel, Eloquent, Stripe SDK, Redis. Самый внешний слой.
Правило зависимости
Зависимости направлены внутрь. Внутренние слои не знают о внешних.
Альтернативный взгляд: центр — домен; снаружи — адаптеры через порты (интерфейсы). Каждое внешнее взаимодействие — отдельный 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) {}
}
Domain/, Application/ без следования правилу — ярлыки.Микросервис — небольшая автономная программа, отвечающая за одну бизнес-возможность и общающаяся с остальными по сети: HTTP/REST, gRPC или сообщения.
Микросервисная архитектура — построение приложения как набора таких независимых сервисов вместо одного большого.
Модуль внутри монолита — часть одного процесса и одной кодовой базы. Микросервис — отдельно развёртываемый компонент.
| Критерий | Монолит | Микросервисы |
|---|---|---|
| Развёртывание | Всё приложение целиком | Каждый сервис отдельно |
| База данных | Обычно общая | У каждого своя |
| Масштабирование | Масштабируется всё | Только нагруженный сервис |
| Технологии | Один стек | Можно разные языки и БД |
| Сбои | Падение может уронить всё | Изолируются, но появляются сетевые отказы |
| Сложность | Проще разрабатывать и отлаживать | Сложнее инфраструктура и наблюдаемость |
| Транзакции | Обычные транзакции БД | Саги и eventual consistency |
| Принцип | Что означает на практике |
|---|---|
| Одна бизнес-возможность | Корзина, оплата, каталог, уведомления, аутентификация — по одному сервису |
| Независимое развёртывание | Обновить оплату, не пересобирая весь сайт |
| Изоляция данных | У сервиса заказов своя БД. В чужие таблицы напрямую не лезут никогда |
| Связь по сети | API или сообщения. Слабая связанность ценой задержек и отказов |
| Автономность команд | Команда владеет сервисом целиком: код, деплой, база, мониторинг |
| Отказоустойчивость | Таймауты, ретраи, circuit breaker, очереди, health checks |
| Наблюдаемость | Логи, метрики, распределённые трассировки |
Сервис заказов при оформлении может синхронно спросить оплату — ответ нужен сразу. А уведомление отправить асинхронно через очередь: пользователь не должен ждать, пока уйдёт письмо.
| Плюсы | Минусы |
|---|---|
| Независимые деплои и релизы | Сложная инфраструктура: оркестрация, service discovery, gateway |
| Точечное масштабирование | Сетевые задержки и частичные отказы |
| Изоляция сбоев | Отладка и трассировка через несколько сервисов |
| Технология под задачу | Согласованность данных: саги вместо транзакций |
| Команды меньше зависят друг от друга | Дорого на старте и для маленьких команд |
| Оправданы, если есть | Не стоит, если |
|---|---|
| Несколько команд | Команда из двух-трёх человек |
| Высокая нагрузка на отдельные части | Нагрузка равномерная и небольшая |
| Нужны частые независимые релизы | Релизы и так редкие |
| Зрелая DevOps-культура | Нет мониторинга и автодеплоя |
| Понятные границы домена | Домен ещё не устоялся и меняется |
| Тема | Где разобрано |
|---|---|
| Надёжная публикация событий между сервисами | Transactional Outbox — следующий раздел |
| Границы домена и контексты | DDD тактика |
| Изоляция данных между клиентами | Мультитенантность |
| Таймауты, идемпотентность, ретраи | Node.js и NestJS → Паттерны надёжности |
Классический сценарий в микросервисах: сервис заказов должен одновременно:
- Сохранить заказ в свою БД (
orders). - Опубликовать событие
OrderCreatedв брокер (Kafka / RabbitMQ / SNS).
Это две разные системы. Атомарной транзакции между ними не бывает без сложных протоколов вроде 2PC. Значит возможны рассогласованные состояния:
| Что произошло | Последствие |
|---|---|
| Заказ сохранён, событие не отправлено (упал брокер / сеть) | Другие сервисы никогда не узнают о заказе. Оплата не спишется, склад не зарезервирует, письмо не уйдёт. |
| Событие отправлено, транзакция откачена (упал SQL после публикации) | Downstream начнёт обрабатывать несуществующий заказ — грязные данные в других сервисах. |
DB::transaction(fn () => { $order->save(); Kafka::publish(...); });. Publish не в БД — rollback Laravel не откатит уже отправленное сообщение. Аналогично «сначала опубликую, потом сохраню» — брокер не откатывается по сигналу приложения.Идея: перенести публикацию в БД. В той же транзакции, что сохраняет заказ, пишем ещё одну строку — в служебную таблицу outbox. Отдельный процесс (relay) читает outbox и публикует события в брокер уже вне транзакции.
- BEGIN транзакции БД.
- Сохранение бизнес-данных.
INSERT INTO orders ... - Сохранение события в outbox.
INSERT INTO outbox (event_type, payload_json, aggregate_id, created_at). - COMMIT. Либо всё сохранилось, либо ничего — атомарно.
- Relay-процесс опрашивает outbox, забирает не-отправленные события.
- Publish в брокер. Успешно → пометить
published_at = now()(или удалить строку). Неуспешно → оставить как есть, следующий цикл повторит.
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;
Индекс частичный — по «горячей» части (не-опубликованные): выборка воркера всегда быстрая, даже когда в таблице миллион старых записей.
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 лежит — заказ создан, событие ждёт в БД.
// 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 ты этого не увидишь напрямую.
event_id в отдельной таблице processed_events.aggregate_id порядок должен сохраняться. Один relay-процесс на partition/aggregate решает это; распараллеливание — по aggregate_id (Kafka partition key).DELETE FROM outbox WHERE published_at < now() - interval '7 days'). Иначе таблица становится терабайтной.Kafka::publish внутри DB::transaction — паттерн сломан. Publish всегда отдельно, после COMMIT'а бизнес-транзакции.- Микросервисы, где надо гарантированно уведомить downstream о доменных событиях.
- Модульные монолиты с eventual consistency между модулями (даже без брокера — тот же паттерн для отложенных Jobs).
- Интеграция с внешними API: платёжный вебхук, отправка в CRM, синхронизация с 1С. Всё, где потеря = деньги/данные.
Когда не нужен: монолит, всё в одной БД, обработка синхронная — просто дёргай event listener напрямую. Для маленьких проектов outbox — оверинжиниринг.
outbox. На типовом Laravel — реализуется за один вечер, но экономит недели отладки «где мои события».
Мультитенантность — архитектура, при которой одно развёрнутое приложение обслуживает несколько независимых клиентов (тенантов), а данные каждого изолированы от остальных. Один код, одна инфраструктура, N клиентов, которые друг о друге не знают.
Типичные примеры: SaaS-сервис, где каждая компания-клиент видит только свои данные; платформа интернет-магазинов, где в одной базе живут десятки брендов; биллинговая система, обслуживающая сотни организаций.
Самая частая путаница: мультитенантность отождествляют с привязкой пользователей к организациям (компания → филиал → отдел). Это два разных слоя, которые решают разные задачи.
| Мультитенантность | Оргструктура внутри клиента | |
|---|---|---|
| Задача | Изоляция данных независимых клиентов | Моделирование структуры одного клиента |
| Природа | Инфраструктурный слой, граница безопасности | Предметная область + авторизация |
| Где живёт | Global scope, middleware, выбор соединения | Обычные таблицы, связи, Policy / Gate |
| Как работает | Неявно и всегда — разработчик о ней не думает | Явно — проверка пишется руками в каждом месте |
| Цена ошибки | Утечка данных чужой компании, нарушение GDPR | Сотрудник увидел лишний отчёт внутри своей же компании |
| Главный вопрос | «Чьи это данные?» | «Что этому сотруднику разрешено?» |
1. «Может ли пользователь легально переключиться между этими сущностями?» Сотрудник переходит из отдела продаж в маркетинг — это оргструктура. ООО «Ромашка» никогда не станет ООО «Лютик» — это тенанты.
2. «Должен ли я удалить все данные одной сущности одной операцией при расторжении договора?» Если да — это тенант.
| Термин | Значение |
|---|---|
| Tenant | Арендатор: клиент, организация, аккаунт — единица изоляции |
| Multi-tenancy | Архитектура с разделением данных между тенантами в общем приложении |
| Single-tenancy | Отдельное развёртывание под каждого клиента: максимальная изоляция, максимальная стоимость эксплуатации |
| Tenant isolation | Степень изоляции: общая БД → отдельные схемы → отдельные БД |
| Tenant context | Текущий тенант в рамках запроса или job'а |
| Tenant provisioning | Создание нового тенанта: миграции, сиды, служебные записи |
| Noisy neighbor | Тенант, выжирающий общие ресурсы и роняющий производительность остальным |
Выбор уровня — центральное архитектурное решение. Это компромисс между стоимостью эксплуатации и жёсткостью границы.
| Уровень | Как устроено | Плюсы | Минусы |
|---|---|---|---|
Колонка tenant_idshared database | Все тенанты в одних таблицах, фильтрация по колонке | Дёшево, одна миграция на всех, простые агрегаты по всей платформе | Одна забытая фильтрация = утечка. Общие индексы растут. Шумный сосед бьёт по всем |
| Схема БД schema-per-tenant | Своя schema в одной СУБД (PostgreSQL), переключение search_path | Данные физически разделены, бэкап на тенанта | Миграции × N схем. Тысячи схем тормозят каталог СУБД |
| Отдельная БД database-per-tenant | Своё соединение на тенанта | Жёсткая граница, можно развезти по серверам, изоляция нагрузки | Дорого. Миграции, бэкапы, мониторинг — кратно сложнее. Кросс-тенантная аналитика превращается в ETL |
tenant_id — этот вариант закрывает большинство B2B SaaS. Переходи на отдельные БД, когда этого требует регуляторика (HIPAA, банковский сектор, требование хранить данные клиента в его юрисдикции) или когда один крупный клиент по нагрузке сопоставим со всеми остальными.На практике тенант и оргструктура сосуществуют: тенант — это клиент, а внутри него разворачивается иерархия подразделений.
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 ломаются на четвёртом уровне: появится «дивизион», «проектная команда» или «рабочая группа» — придётся менять схему и все запросы. Иерархия одна, тип узла — её атрибут.Фильтр по тенанту не должен зависеть от памяти разработчика. Он вешается глобальным 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);
}
}
Здесь всё наоборот: правила пишутся руками и живут в 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();
}
}
users.email UNIQUE означает, что один и тот же человек не сможет быть зарегистрирован у двух клиентов. Любая уникальность в мультитенантной схеме — составная: UNIQUE (tenant_id, email), UNIQUE (tenant_id, order_number).tenant_id нужно класть в job явно и восстанавливать контекст в начале handle(). То же касается команд по расписанию и консольных команд.DB::table('orders'), DB::select(...) и любой join через query builder не знают про global scope. Один такой запрос в выгрузке отчёта — и в CSV клиента попадают чужие строки.withoutGlobalScopes(). Добавляется «на минутку» при отладке и остаётся в проде навсегда. Такие вызовы стоит запрещать линтером и явно разрешать только в административном коде.Cache::remember('dashboard_stats', ...) — один ключ на всю платформу. Первый зашедший клиент нагреет кэш, остальные увидят его цифры. Ключ обязан содержать tenant_id.| Пакет | Что берёт на себя |
|---|---|
| stancl/tenancy | Наиболее полный. Поддерживает и multi-database, и single-database с автоматическим скоупом. Определение тенанта по домену/поддомену/пути, изоляция кэша, очередей, файлового хранилища, provisioning с миграциями |
| spatie/laravel-multitenancy | Лёгкий и намеренно минималистичный. Отвечает за определение текущего тенанта и переключение окружения под него (соединение с БД, префикс кэша, storage) через механизм tasks |
| Своя реализация | Трейт с global scope + middleware + tenant_id в таблицах. Для single-database это 100–150 строк кода и полный контроль |
tenant_id он из коробки не даёт — глобальный scope пишется самостоятельно. Пакет решает задачу «определить тенанта и настроить под него окружение». Корректная формулировка: способ изоляции (колонка / схема / БД) — архитектурное решение, а пакет лишь обслуживает уже выбранный вариант.- Мультитенантность — изоляция данных независимых клиентов внутри одного приложения. Это граница безопасности.
- Три уровня изоляции: колонка
tenant_id, отдельная схема, отдельная БД. Выбор — компромисс между стоимостью эксплуатации и жёсткостью границы. - Оргструктура (компания → филиал → отдел) — это не мультитенантность, а модель предметной области с иерархией и авторизацией.
- Изоляция тенанта должна быть неявной (global scope), авторизация внутри тенанта — явной (Policy). Перепутать слои местами означает построить безопасность на том, что кто-то не забудет дописать
where. - Утечки живут не в Eloquent, а по краям: очереди, raw-запросы, кэш, файлы, поисковые индексы.
Дано: 200-строчный UserController, написанный в стиле «всё в одном». Задача — пошагово привести к читаемому и тестируемому виду без изменения поведения.
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 — через события).
if ($status === 1) вместо StatusEnum::Active.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-биндингом.
7. Нужен ли Repository в Laravel?
«Зависит». Eloquent уже — абстракция. Добавление Repository в простом проекте — overengineering. В DDD/Hexagonal — обязателен. Объясните trade-offs.
8. Action vs Service?
Action — один публичный метод (одно действие), Service — несколько связанных. Action лучше для CQRS-стиля.
9. Зачем DTO, если есть массивы?
Type safety, валидация в конструкторе, документация, readonly — нельзя случайно изменить.
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 — человек, у которого есть провалы. Конкретно: «применили микросервисы для маленькой команды, потеряли год на инфраструктуру, вернулись к монолиту». Что научились — критичная часть.