Чистая архитектура домена аудиотреков в музыкальном сервисе
На примере музыкального сервиса показываю, как превратить хаотичный модуль треков в систему с ограниченными контекстами, слоями Domain/Application/Infrastructure и готовностью к тяжёлым DSP-операциям
Под словом «трек» я буду иметь в виду аудиотрек в музыкальном сервисе, а не гоночную трассу. Представьте площадку вроде «музыкального Notion»: пользователи загружают свои демки, докупают версии в лучшем качестве, запускают эффекты вроде удаления вокала и делятся результатом. Такой продукт называют «доменом треков» — область ответственности внутри платформы, где живут все операции с музыкальными файлами и их производными.
Музыкальные проекты редко ограничиваются CRUD-операциями. Помимо загрузки трека пользователю хочется разделять его на стемы, удалять вокал, мастеринговывать, хранить платные форматы и подписочный контент. Если всё это собрать в один сервис, он быстро превращается в «гигантский TrackService», где переплетаются SQL, cron'ы, бизнес-правила и работа с очередями. В этой статье покажу, как я пересобрал домен треков в рамках такого сервиса, чтобы он остался в монолите NestJS, но при этом ощущался как набор независимых контекстов.
Что такое домен треков
Это логическая граница внутри музыкального продукта, которая охватывает всё, что связано с аудиофайлами: от метаданных до DSP-обработок. Именно здесь появляются вопросы «кто владеет этим треком?», «где хранится wav?», «какой обработчик удаляет вокал?» и «какой тариф даёт доступ к премиум-стемам?». Дальше я буду использовать конкретные примеры — загрузку mp3 и постановку задач на обработку — но сам подход можно перенести на любые мультимедийные домены.
Откуда растут требования
- Треки и ассеты. Один трек может иметь несколько вариантов:
mp3,wav, платные стемы. Нужны политики хранения (кто и когда может скачать, что удаляем автоматически). - Тяжёлая обработка. Разделение на части, удаление вокала и прочие DSP-задачи не должны блокировать HTTP-запросы.
- Монетизация. Есть премиальные ассеты, к которым доступ открывается только после покупки, и бесплатные версии с ограниченным сроком.
- Наблюдаемость. Нужно понимать, что происходит с каждой задачей и файлом: когда был скачан, сколько живёт, где хранится.
Чтобы не тащить всё это в один сервис, я выделил несколько ограниченных контекстов.
Ограниченные контексты (bounded contexts)
| Контекст | Отвечает за | Что хранит |
|---|---|---|
TrackCatalog | Метаданные трека, владельца, статусы публикации | tracks + аудит изменений |
TrackAssets | Бинарные файлы, провайдеры хранения, срок жизни | Таблицу track_assets, работу с хранилищем и cron очистки |
TrackProcessing | Постановка и учёт DSP-задач | Очереди, job'ы, статусы обработки |
TrackIntelligence | ML-наблюдения, рекомендации | Модели, фичи, аналитику |
В рамках статьи я реализовал два первых контекста и заготовку для Processing. Каждый контекст оформлен отдельным модулем NestJS и при необходимости может быть вынесен в микросервис без боли.
Слои внутри модуля
Вместо одной директории tracks/ с кучей файлов я разбил код на три слоя:
src/tracks/
track-assets/
domain/
application/
infrastructure/
processing/
domain/
application/
infrastructure/
tracks.module.ts
- Domain — чистые сущности и value object'ы. Например,
TrackAssetEntityпроверяет, истёк ли файл и можно ли его отдавать, не зная ничего про NestJS или базу. - Application — use-case сервисы, которые оркестрируют домен и инфраструктуру.
TrackAssetsServiceполучает входные параметры, вызывает доменную логику, а затем репозиторий иStorageService. - Infrastructure — адаптеры. Сюда попали репозитории, cron-сервисы, очереди. Если завтра понадобится вынести хранение в отдельный сервис, меняется только инфраструктурный слой.
Такой разброс помогает быстро ответить на вопросы «где лежит бизнес-логика?» и «как подключить новый провайдер без переписывания домена».
TrackAssetsModule в деталях
Domain: TrackAssetEntity
- Инкапсулирует поле
lifecycleStateиexpiresAt. - Даёт методы
isDeleted(),isExpired(),isDownloadable()— сервисы приложения перестают напрямую работать с полями и читают готовые ответы. - Позволяет централизованно добавить новые правила (например, премиальный файл скачивается только если у пользователя есть покупка).
Application: TrackAssetsService
- Создаёт ассеты через
StorageService+TrackAssetsRepository. - При стриминге преобразует запись в доменную сущность, проверяет доступность и только потом создаёт
readStream. - При удалении тоже работает через домен, чтобы не забыть про статусы в БД.
Infrastructure: репозиторий и cron
- Репозиторий использует Drizzle ORM, возвращает обычные
TrackAssetзаписи. TrackAssetsCleanupServiceзапускается каждые 10 минут, через репозиторий ищет просроченные ассеты и отдаёт их сервису на удаление. Cron живёт в слое инфраструктуры, потому что взаимодействует с внешним расписанием и логгером.
TrackProcessingModule как заготовка
Пока сама DSP-логика не реализована, но модуль уже оформлен и показывает, как мы будем подключать воркеры:
- Domain:
ProcessingJobEntityописывает задание (trackId, тип обработки, payload). - Infrastructure:
InMemoryProcessingQueue— простая очередь с логгером. Её легко заменить на BullMQ, SQS или Kafka. - Application:
DemoProcessingHandlerпринимает запрос из контроллера, создаёт доменную сущность и кладёт её в очередь.
Такой «скелет» позволяет фронтовой команде или продукту увидеть, где будут лежать будущие возможности, и заранее обсудить API (REST, gRPC, события).
Как всё связывается в NestJS
TrackAssetsModuleэкспортирует толькоTrackAssetsService, скрывая репозиторий и cron.TrackProcessingModuleэкспортирует свои хэндлеры.TracksModuleагрегирует оба подмодуля и экспортирует их наружу.AppModuleподключаетTracksModule, не зная о внутренней структуре.
Благодаря этому в других частях приложения мы зависим либо от TrackAssetsService, либо от DemoProcessingHandler, не создавая жёстких связей между слоями.
Пример потока: скачивание ассета
- Контроллер вызывает
TrackAssetsService.streamAsset(assetId). - Сервис достаёт запись через репозиторий.
- Запись оборачивается в
TrackAssetEntity. Если файл истёк или удалён, сервис сразу кидаетNotFoundException. - Если файл доступен, сервис создаёт
readStreamчерезStorageService. - После успешного создания стрима репозиторий обновляет
lastAccessedAt.
В итоге условия доступности сосредоточены в домене, логика чтения/записи — в слоях приложения и инфраструктуры, а контроллер получает готовый ответ.
Как масштабировать этот подход
- Добавление новых форматов. Достаточно расширить enum в схеме и обновить
TrackAssetEntity, не затрагивая очереди или cron. - Вынесение обработки. Можно оставить Nest для API, а инфраструктурный слой TrackProcessing переключить на внешний gRPC сервис. Application-слой не заметит разницы.
- Новые контексты. Появится, например, CollaborationModule — просто создаём новую директорию с теми же слоями и подключаем в
TracksModule. - Feature флаги. Инфраструктурный слой легко обернуть в
ConfigService, чтобы включать обработку по регионам или тарифам.
Практический чеклист при внедрении
- Разбейте папку
tracks/на подмодули по контекстам. - Выделите доменные сущности даже если они пока тонкие. Они станут точкой расширения.
- Сервис приложения должен быть тонким: никакого SQL/HTTP прямо внутри него.
- Инфраструктура — единственное место, где есть зависимость от Drizzle, BullMQ, Cron и т.д.
- Агрегируйте модули через
TracksModule, чтобы наружу экспортировать только нужные сервисы. - Покройте доменные сущности тестами, чтобы не словить регрессию при добавлении новых состояний.
Что даёт такая архитектура
- Прозрачность. Любой разработчик сразу видит, где бизнес-правила, а где адаптеры.
- Тестируемость. Domain тестируется без Nest и базы. Application тестируется через мок-интерфейсы. Infrastructure — интеграционными тестами.
- Расширяемость. Добавить новый тип обработки = создать доменную сущность/хэндлер в
processing/и подключить новую очередь. - Готовность к микросервисам. Если станет тесно, достаточно вынести инфраструктуру и application в отдельный сервис, оставив общий домен в npm-пакете.
Заключение
Даже в монолите можно выстроить архитектуру, в которой разные команды пилят свои контексты, не мешая друг другу. Треки — идеальный пример: сегодня у нас базовая загрузка и cron очистки, завтра — сложные DSP пайплайны. Благодаря слоям Domain/Application/Infrastructure и модульной структуре мы заранее заложили основу для роста и не превратили проект в очередной «скрипт на миллион строк». Если вы чувствуете, что ваш TrackService выходит из-под контроля, начните хотя бы с выделения домена и инфраструктуры — и вы сразу увидите, как меняется скорость разработки.