Все статьи🇷🇺 Русский
6 мин

Чистая архитектура домена аудиотреков в музыкальном сервисе

На примере музыкального сервиса показываю, как превратить хаотичный модуль треков в систему с ограниченными контекстами, слоями Domain/Application/Infrastructure и готовностью к тяжёлым DSP-операциям

architecturenestjsclean-architecturemusic-techdsp

Под словом «трек» я буду иметь в виду аудиотрек в музыкальном сервисе, а не гоночную трассу. Представьте площадку вроде «музыкального Notion»: пользователи загружают свои демки, докупают версии в лучшем качестве, запускают эффекты вроде удаления вокала и делятся результатом. Такой продукт называют «доменом треков» — область ответственности внутри платформы, где живут все операции с музыкальными файлами и их производными.

Музыкальные проекты редко ограничиваются CRUD-операциями. Помимо загрузки трека пользователю хочется разделять его на стемы, удалять вокал, мастеринговывать, хранить платные форматы и подписочный контент. Если всё это собрать в один сервис, он быстро превращается в «гигантский TrackService», где переплетаются SQL, cron'ы, бизнес-правила и работа с очередями. В этой статье покажу, как я пересобрал домен треков в рамках такого сервиса, чтобы он остался в монолите NestJS, но при этом ощущался как набор независимых контекстов.

Что такое домен треков

Это логическая граница внутри музыкального продукта, которая охватывает всё, что связано с аудиофайлами: от метаданных до DSP-обработок. Именно здесь появляются вопросы «кто владеет этим треком?», «где хранится wav?», «какой обработчик удаляет вокал?» и «какой тариф даёт доступ к премиум-стемам?». Дальше я буду использовать конкретные примеры — загрузку mp3 и постановку задач на обработку — но сам подход можно перенести на любые мультимедийные домены.

Откуда растут требования

  1. Треки и ассеты. Один трек может иметь несколько вариантов: mp3, wav, платные стемы. Нужны политики хранения (кто и когда может скачать, что удаляем автоматически).
  2. Тяжёлая обработка. Разделение на части, удаление вокала и прочие DSP-задачи не должны блокировать HTTP-запросы.
  3. Монетизация. Есть премиальные ассеты, к которым доступ открывается только после покупки, и бесплатные версии с ограниченным сроком.
  4. Наблюдаемость. Нужно понимать, что происходит с каждой задачей и файлом: когда был скачан, сколько живёт, где хранится.

Чтобы не тащить всё это в один сервис, я выделил несколько ограниченных контекстов.

Ограниченные контексты (bounded contexts)

КонтекстОтвечает заЧто хранит
TrackCatalogМетаданные трека, владельца, статусы публикацииtracks + аудит изменений
TrackAssetsБинарные файлы, провайдеры хранения, срок жизниТаблицу track_assets, работу с хранилищем и cron очистки
TrackProcessingПостановка и учёт DSP-задачОчереди, job'ы, статусы обработки
TrackIntelligenceML-наблюдения, рекомендацииМодели, фичи, аналитику

В рамках статьи я реализовал два первых контекста и заготовку для 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, не создавая жёстких связей между слоями.

Пример потока: скачивание ассета

  1. Контроллер вызывает TrackAssetsService.streamAsset(assetId).
  2. Сервис достаёт запись через репозиторий.
  3. Запись оборачивается в TrackAssetEntity. Если файл истёк или удалён, сервис сразу кидает NotFoundException.
  4. Если файл доступен, сервис создаёт readStream через StorageService.
  5. После успешного создания стрима репозиторий обновляет lastAccessedAt.

В итоге условия доступности сосредоточены в домене, логика чтения/записи — в слоях приложения и инфраструктуры, а контроллер получает готовый ответ.

Как масштабировать этот подход

  • Добавление новых форматов. Достаточно расширить enum в схеме и обновить TrackAssetEntity, не затрагивая очереди или cron.
  • Вынесение обработки. Можно оставить Nest для API, а инфраструктурный слой TrackProcessing переключить на внешний gRPC сервис. Application-слой не заметит разницы.
  • Новые контексты. Появится, например, CollaborationModule — просто создаём новую директорию с теми же слоями и подключаем в TracksModule.
  • Feature флаги. Инфраструктурный слой легко обернуть в ConfigService, чтобы включать обработку по регионам или тарифам.

Практический чеклист при внедрении

  1. Разбейте папку tracks/ на подмодули по контекстам.
  2. Выделите доменные сущности даже если они пока тонкие. Они станут точкой расширения.
  3. Сервис приложения должен быть тонким: никакого SQL/HTTP прямо внутри него.
  4. Инфраструктура — единственное место, где есть зависимость от Drizzle, BullMQ, Cron и т.д.
  5. Агрегируйте модули через TracksModule, чтобы наружу экспортировать только нужные сервисы.
  6. Покройте доменные сущности тестами, чтобы не словить регрессию при добавлении новых состояний.

Что даёт такая архитектура

  • Прозрачность. Любой разработчик сразу видит, где бизнес-правила, а где адаптеры.
  • Тестируемость. Domain тестируется без Nest и базы. Application тестируется через мок-интерфейсы. Infrastructure — интеграционными тестами.
  • Расширяемость. Добавить новый тип обработки = создать доменную сущность/хэндлер в processing/ и подключить новую очередь.
  • Готовность к микросервисам. Если станет тесно, достаточно вынести инфраструктуру и application в отдельный сервис, оставив общий домен в npm-пакете.

Заключение

Даже в монолите можно выстроить архитектуру, в которой разные команды пилят свои контексты, не мешая друг другу. Треки — идеальный пример: сегодня у нас базовая загрузка и cron очистки, завтра — сложные DSP пайплайны. Благодаря слоям Domain/Application/Infrastructure и модульной структуре мы заранее заложили основу для роста и не превратили проект в очередной «скрипт на миллион строк». Если вы чувствуете, что ваш TrackService выходит из-под контроля, начните хотя бы с выделения домена и инфраструктуры — и вы сразу увидите, как меняется скорость разработки.