DDD-lite в TypeScript бэкендах без бесконечного бойлерплейта
DDD-lite в TypeScript бэкендах разделяет валидацию рантайма, доменные правила и транспортный код, чтобы команды выпускали фичи быстрее без раздутых слоёв.

Где бэкенды путаются
Большинство TypeScript-бэкендов начинает читабельно. Затем контроллер берёт на себя чуть больше работы. Он парсит ввод, проверяет пару правил, пишет в базу и формирует ответ. Это кажется безвредным для первых нескольких маршрутов. Через месяц тот же паттерн встречается везде, с чуть другими проверками в каждом файле.
Один обработчик запроса в итоге делает сразу слишком много. Он проверяет отсутствующие поля, обрезает строки, загружает связанные записи, блокирует недопустимые состояния, пишет данные и строит JSON. Ничего из этого по отдельности не кажется неправильным. Проблема — в том, что всё это собрано в одном месте. Любое небольшое изменение начинает тянуть за всё остальное.
Тут и начинаются дубли. Контроллер отклоняет пустой email. Сервис проверяет его снова. Помощник модели добавляет третью версию, которая почти совпадает с первыми двумя. Тесты становятся хрупкими, потому что приходится прогонять весь стек, чтобы проверить одно правило. Рефактор становится рискованным, потому что изменение формы ответа может случайно поменять поведение бизнеса.
Короткие пути распространяются быстро. Если один контроллер лезет прямо в базу и смешивает бизнес-проверки с маршрутом, следующий контроллер скопирует это. Быстро правила зарываются в обработчиках, валидаторы знают слишком много о хранилище, а транспортный код решает бизнес-исходы. Даже небольшое переименование превращается в поиск по схемам запросов, сервисным функциям и случайным условным операторам.
Лёгкий стиль DDD справляется с этим без превращения кода в теоретический проект. Разделение практичное: рантайм-валидация проверяет вход, доменный код решает, что разрешено, а транспортный код занимается HTTP-деталями — кодами статусов и JSON. Когда у каждой части одна задача, код проще тестировать, проще переносить и вносить изменения.
Три задачи, которые нужно разделить
Бэкенды обычно смешивают три разных вида работы: проверку ввода, применение бизнес-правил и взаимодействие с внешним миром. Когда один файл пытается делать всё сразу, ошибки накапливаются быстро.
Рантайм-валидация работает с «сырыми» данными. Она отвечает на простые вопросы: прислал ли клиент email, является ли inviteCode строкой, является ли age числом. Этот слой не должен решать, может ли кто-то зарегистрироваться. Он лишь превращает неизвестный ввод в доверенные данные или отклоняет его сразу с понятной ошибкой.
Доменные правила решают, что бизнес позволяет. Они отвечают на другие вопросы: может ли этот клиент использовать купон, может ли команда превысить пять мест, работает ли просроченное приглашение. Эти правила не должны заботиться о HTTP-статусах, JSON-телах, заголовках очереди или повторах вебхуков. Они должны вести себя одинаково, пришёл ли вызов из API, фоновой задачи или потребителя сообщений.
Транспортный код находится на краю. Он читает HTTP-запрос, полезную нагрузку задачи или событие, вызывает валидацию, передаёт чистые данные в use case и мапит результат обратно в формат, ожидаемый вызывающей стороной. Этот слой должен быть тонким. Если контроллер начинает проверять лимиты подписки или парсить десять полей вручную, он уже делает слишком много.
Поток регистрации наглядно показывает разделение. Валидация проверяет, что email есть и что inviteCode — строка. Доменный слой решает, существует ли инвайт, использован ли он и принадлежит ли он нужной рабочей области. Транспортный слой превращает эти исходы в 400, 409 или 201.
Держите каждую задачу достаточно маленькой, чтобы тестировать отдельно. Тесты валидации используют «грязный» ввод. Доменные тесты — чистые объекты и бизнес-кейсы. Транспортные тесты проверяют маппинг запросов и ответов. Так каждый тест задаёт один вопрос вместо пяти.
Форма проекта, которая остаётся вменяемой
Вещи быстро портятся, когда парсинг запросов, бизнес-правила и работа с базой живут в одном файле. Скромная структура папок обычно достаточно, чтобы сохранить границы ясными:
transport/для контроллеров, настройки маршрутов и маппинга ответовapplication/для use case, таких какcreateUserилиacceptInvitedomain/для сущностей, value object и бизнес-правил, которые не зависят от Express, Fastify или драйвера базыinfrastructure/для репозиториев, очередей, провайдеров почты и другого внешнего кода
Это работает, потому что обычные TypeScript-модули находятся посередине. Use case не должен заботиться, пришёл ли запрос из REST, очереди задач или CLI. Он должен получать нормальные данные, вызывать доменный код, сохранять изменения через интерфейсы и возвращать результат.
Держите доменные объекты отдельно от типов запросов и ответов. SignupRequestBody — не то же самое, что User, а UserResponse — не ваша доменная модель с переименованными свойствами. Когда эти типы сливаются, транспортные заботы протекают в места, где им не место.
Рантайм-схемы должны жить на границе ввода. Разберите и очистите входные данные до того, как они попадут в application-слой. После этого доменный код работает с доверенными значениями и применяет правила вроде "invite code must belong to the same workspace" или "a suspended user cannot create a project".
Это экономит время позже. Когда команда добавит ещё одну точку входа — админ-инструмент или фоновый воркер — она сможет переиспользовать тот же use case, вместо того чтобы копировать логику из старого контроллера и исправлять её в двух местах.
Один поток запроса, шаг за шагом
Хороший поток запроса кажется скучным. Это хороший знак.
Контроллер должен заниматься HTTP-деталями и чуть-чуть другим. Он читает params, query, заголовки и JSON-тело. Затем он передаёт этот сырой ввод в один шаг валидации.
Валидация проверяет форму и типы, обрезает строки, заполняет простые значения по умолчанию и возвращает простые значения, которым остальной код может доверять. После этого use case не должен видеть объект запроса Express, Fastify или Nest.
Поток простой:
- Контроллер получает запрос и берёт только поля, нужные действию.
- Валидация сразу отклоняет плохой ввод и нормализует принятую форму один раз.
- Use case работает с простыми объектами и примитивами.
- Доменные правила выполняются перед любой записью или сайд-эффектом.
- Контроллер мапит результат обратно в HTTP.
Граница между валидацией и доменными правилами важнее, чем многие команды думают. Валидация спрашивает: «Имеет ли этот набор данных правильную форму?» Доменные правила спрашивают: «Можно ли выполнить это действие прямо сейчас?» Дата может правильно парситься и всё равно нарушать политику. Email может быть валидным формально, но уже занятым другим пользователем.
Делайте записи и сайд-эффекты только после прохождения правил. Сохраняйте запись, отправляйте письмо или публикуйте событие только когда use case скажет «да». Такой порядок предотвращает полузавершённую работу и грязную очистку.
Финальный шаг маппинга небольшой, но команды часто ошибаются. Ожидаемые ошибки должны превращаться в понятные ответы вроде 400, 404 или 409. Неожиданные ошибки идут в логи и возвращают 500, не раскрывая внутренних деталей.
Когда команды строго держатся этого потока, файлы остаются меньше, и баги легче находить. Контроллер говорит на HTTP, валидатор чистит ввод, use case решает, а маппер ответа снова говорит на HTTP.
Реалистичный пример: регистрация с кодом-приглашением
Возьмите эндпоинт регистрации с токеном-приглашением.
Контроллер читает тело запроса и сначала проверяет «сырые» факты: форма email, минимальная длина пароля и то, что токен инвайта соответствует ожидаемому формату. Если что-то из этого не проходит, запрос останавливается и возвращает 400 с ошибками по полям.
Эта первая проверка должна оставаться скучной. Она не должна спрашивать, существует ли инвайт, переполнена ли рабочая область или разрешено ли этому человеку присоединиться. Это доменные вопросы.
Внутри use case приложение загружает инвайт и рабочую область, затем применяет бизнес-правила простым языком. Активен ли инвайте? Уже ли он использован? Позволяет ли этот воркспейс такой тип участника? Если инвайт был отправлен на конкретный адрес, совпадает ли новый пользователь с ним?
Тот же use case должен обрабатывать изменение состояния. Он хеширует пароль, создаёт запись пользователя, привязывает пользователя к рабочей области и отмечает инвайт как использованный. Вызовы репозиториев здесь тоже уместны, рядом с правилами, которые они поддерживают. Если вы разбросаете записи по контроллерам и хелперам, поток станет ненадёжным.
Результат может быть компактным. Не нужно отдавать половину доменной модели контроллеру. Успех с ID пользователя или причина ошибки вроде invite_not_found, invite_used или email_mismatch обычно достаточно.
Потом контроллер завершает работу. Он превращает результат в HTTP. Успех — JSON с созданным аккаунтом. Известные отказы — понятные статусы и короткие сообщения. Это держит валидацию, доменные правила и транспорт отдельно без лишнего бойлерплейта.
Где валидация заканчивается и начинаются доменные правила
Это граница, которую команды чаще всего размывают.
Рантайм-валидация должна останавливать плохой ввод у двери. Отсутствующие поля, неверные типы, пустые строки, некорректные значения перечислений и плохо оформленные даты — сюда. Эти проверки отвечают на один вопрос: можно ли безопасно прочитать полезную нагрузку?
Доменные правила начинаются после того, как данные имеют чистую форму. «Этот код приглашения истёк», «этот план не может добавить ещё админа» и «приостановленный пользователь не может создать проект» — это бизнес-решения. Они зависят от модели, текущего состояния и часто от базы данных. Помещайте их в use case или доменную службу, а не в файл схемы.
Названия тоже важны. Транспортный код часто использует имена запроса вроде invite_code, user_email, query-параметры и заголовки. Ваш домен, вероятно, хочет inviteCode и email. Преобразуйте их один раз рядом с контроллером и перестаньте тащить HTTP-имена по всему приложению. Этот шаг маппинга снимает много путаницы.
Хорошее правило простое. Контроллер читает запрос и запускает рантайм-валидацию. Маппер строит один входной объект для use case. Use case применяет бизнес-правила и вызывает репозитории.
Возьмите снова регистрацию. Сырой запрос может включать заголовки, поля тела и параметры маршрута. Use case не должен знать об этом. Он должен получить одну простую форму ввода вроде email, password и inviteCode. Как только вы это сделаете, доменный код перестанет заботиться, пришли ли данные из JSON, сообщения очереди или команды CLI.
Если правило требует доступа к базе или зависит от текущего состояния бизнеса, это не транспортная валидация. Держите эту линию жёсткой. Это экономит много времени, особенно когда меняются точки входа, а правила остаются теми же.
Ошибки, которые создают лишнюю работу
Лишняя работа обычно начинается, когда команда пытается сделать стиль формальнее, чем нужно. Ясные границы помогают. Церемония — нет.
Одна распространённая ошибка — создание DTO для каждого маленького шага. Тело запроса становится CreateUserDto, затем CreateUserInput, затем CreateUserCommand, хотя все три формы идентичны. Если смысл не изменился, оставьте одну форму. Новый тип создавайте только при реальном переводе между транспортом, доменом и хранением.
Другая ошибка — позволять доменным объектам читать объекты Express или Fastify. Сущность не должна знать о req, заголовках, куках или методах ответа фреймворка. Это привязывает правила к одному веб-слою и делает тесты неудобными. Передавайте простые значения: actorId, email, inviteCode, now.
Повторение одного и того же правила в трёх местах — ещё один простой способ тратить время. Команды часто проверяют формат email в схеме, повторяют это в сервисе и добавляют ещё одну проверку в хук БД. Дайте каждому правилу один дом: валидация проверяет форму, тип и простые форматы; домен — бизнес-правила и изменения состояния; база данных — гарантию уникальности и внешних ключей.
Глубокие деревья папок создают другой вид тормоза. Если у вас есть controllers, handlers, use-cases, services, actions и mappers, кто-то потратит полдня на открытие файлов, которые только переэкспортируют импорты. Меньшие кодовые базы обычно лучше с плоской структурой и именами файлов, которые точно говорят, что они делают.
Общие базовые классы создают ту же проблему. BaseService<T>, BaseRepository<T> и абстрактные хэндлеры часто прячут простую логику за наследованием. Вы экономите пару строк в начале, а потом теряете время при отладке. Простые функции и небольшие классы легче читать, менять и обычно проще для инструментов автодополнения и генерации кода.
Полезный фильтр такой: если файл не содержит правила, преобразования или сайд-эффекта, возможно, он не нужен.
Быстрые проверки при добавлении нового эндпоинта
Новый эндпоинт должен быть скучным для чтения. Если контроллер требует более минуты, чтобы понять, что он делает, вероятно, он делает слишком много.
Не нужен полный архитектурный ревью, чтобы это поймать. Несколько вопросов достаточно:
- Может ли кто-то быстро прочитать контроллер и объяснить, что он делает?
- Отклоняете ли вы плохой сырой ввод до того, как стартует use case?
- Может ли доменное правило выполняться с простыми значениями без объектов запроса/ответа?
- Может ли воркер очереди, cron или CLI вызвать тот же use case без переписывания?
- Покрывают ли тесты один позитивный путь и одно правило, блокирующее действие?
Эти проверки работают, потому что они соответствуют разным задачам. Контроллер переводит детали транспорта. Валидация отклоняет некорректный ввод: отсутствие полей, неправильные типы или плохие enum-значения. Use case решает, разрешено ли действие.
Опять регистрация. Контроллер читает JSON и заголовки. Валидация подтверждает, что email, password и inviteCode присутствуют и правильной формы. Use case решает, активен ли инвайт, занят ли email и разрешена ли регистрация для данного типа аккаунта. Маппер ответа превращает результат в коды статусов и JSON.
Если доменный код нуждается в req.body, res.status или контексте фреймворка, граница уже размыта. Та же проблема проявляется, когда воркер не может переиспользовать логику регистрации и вы копируете правила во второй путь. Это копирование обычно уходит в дрейф за пару дней.
Тесты покажут, действительно ли разделение есть. Один тест должен показать, что валидный ввод доходит до use case и возвращает ожидаемый результат. Другой должен показать, что доменное правило блокирует действие даже при корректной форме данных.
Для большинства команд этого достаточно. Ясные границы побеждают причудливые паттерны.
Следующие шаги для живой кодовой базы
Этот стиль работает лучше как очистка, а не как переписывание. Если ваше приложение уже в проде, не останавливайте команду и не рисуйте всё заново. Выберите один шумный эндпоинт — тот, который часто ломается, растёт условными операторами или тормозит ревью.
Держите внешний интерфейс стабильным, пока чистите внутренности. Форма запроса, форма ответа, коды статусов и сообщения об ошибках должны оставаться прежними, если нет веской причины менять их. Пользователи и фронтенд не должны заметить ваш рефактор.
Хорошая последовательность: добавьте пару тестов вокруг текущего поведения эндпоинта. Перенесите парсинг запроса и рантайм-проверки в один валидатор. Вынесите одно бизнес-правило в доменную функцию. Оставьте вызовы базы там, где они есть, если это делает изменение небольшим. Удаляйте старую логику только после того, как новый путь даст тот же результат.
Порядок важен. Команды застревают, когда пытаются переместить всё сразу. Контроллер на 200 строк может стать гораздо проще, если убрать лишь одно правило — например, кто может одобрить возврат или когда инвайт истекает.
ORM-хуки требуют того же подхода. Если хук проверяет бизнес-правило, сначала вынесите это правило в простую доменную функцию. Вызовите её из приложения, докажите, что она работает, затем уберите хук. Скрытое поведение — любимое место багов.
Держите изменение маленьким, чтобы его завершить
Хороший первый рефактор занимает день или два, а не месяц. Если вам нужен план миграции, шесть новых папок и три встречи — срез слишком большой. Небольшие изменения учат команду, что должно быть в валидации, что в доменном коде и что должно оставаться рядом с транспортом.
Запишите правило после того, как его переместили. Короткая заметка в кодовой базе — достаточно. После нескольких эндпоинтов шаблон становится очевидным, и следующий чистящий шаг идёт быстрее.
Если нужен внешний обзор перед правками в живом бэкенде, Oleg Sotnikov на oleg.is помогает стартапам и небольшим компаниям почистить структуру бэкенда, инфраструктуру и AI-first доставку, не превращая работу в долгий рефактор-проект.
Часто задаваемые вопросы
Что значит DDD-lite в TypeScript бэкенде?
DDD-lite означает, что вы разделяете три задачи и держите их небольшими. Валидация проверяет «сырые» входные данные, доменный код решает, что разрешено бизнесом, а транспортный код обрабатывает HTTP-детали вроде кодов статуса и JSON.
Где должна происходить runtime-валидация?
Держите её на границе ввода — сразу после того, как контроллер прочитал запрос. Парсите, обрезайте и нормализуйте полезную нагрузку там, чтобы use case получал только доверенные значения.
Что относится к доменным правилам, а не к валидации?
Доменные правила отвечают на бизнес-вопросы, а не на вопросы формы. Например: истёк ли инвайт, переполнена ли рабочая область, или пользователь приостановлен — это вопросы слоя использования/домена и они могут требовать чтения из базы.
Должны ли контроллеры обращаться напрямую к базе данных?
Нет. Контроллер должен читать запрос, вызывать валидацию, передавать чистые данные в use case и маппить результат обратно в HTTP. Как только он смешивает запросы к БД и бизнес-проверки, файл становится ненадёжным.
Нужны ли отдельные DTO для каждого слоя?
Обычно нет. Если CreateUserDto, CreateUserInput и CreateUserCommand означают одно и то же, оставьте одну форму. Новый тип создавайте только тогда, когда действительно переводите между транспортом, доменом или хранилищем.
Как сделать один use case подходящим для HTTP, задач и CLI?
Используйте простые объекты и простые значения в application-слое. Если логика регистрации требует только email, password и inviteCode, воркер, CLI или фоновая задача смогут вызвать тот же use case без HTTP-кода.
Как маппить доменные ошибки в HTTP-статусы?
Ожидаемые ошибки обрабатывайте как обычные результаты и мапьте их на краю. Плохой ввод обычно — 400, отсутствующие данные — 404, конфликт бизнес-правил — 409, а неожиданные ошибки логируйте и отдавайте 500 без утечки внутренних деталей.
Как рефакторить грязный эндпоинт без полного переписывания?
Начните с одного шумного эндпоинта, не с полного приложения. Добавьте тесты текущего поведения, переместите парсинг в валидатор, вынесите одно бизнес-правило в доменную функцию и держите внешний API неизменным, пока чистите внутренности.
Что тестировать при разделении валидации, домена и транспорта?
Хорошее разделение даёт сфокусированные тесты. Один тест доказывает, что валидация отклоняет плохой ввод, другой — что бизнес-правило блокирует действие, а небольшой тест транспорта проверяет статус и тело ответа.
Какие обычные признаки размытой границы?
Признаки — правила в схемах, утекший req или res в доменный код и одно и то же правило, повторяющееся в контроллерах, сервисах и хукe. ORM-хуки часто прячут бизнес-логику — вынесите такие правила в простые функции, которые можно напрямую вызвать и протестировать.