Агрегаты в Domain-Driven-Design и C# | OTUS
Запланируйте обучение с выгодой в Otus!
-15% на все курсы до 22.11 Забрать скидку! →
Выбрать курс

Агрегаты в Domain-Driven-Design и C#

CSharp_Deep_17.2-5020-29c0ae.png

В этой статье хотелось бы рассказать о таком понятии, как агрегат в Domain- Driven-Design (DDD), а именно о его преимуществах в контексте транзакционности изменений и группировки бизнес-логики. Пожалуй, из всех так называемых тактических шаблонов в DDD этот часто является самым важным и трудным для понимания. Об агрегатах имеет смысл поговорить, упомянув также шаблон репозиторий.

Репозиторий и агрегат

Репозиторий — это очень популярный шаблон для реализации слоя доступа к данным в .NET-приложениях, даже если они не используют шаблоны DDD. Однако, изначально он рассматривался в контексте работы с объектной моделью предметной области именно в контексте DDD, для того чтобы получить из базы данных агрегат и работать с коллекцией агрегатов.

Агрегат — набор объектов предметной области, которые сохраняются в источнике данных (например, реляционной БД), используются совместно в логике приложения, часто имеют связи на уровне базы данных, а также имеют свойство меняться в рамках одной транзакции по бизнес-процессу; агрегат представлен корневой сущностью с ссылками на зависимые объекты. К зависимым объектам мы получаем доступ именно через корень агрегата. Вся бизнес-логика для работы с данными внутри агрегата происходит через корень.

Некоторые NoSQL базы можно вполне считать "агрегатоориентированными", например, документы в Mongo представляют из себя именно агрегатный формат хранения данных, транзакционность гарантируется на уровне одного документа. Более детально эта идея раскрыта в книге "NoSQL Distilled" Мартина Фаулера.

Теперь стоит рассмотреть в чем преимущества использования агрегатов при реализации бизнес-логики.

Задача

Давайте рассмотрим некоторую часть информационной системы для работы с клиентами, клиент имеет некоторый набор данных, несколько мест работы и контакты. Сценарий использования данных выглядит примерно так: мы находим нужного клиента по ФИО или создаем нового, переходим в его карточку, можем добавлять, удалять контакты, места работы и редактировать общие данные, при нажатии на кнопку "Сохранить" происходит сохранение всех данных, при добавлении и удалении контактов или мест работы происходит автоматическое сохранение всех данных клиента, если клиент новый, то все данные сохраняются только по кнопке.

Нужно описать эту структуру данных и реализовать CRUD. Рассмотрим только вариант создания нового клиента.

Сначала можно реализовать три таблицы Customers, Contacts и JobPlaces. Код классов для работы с этими таблицами будут выглядеть так:

/// <summary>
/// Клиент
/// </summary>
public class Customer
{
    /// <summary>
    /// Id
    /// </summary>
    public Guid Id { get; set; }

    /// <summary>
    /// Полное имя клиента
    /// </summary>
    public string FullName { get; set; }

    /// <summary>
    /// Канал привлечения (интернет-реклама, реклама на улице и т.д.)
    /// </summary>
    public AcquisitionChannel Channel { get; set; }

    /// <summary>
    /// Дата создания
    /// </summary>
    public DateTime CreatedDate { get; set; }

    /// <summary>
    /// Признак активности
    /// </summary>
    public bool IsActive { get; set; }
}

/// <summary>
/// Место работы
/// </summary>
public class JobPlace
{
    /// <summary>
    /// Id, уникальный идентификатор
    /// </summary>
    public Guid Id { get; set; }

    /// <summary>
    /// Описание места работы
    /// </summary>
    public string Description { get; set; }

    /// <summary>
    /// Дата начала работы
    /// </summary>
    public DateTime StartDate { get; set; }

    /// <summary>
    /// Дата окончания работы
    /// </summary>
    public DateTime? CompletionDate  { get; set; }

    /// <summary>
    /// Идентификатор клиента
    /// </summary>
    public Guid CustomerId { get; set; }
}

/// <summary>
/// Контакт клиента
/// </summary>
public class Contact
{
    /// <summary>
    /// Id, уникальный идентификатор
    /// </summary>
    public Guid Id { get; set; }

    /// <summary>
    /// Адрес электронной почты
    /// </summary>
    public string Email { get; set; }

    /// <summary>
    /// Телефон
    /// </summary>
    public string Phone { get; set; }

    /// <summary>
    /// Id клиента
    /// </summary>
    public Guid CustomerId { get; set; }
}

Для работы с базой данных создадим Generic-репозиторий с интерфейсом ниже, так как базового набора операций для каждой таблицы хватит:

public interface IRepository<T>
{
    Task<IEnumerable<T>> GetAllAsync();

    Task<T> GetByIdAsync(Guid id);

    Task<Guid> AddAsync(T entity);

    Task UpdateAsync(T entity);

    Task DeleteAsync(T entityId);
}

В реализации будет код для работы с SQL-базой данных, это могут быть SQL-вызовы базы данных или вызовы хранимых процедур, функций или представлений.

Мы не рассматриваем вариант с ORM для более четкого понимания изначальной идеи репозиториев и агрегатов. Важно заметить, что этот интерфейс говорит нам о том, что вызывая операции Add, Update, Delete, мы предполагаем, что произойдет некоторое целостное изменение данных в коллекции объектов, которую представляет репозиторий и в базе данных также, то есть операции транзакционны.

Чтобы реализовать задачу нужно в методе сохранения клиента реализовать обновление или вставку в соответствующие таблицы, но по условию все данные сохраняются вместе, то есть обновление таблиц нужно производить в транзакции, но, как было сказано выше, методы репозиториев по умолчанию выполняют изменения в таблицах в отдельных транзакциях, так как внутри мы просто делаем Insert или Update-операцию на SQL, в этом случае нужно будет сделать механизм для управления транзакцией в методе сохранения, что уже неявным образом вносит зависимость от БД в код приложения.

Ниже добавляем реализацию через TransactionScope, можно также реализовать свою абстракцию вида IUnitOfWork, которая будет использовать TransactionScope.

public async Task<CustomerCreatedDto> CreateCustomerAsync(
CreateCustomerDto customerDto)
{
    if (customerDto == null)
        throw new ArgumentNullException(nameof(customerDto));

    using var transactionScope = new TransactionScope();
    var customer = new Customer()
    {
        Id = Guid.NewGuid(),
        Channel = customerDto.Channel,
        CreatedDate = DateTime.Now,
        FullName = customerDto.FullName,
        IsActive = true,
    };

    var customerId = await _customerRepository.AddAsync(customer);

    var jobPlaces = customerDto.JobPlaces?.Select(x => new JobPlace()
    {
        Id = Guid.NewGuid(),
        CustomerId = customerId,
        Description = x.Description,
        StartDate = x.StartDate,
        CompletionDate = x.CompletionDate
    }).ToList();

    if (jobPlaces != null && jobPlaces.Any())
    {
        foreach (var jobPlace in jobPlaces)
        {
            await _jobPlaceRepository.AddAsync(jobPlace);
        }
    }

    var contacts = customerDto.Contacts?.Select(x => new Contact()
    {
        Id = Guid.NewGuid(),
        CustomerId = customerId,
        Email = x.Email,
        Phone = x.Phone
    }).ToList();

    if (contacts != null && contacts.Any())
    {
        foreach (var contact in contacts)
        {
            await _contactRepository.AddAsync(contact);
        }
    }

    transactionScope.Complete();

    return new CustomerCreatedDto()
    {
        Id = customer.Id
    };
}

Примерно такой код можно встретить во многих .NET-приложениях, он выглядит довольно громоздко и подвержен ошибкам в поддержке, например, использование инициализаторов, а не конструкторов ведет к тому, что логика создания объектов может быть разбросана по многим местам, это создает вероятность ошибок, явное использование транзакционности делает неочевидную связь со слоем доступа к данным, могут быть проблемы с юнит-тестами.

На первый взгляд, это вполне логичный подход, как и использование репозитория на таблицу. Если мы хотим добавить новое место работы клиенту где-то еще, то мы просто сделаем вставку в таблицу JobPlaces через нужный репозиторий.

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

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

В большинстве случаев нам не придется отдельно от формы клиента добавлять ему места работы или контакты, да и в принципе это действие скорее всего не имеет смысла в контексте предметной области, так как сама структура данных говорит нам, что они логически связаны.

Для того чтобы это реализовать, нужно рассматривать клиента, его контакты и места работы не как отдельные таблицы в БД, а как целостный агрегат, который должен изменяться вместе, — это позволит нам упростить код.

Ниже код для агрегата клиента:

/// <summary>
/// Клиент
/// </summary>
public class Customer
{
 public Guid Id { get; set; }

 public string FullName { get; set; }

 public AcquisitionChannel Channel { get; set; }

 public DateTime CreatedDate { get; set; }

 public bool IsActive { get; set; }

 public List<JobPlace> JobPlaces { get; set; }

 public List<Contact> Contacts { get; set; }
}

На первый взгляд, почти ничего не изменилось, но у нас появились поля для доступа коллекциям связанных объектов.

Нужно обратить внимание, что это очень упрощенный пример модели клиента, ее можно назвать "анемичной", так как по сути она не содержит бизнес-логики, объектов- значений, как должно быть в правильном варианте реализации для DDD, именно такой формат объекта модели можно увидеть во многих .NET-приложениях, особенно, использующих ORM, так как ORM неявно подталкивает нас "агрегатному" формату описания модели данных, такая модель не позволит сразу воспользоваться всеми преимуществами "агрегатного" формата описания бизнес-логики, но позволит рассмотреть преимущества для реализации транзакционных изменений.

Реализация для агрегата в "анемичной" модели

Ниже типичный пример редактирования клиента в такой схеме.

public async Task<CustomerCreatedDto> CreateCustomerAsync(
CreateCustomerDto customerDto)
{
    if (customerDto == null)
        throw new ArgumentNullException(nameof(customerDto));

    var customer = new Customer()
    {
        Id = Guid.NewGuid(),
        Channel = customerDto.Channel,
        CreatedDate = DateTime.Now,
        FullName = customerDto.FullName,
        IsActive = true,
        JobPlaces = customerDto.JobPlaces?.Select(x => new JobPlace()
        {
            Id = Guid.NewGuid(),
            Description = x.Description,
            StartDate = x.StartDate,
            CompletionDate = x.CompletionDate
        }).ToList(),
        Contacts = customerDto.Contacts?.Select(x => new Contact()
        {
            Id = Guid.NewGuid(),
            Email = x.Email,
            Phone = x.Phone
        }).ToList()
    };

    await _customerRepository.AddAsync(customer);

    return new CustomerCreatedDto()
    {
        Id = customer.Id
    };
}

Мы упростили код транзакционных изменений, теперь мы точно знаем, что сохранение произойдет в транзакции, во всяком случае мы ждем этого от репозитория. В итоге мы сосредоточились на создании объекта в правильном состоянии, в более сложном примере код был больше за счет различных проверок, получения дополнительных данных и т. д.

Реализация для агрегата с логикой

Код можно сделать еще проще, передав логику работы с данными клиента в код класса, таким образом мы сделаем код действительно объектно-ориентированным и получим полноценный агрегат в формате DDD:

/// <summary>
/// Клиент
/// </summary>
public class Customer
{
 private readonly List<Contact> _contacts = new List<Contact>();
 private readonly List<JobPlace> _jobPlaces = new List<JobPlace>();

 public Guid Id { get; private set; }

 public string FullName { get; private  set; }

 public AcquisitionChannel Channel { get; private  set; }

 public DateTime CreatedDate { get; private  set; }

 public bool IsActive { get; private  set; }

 public IEnumerable<JobPlace> JobPlaces => _jobPlaces.ToList();

 public IEnumerable<Contact> Contacts => _contacts.ToList();

 public Customer(string fullName, AcquisitionChannel acquisitionChannel, 
    DateTime createdDate)
    {
        Id = Guid.NewGuid();
        FullName = fullName;
        Channel = acquisitionChannel;
        CreatedDate = createdDate;
        IsActive = true;
    }

    public void AddJobPlaces(List<JobPlace> jobPlaces)
    {
        if (jobPlaces != null && jobPlaces.Any())
        {
            _jobPlaces.AddRange(jobPlaces);  
        }
    }

    public void AddContacts(List<Contact> contacts)
    {
        if (contacts != null && contacts.Any())
        {
            _contacts.AddRange(contacts);  
        }
    }
}

Мы добавили конструкторы для всех типов, чтобы поместить логику инициализации объекта в одно место и добавили методы заполнения коллекций, также коллекции не являются редактируемыми через интерфейс класса, таким образом вся логика изменения агрегата будет внутри корня агрегации

Код создания клиента ниже:

public async Task<CustomerCreatedDto> CreateCustomerAsync(
CreateCustomerDto customerDto)
{
    if (customerDto == null)
        throw new ArgumentNullException(nameof(customerDto));

    var customer = new Customer(customerDto.FullName, 
        customerDto.Channel, DateTime.Now);

    var jobPlaces = customerDto.JobPlaces?
        .Select(x => new JobPlace(customer, x.Description, x.StartDate, x.CompletionDate ))
        .ToList();
    customer.AddJobPlaces(jobPlaces);

    var contacts = customerDto.Contacts?
        .Select(x => new Contact(customer, x.Email, x.Phone))
        .ToList();
    customer.AddContacts(contacts);

    await _customerRepository.AddAsync(customer);

    return new CustomerCreatedDto()
    {
        Id = customer.Id
    };
}

Еще один шаг, который может помочь сделать этот код еще более качественным, заключается в вынесении кода заполнения агрегата по DTO в отдельный класс-фабрику.

public static class CustomerFactory
{
    public static Customer CreateCustomer(CreateCustomerDto customerDto)
    {
        var customer = new Customer(customerDto.FullName, 
        customerDto.Channel, DateTime.Now);

        var jobPlaces = customerDto.JobPlaces?
            .Select(x => new JobPlace(customer, 
            x.Description, x.StartDate,     x.CompletionDate ))
            .ToList();
        customer.AddJobPlaces(jobPlaces);

        var contacts = customerDto.Contacts?
            .Select(x => new Contact(customer, x.Email, x.Phone))
            .ToList();
        customer.AddContacts(contacts);

        return customer;
    }
}

После рефакторинга код изначального сценария будет выглядеть так:

public async Task<CustomerCreatedDto> CreateCustomerAsync(CreateCustomerDto customerDto)
{
    if (customerDto == null)
        throw new ArgumentNullException(nameof(customerDto));

    var customer = CustomerFactory.CreateCustomer(customerDto);

    await _customerRepository.AddAsync(customer);

    return new CustomerCreatedDto()
    {
        Id = customer.Id
    };
}

Также агрегат можно улучшить, при необходимости выделив новые объекты-значения, например, если бы поле FullName имело некоторую логику по формированию имени, то его можно было сделать отдельным типом и поместить эту логику туда.

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

Иногда может потребоваться сделать транзакцию между несколькими агрегатами, возможно, в этом случае мы имеем дело с одним агрегатом и речь идет о неверной декомпозиции или транзакционность для этой операции не является обязательной, тогда полезно задать себе следующие вопросы: "А что бы было если бы данные агрегатов были в разных базах и сервисах? Является ли проблемой то, что данные другого агрегата изменяться в рамках отдельной транзакции?" Если это не является проблемой, то транзакция между агрегатами не нужна, но если они хранятся в одной базе данных, то для уменьшения числа возможных проблем стоит реализовать изменения через единую транзакцию, но скорее всего это будет почти единичный случай, иначе это не должны быть разные агрегаты.

Выводы

Мышление в формате агрегатов, а не таблиц, ведет к лучшей модульности операций изменения данных в приложениях, то есть нашей бизнес-логики, код становится более структурированным относительно тразакционных изменений на всех уровнях использования данных, так как и формы пользовательского интерфейса, и API будут неявно следовать этим правилам.

Конечно, взамен мы получаем некоторые сложности с чтением данных в сложных запросах, — эта проблема решается через полноценный CQRS (command-query responsibility segregation) или через реализацию чтения более производительным способом в реализации репозитория, например, чтобы некоторые методы репозитория возвращали не агрегаты, а просто DTO-объекты для конкретной операции чтения. Еще можно получить некоторые проблемы при высокой нагрузке на запись, так как использование агрегатов часто ведет к блокировке нескольких таблиц, но это можно компенсировать через CQRS, убрав операции чтения, если же по логике мы можем писать в разные таблицы, то, возможно, они не являются частью одного агрегата и изначально была сделана неверная декомпозиция. Эти проблемы являются не слишком большой платой за более качественный продукт, который имеет архитектуру, продиктованную требованиями бизнеса и лучше подходит для долгосрочной поддержки сложных сценариев, чем реализация без использования агрегатов.

Не пропустите новые полезные статьи!

Спасибо за подписку!

Мы отправили вам письмо для подтверждения вашего email.
С уважением, OTUS!

Автор
1 комментарий
0

А можно ссылку на проект? Хочется полностью посмотреть код, меня очень смущает, что вы в домен для создания сущности передаёте DTO

Для комментирования необходимо авторизоваться
Популярное
Сегодня тут пусто
Черная пятница в Otus! ⚡️
Скидка 15% на все курсы до 22.11 →