Прагматичные альтернативы DDD · часть 1
Два прошлых урока учили строить строгие границы, и у тебя может остаться впечатление, что серьёзный бэкенд обязан выглядеть как Clean плюс агрегаты. Это не так: подавляющая часть продакшн-кода в индустрии написана проще, и она работает, кормит компании и переживает годы. Этот урок легализует простые подходы: Transaction Script, CQRS без бойлерплейта, vertical slice.
Цель не «отменить DDD». Цель научиться задавать вопрос «окупится ли инвестиция» до того, как в проекте появился первый репозиторий с маппером. Архитектура это всегда трата: ты платишь кодом, порогом входа и скоростью старта сейчас, чтобы дешевле меняться потом. Платить стоит там, где «потом» наступит и будет дорогим.
Когда DDD не окупается
Чек-лист из прошлого урока, развёрнутый с примерами:
- CRUD-нагрузка. Если девять эндпоинтов из десяти это «провалидируй и положи в таблицу», домена как такового нет. Агрегат без инвариантов это структура с бойлерплейтом; ему нечего защищать.
- Команда меньше пяти человек. Единый язык и границы контекстов решают проблему координации многих голов. Когда головы две, у них и так один язык, а каждый лишний слой замедляет обоих.
- MVP с горизонтом до года. Инвестиция в модель окупается изменениями. Если продукт умрёт или будет переписан раньше, чем изменения накопятся, ты заплатил за страховку дома, который снесут.
- Домен простой, даже если продукт большой. Миллионы пользователей не делают домен сложным: лента картинок с лайками это высокие нагрузки при примитивных правилах. Сложность инфраструктуры лечится не агрегатами.
И обратная сторона, чтобы прагматизм не превратился в догму наоборот: в одной кодовой базе спокойно живут несколько подходов. Админка на Transaction Script, продуктовое ядро на DDD, аналитика на голом SQL. Это не эклектика, а сознательный выбор инструмента под подзадачу, тот самый core/supporting/generic из прошлого урока, доведённый до кода.
Transaction Script: функция на use-case
Самый старый паттерн каталога PoEAA и самый недооценённый. Transaction Script: один use-case, одна функция, внутри всё по порядку.
Рефанд билета из урока про REST в этой укладке, поверх Drizzle:
// features/refund-ticket.ts: ВЕСЬ use-case в одной функции
export async function refundTicket(userId: string, ticketId: string) {
return db.transaction(async (tx) => {
const [ticket] = await tx
.select()
.from(tickets)
.where(eq(tickets.id, ticketId))
.for('update'); // лочим строку до конца транзакции
if (!ticket) return { ok: false, reason: 'not_found' } as const;
if (ticket.userId !== userId) return { ok: false, reason: 'forbidden' } as const;
if (ticket.status === 'refunded') return { ok: false, reason: 'already_refunded' } as const;
const [event] = await tx.select().from(events).where(eq(events.id, ticket.eventId));
if (event.startsAt < new Date()) return { ok: false, reason: 'event_started' } as const;
await tx.update(tickets).set({ status: 'refunded' }).where(eq(tickets.id, ticketId));
await tx.insert(refunds).values({ ticketId, amount: ticket.price, requestedBy: userId });
return { ok: true, amount: ticket.price } as const;
});
}
Тридцать строк, и видно всё: правила, порядок, транзакцию, блокировку.
Сравни с DDD-укладкой того же use-case: агрегат Ticket с методом
refund(), репозиторий с byId и save, маппер между агрегатом и
строкой, доменное событие TicketRefunded, обработчик события для
записи в refunds. Поведение то же, файлов пять, строк под полторы
сотни.
Что покупает DDD-версия за эти строки? Инвариант «нельзя рефандить дважды» защищён типом, а не дисциплиной; правило живёт в одном месте, даже если рефанд зовётся из трёх сценариев. Что покупает Transaction Script? Любой человек читает функцию сверху вниз и понимает всё; стек вызовов плоский; дебаг тривиален; написано за полчаса.
Минус скрипта вылезает со временем, и имя ему дублирование. Появился рефанд от модератора: скопировали функцию, убрали проверку владельца. Появился массовый рефанд при отмене события: скопировали ещё раз. Теперь правило «рефанд только до начала события» живёт в трёх местах, и когда бизнес его поменяет, обновят два из трёх. Это и есть граница перехода: одни и те же бизнес-правила скопированы в трёх и более местах, и ошибка рассинхрона уже случалась или вот-вот случится. До этой границы скрипт честно лучше: дешевле, читаемее, быстрее. После неё дешевле становится модель, в которую правило положено один раз.
Заметь, что и внутри скрипта есть бесплатное улучшение: вынести решения в чистую функцию, а I/O оставить по краям, как в functional core. Скрипт с чистым ядром тестируется без БД и при этом не требует ни агрегатов, ни репозиториев.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.