Прагматичные альтернативы DDD · часть 2
Продолжение прагматичного блока. В первой части были подходы уровня кода: как уложить use-case. Здесь подходы уровня системы и культуры: как уложить продукт целиком (modular monolith), куда положить логику (толстая БД против толстого приложения), на чём строить команду (соглашения против абстракций) и мета-принцип про скучные технологии. Финал блока: парадигма тоже выбирается под задачу, а не по вере.
Modular monolith: границы без сети
Дилемму «монолит или микросервисы» обычно подают как выбор из двух зол: либо большой ком грязи, либо распределённая система со всеми её радостями. Это ложная дихотомия, и между полюсами лежит самая недооценённая точка: modular monolith. Одно приложение, один деплой, но внутри границы держатся так же строго, как между сервисами.
Начинается всё с укладки. Package by layer против package by feature:
по слоям (страдает): по модулям (дышит):
src/ src/
controllers/ modules/
users.ts users/
billing.ts index.ts <- публичный API
notifications.ts service.ts <- внутренности
services/ db.ts
users.ts billing/
billing.ts index.ts
... ...
repositories/ notifications/
... index.ts
В layer-укладке модуль billing это мысленная конструкция, размазанная
по трём папкам, и любой может импортировать что угодно откуда угодно. В
module-укладке у billing есть тело и есть публичная дверь:
index.ts экспортирует то, что можно звать снаружи, остальное считается
приватным.
Слово «считается» нужно превратить в «является», иначе граница продержится до первого дедлайна. Для TS это правило линтера:
// eslint.config.mjs: сквозные импорты внутрь чужого модуля запрещены
{
rules: {
'import-x/no-restricted-paths': ['error', {
zones: [
{
target: './src/modules/billing',
from: './src/modules',
except: ['./billing'],
message: 'Внутрь billing нельзя, зови через modules/billing/index.ts',
},
// ... зона на каждый модуль, или generate из списка
],
}],
},
}
Теперь import { chargeUser } from '~/modules/billing' компилируется, а
import { db } from '~/modules/billing/db' падает на CI. Ровно эту
дисциплину Shopify построил для Rails инструментом packwerk и держит на
ней монолит, в котором работают тысячи разработчиков: модули объявляют
зависимости и публичные API, инструмент ловит нарушения.
Что получает modular monolith по сравнению с микросервисами при тех же границах:
- вызов соседнего модуля это вызов функции: без сетевых отказов, повторов и сериализации;
- транзакция через два модуля это обычная транзакция БД, а не сага;
- деплой один: никакой матрицы совместимости версий;
- рефакторинг границы это перемещение в IDE, а не миграция API с дедлайнами.
А что теряет: независимый деплой частей, независимое масштабирование, изоляцию отказов и технологическую свободу (всё в одном рантайме). Когда эти потери становятся реальной болью, модульный монолит превращается в микросервисы легче всех остальных архитектур, потому что границы уже проведены и публичные API уже существуют: меняется транспорт вызова, а не структура системы. Эмпирика индустрии: пока команда одна и над продуктом работает меньше ~50 человек, modular monolith почти всегда выигрывает у микросервисов по совокупной стоимости.
Полный спектр укладок, чтобы видеть ось целиком: big ball of mud, package by layer, package by feature, vertical slices, modular monolith, микросервисы, micro-frontends. Это не дискретный выбор, а шкала строгости границ против операционной цены, и двигаться по ней можно постепенно.
Relational-First: толстая база, тонкое приложение
Следующий сдвиг рамки: а почему логика вообще в приложении? Postgres умеет 70 процентов того, что DDD-проекты пишут в коде, и подход «relational-first» сознательно складывает логику в БД:
-- инвариант: не продать больше вместимости (вместо проверки в сервисе)
ALTER TABLE events ADD CONSTRAINT capacity_not_exceeded
CHECK (sold_count <= capacity);
-- авторизация: каждый видит только свои билеты (вместо if-ов в коде)
ALTER TABLE tickets ENABLE ROW LEVEL SECURITY;
CREATE POLICY tickets_owner ON tickets
USING (user_id = current_setting('app.user_id')::uuid);
-- read-модель: витрина продаж (знакомо по прошлому уроку)
CREATE MATERIALIZED VIEW event_sales_summary AS ...;
-- события: уведомить подписчиков о новой продаже
CREATE TRIGGER notify_sale AFTER INSERT ON tickets
EXECUTE FUNCTION pg_notify_sale();
RLS здесь самый показательный кусок: правило «видишь только своё» исполняет сама БД, и его невозможно забыть в одном из эндпоинтов, потому что эндпоинт вообще не участвует. На этой философии стоят PostgREST (REST API генерируется прямо из схемы БД), Hasura (GraphQL оттуда же) и Supabase, где «бэкенда» в привычном смысле может не быть вовсе.
Сильные стороны: инварианты, которые нельзя обойти (CHECK держит
даже против кривого скрипта миграции и админа с psql), меньше кода,
один источник истины. Слабые: жёсткая привязка к СУБД, логика на SQL и
PL/pgSQL хуже тестируется и версионируется (тесты требуют реального
Postgres, дифф триггера в миграции читается тяжелее диффа функции),
дебаг через RAISE NOTICE это не дебаг через брейкпоинт.
Прагматичная середина, которая стоит взять даже если ты не строишь Supabase-стиль: инварианты данных в БД, сценарии в коде. Уникальность, ссылочная целостность, CHECK-и на диапазоны, NOT NULL: это контракт данных, ему место в схеме. Оркестрация («купи билет, спиши деньги, отправь письмо») остаётся в приложении. БД защищает данные от любого кода, код выражает сценарии понятным языком.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.