Что такое доменный слой (Domain Layer) в архитектуре приложения?

Ответ

Доменный слой (Domain Layer) — это центральный, наиболее важный слой в архитектурах, основанных на предметной области (например, Domain-Driven Design, Чистая Архитектура). Он инкапсулирует ключевую бизнес-логику, правила и сущности приложения, полностью абстрагируясь от деталей инфраструктуры (база данных, UI, фреймворки) и внешних сервисов.

Его основная цель: Чёткое выражение бизнес-концепций и правил в коде, чтобы они были понятны как разработчикам, так и экспертам предметной области.

Ключевые строительные блоки доменного слоя:

  • Сущности (Entities): Объекты, имеющие уникальную идентичность, которая сохраняется на протяжении всего жизненного цикла (например, Customer, Order, Invoice). Они содержат как данные, так и поведение (методы, инкапсулирующие бизнес-правила).
  • Объекты-значения (Value Objects): Неизменяемые объекты, не имеющие идентификатора и определяемые исключительно своими атрибутами (например, Address, Money, Color). Два Value Object с одинаковыми значениями считаются равными.
  • Агрегаты (Aggregates): Кластеры связанных сущностей и объектов-значений, которые рассматриваются как единое целое для операций изменения данных. У агрегата есть корень (Aggregate Root) — единственная сущность, через которую происходит всё внешнее взаимодействие с агрегатом.
  • Доменные сервисы (Domain Services): Классы, содержащие бизнес-логику, которая по своей природе не принадлежит ни одной конкретной сущности или объекту-значению (например, сложный расчёт, затрагивающий несколько агрегатов).
  • Репозитории (Repository Interfaces): Абстракции (интерфейсы), определяющие контракты для доступа к агрегатам. Их реализация находится в слое инфраструктуры.
  • События домена (Domain Events): Способ информирования других частей системы о значимых изменениях, произошедших внутри домена.

Пример сущности (Aggregate Root) на C#:

public class Order : Entity<int> // Entity с Id типа int
{
    // Внутреннее состояние. Коллекция защищена от прямого изменения.
    private readonly List<OrderLine> _lines = new();
    public CustomerId CustomerId { get; private set; } // Value Object для Id
    public OrderStatus Status { get; private set; }
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    // Публичные методы — это единственный способ изменить состояние.
    // Они инкапсулируют бизнес-правила.
    public void AddItem(Product product, int quantity)
    {
        // Правило 1: Нельзя добавлять товары в завершённый заказ.
        if (Status == OrderStatus.Completed || Status == OrderStatus.Cancelled)
            throw new InvalidOrderOperationException("Cannot modify a completed/cancelled order.");

        // Правило 2: Количество должно быть положительным.
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be greater than zero.", nameof(quantity));

        // Правило 3: Если товар уже есть в заказе, увеличиваем количество.
        var existingLine = _lines.FirstOrDefault(l => l.ProductId == product.Id);
        if (existingLine != null)
        {
            existingLine.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(new OrderLine(product.Id, product.Price, quantity));
        }
    }

    public void MarkAsCompleted()
    {
        // Правило: Нельзя завершить пустой заказ.
        if (!_lines.Any())
            throw new InvalidOrderOperationException("Cannot complete an empty order.");
        Status = OrderStatus.Completed;
        // Может генерировать Domain Event: new OrderCompletedEvent(this.Id)
    }
}

Преимущества выделенного доменного слоя:

  • Тестируемость: Логику можно тестировать изолированно, без БД или веб-сервера.
  • Сохраняемость: Бизнес-правила не "растворяются" в коде инфраструктуры.
  • Гибкость: Слой инфраструктуры (способ хранения данных) можно заменить, не затрагивая ядро бизнес-логики.
  • Ясность: Код напрямую отражает бизнес-концепции, упрощая коммуникацию в команде.

Ответ 18+ 🔞

Давай разжую тебе эту тему, как будто объясняю за бутылкой пива после работы, а не на очередном скучном митапе про архитектуру.

Смотри, доменный слой — это, по сути, святая святых твоего приложения. Его мозги, его характер, его внутренние тараканы и все бизнес-правила, от которых у заказчика дергается глаз. Это то, ради чего всё и затевается. Не база данных, не красивый интерфейс, а вот эта самая логика: как считать деньги, как формировать заказ, когда можно отменить, а когда уже поздно. Если представить приложение как человека, то домен — это его личность, а всё остальное (база, апишки, кнопочки) — это просто одежда, руки-ноги и рот, чтобы эту личность выражать.

Зачем его выделять в отдельный слой? Да чтобы не превратить проект в одно большое спагетти, где логика "кликнули — сохранилось в базу" размазана по двадцати файлам, и через полгода никто не понимает, почему скидка применяется два раза. Ты же не хочешь, чтобы твой код через год вызывал только один вопрос: "Какого хуя?".

Из чего этот слой состоит? Да из понятных штук:

  • Сущности (Entities): Это главные герои. Заказ, Пользователь, Счёт. У них есть ID, они живут долго, меняются, но остаются собой. Они не просто мешки с данными — они умеют что-то делать. Заказ.ДобавитьТовар() — и внутри этого метода сидит проверка, можно ли вообще это сделать.
  • Объекты-значения (Value Objects): Это неизменяемые вспомогательные персонажи. Адрес, Деньги, Цвет. У них нет ID. Два адреса с улицей "Ленина, 1" — это один и тот же адрес, пофиг, что объекты в памяти разные. Их создали, использовали и выкинули. Простые и надежные.
  • Агрегаты (Aggregates): Это такая семейная ячейка. Допустим, Заказ (корень агрегата) и все его ПозицииЗаказа. Менять позиции можно ТОЛЬКО через методы самого заказа. Никто снаружи не может взять и просто так удалить позицию, минуя все проверки заказа. Это чтобы целостность не нарушить. Агрегат — это единица сохранности и согласованности.
  • Доменные сервисы (Domain Services): Иногда логика такая здоровая, что её некуда приткнуть. Не принадлежит она ни одному заказу или пользователю в отдельности. Например, сложный расчёт комиссии, который需要考虑 кучу факторов. Вот для этой логики, которая "висит в воздухе", и делают сервисы. Но злоупотреблять — не надо, а то получится анархия.
  • Интерфейсы репозиториев (Repository Interfaces): Это обещания. Домен говорит: "Мне нужно уметь сохранять и загружать заказы, вот такой контракт". А как именно это будет делать PostgreSQL, MongoDB или твоя память — ему, блядь, похуй. Реализацию этих интерфейсов пишешь уже в другом месте, в слое инфраструктуры.
  • События домена (Domain Events): Способ вежливо крикнуть на всю систему: "Эй, народ, у меня тут заказ завершился, может, кому надо?". Чтобы другие части приложения (например, сервис нотификаций) могли отреагировать, не лезя в кишки домену.

Вот смотри на пример, как это может выглядеть в коде. Сущность Заказ, которая сама за собой следит:

public class Order : Entity<int> // Сущность с целочисленным ID
{
    // Состояние спрятано, трогать его снаружи — низзя.
    private readonly List<OrderLine> _lines = new();
    public CustomerId CustomerId { get; private set; } // Это Value Object для ID заказчика
    public OrderStatus Status { get; private set; }
    // Наружу отдаём только для чтения, чтобы никто не накосячил
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();

    // Единственный способ изменить заказ — вызвать его метод.
    // Здесь и живут все бизнес-правила, как в крепости.
    public void AddItem(Product product, int quantity)
    {
        // Правило 1: В завершённый или отменённый заказ хуй что добавишь.
        if (Status == OrderStatus.Completed || Status == OrderStatus.Cancelled)
            throw new InvalidOrderOperationException("Нельзя изменять завершённый или отменённый заказ.");

        // Правило 2: Количество должно быть нормальным, а не нулём или минусом.
        if (quantity <= 0)
            throw new ArgumentException("Количество должно быть больше нуля, гений.", nameof(quantity));

        // Правило 3: Если товар уже есть — увеличиваем количество, а не создаём новую строку.
        var existingLine = _lines.FirstOrDefault(l => l.ProductId == product.Id);
        if (existingLine != null)
        {
            existingLine.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(new OrderLine(product.Id, product.Price, quantity));
        }
    }

    public void MarkAsCompleted()
    {
        // Правило: Пустой заказ завершить нельзя, это же пиздец.
        if (!_lines.Any())
            throw new InvalidOrderOperationException("Нельзя завершить пустой заказ.");
        Status = OrderStatus.Completed;
        // Тут можно нагенерить Domain Event, типа OrderCompletedEvent(this.Id), чтобы все узнали.
    }
}

И зачем весь этот цирк? А вот зачем:

  • Тестируемость — овердохуищная. Ты можешь взять этот класс Order, создать его в памяти, вызывать методы и проверять, как он себя ведёт. Никаких баз данных, HTTP-запросов, конфигов. Чистая логика. Это быстро и надёжно.
  • Сохраняемость. Бизнес-правила не потеряются где-то между контроллером и SQL-запросом. Они здесь, в одном месте, и с ними ничего не случится, даже если ты завтра переедешь с Entity Framework на Dapper.
  • Гибкость. Захотел сменить базу данных? Пожалуйста! Реализуй интерфейсы репозиториев под новую базу в слое инфраструктуры, а доменный слой даже не чихнёт. Он про это ничего не знает и знать не хочет.
  • Ясность. Открываешь код сущности — и сразу видишь, как работает бизнес. "А, AddItem бросает исключение, если заказ завершён. Значит, так и задумано". Это упрощает жизнь и тебе, и новичкам в проекте, и даже, прости господи, общению с аналитиками.

Короче, если не хочешь через год поддерживать монстра, в котором всё перемешано и любое изменение вызывает волну непредсказуемых багов — учись выделять это ядро. Сначала кажется, что это овердизайн для маленького проекта, но поверь, привычка писать так — это как умение класть кирпичи ровно. Окупается всегда.