Перейти к содержанию

ООАП. Лекция 8 — Шаблоны корпоративных приложений

Конспект лектора. Источник раздела — Мартин Фаулер, «Шаблоны корпоративных приложений» (Patterns of Enterprise Application Architecture, 2002). Это тот же автор, что и «UML. Основы», и та же структура статьи, что у GoF: назначение, как работает, когда применять. Каталог доступен и онлайн: martinfowler.com/eaaCatalog. Код — C# и EF Core, диаграммы — Mermaid.


Блок 1. Что такое корпоративное приложение и откуда каталог

Признаки корпоративного приложения

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

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

И сразу оговорка, с которой Фаулер начинает книгу: корпоративные приложения бывают очень разными — розничный магазин с огромным числом пользователей и несложной логикой, система учёта аренды со сложнейшими правилами и сотней пользователей, простой учёт расходов на десяток сотрудников. Решения для них разные, поэтому у него «звенит будильник», когда кто-то говорит «всегда делай так». Наш курс придерживается той же позиции: интерес не в правилах, а в умении взвешивать альтернативы.

Почему это отдельный каталог

GoF описывает приёмы уровня классов и объектов. Корпоративные приложения породили свой слой типовых решений: как разложить систему на слои, как связать объекты с реляционной базой, как оформить границу приложения. Фаулер собрал их в 2002 году в каталог из полусотни шаблонов; половина из них с тех пор встроена в ORM и фреймворки — но именно поэтому их надо знать: вы ими уже пользуетесь, иногда не подозревая.

Сегодня разбираем четыре: Repository, Unit of Work, Active Record, Service Layer, — плюс Data Mapper для контраста.


Блок 2. Карта слоёв и паттернов источника данных

Три слоя по Фаулеру

%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#c9a227'}}}%%
flowchart TB
    P["Слой представления<br/>UI, REST, консоль"]
    D["Слой домена<br/>бизнес-логика"]
    S["Слой источника данных<br/>БД, очереди, внешние API"]
    P --> D --> S
Исходник диаграммы
flowchart TB
    P["Слой представления<br/>UI, REST, консоль"]
    D["Слой домена<br/>бизнес-логика"]
    S["Слой источника данных<br/>БД, очереди, внешние API"]
    P --> D --> S

Правило: представление знает о домене, домен — об источнике данных (а лучше — только об абстракции над ним), обратных стрелок нет. Это то же самое, что мы разбирали в лекции 2 про луковую архитектуру, только словами Фаулера и на двадцать лет раньше.

Два способа организовать домен

Подход Суть Когда уместен
Transaction Script сценарий = процедура: получил данные, применил правила, сохранил простая логика, мало правил, короткая жизнь проекта
Domain Model сеть объектов с поведением и инвариантами; один объект на одну запись логика сложная и меняется
Table Module один класс на таблицу, один экземпляр на всю таблицу; работает с набором записей средний случай; исторически — стиль COM и .NET с DataSet

Table Module — золотая середина: структуры больше, чем у процедур, дублирование убирать легче, но тонких приёмов доменной модели (наследование, стратегии, остальные паттерны GoF) уже не применить. Фаулер прямо отмечает, что этот стиль вырос из платформ Microsoft, где интерфейс умеет отображать результат SQL-запроса напрямую.

Про выбор: Transaction Script прост и понятен всем, отлично ложится на шлюзы таблиц и строк, и границы транзакции в нём очевидны; но с ростом сложности появляется дублирование, которое трудно и заметить, и вынести. Domain Model дороже на входе — новичку нужны месяцы, чтобы привыкнуть, — зато сложная логика в ней организуется куда лучше, а плата уходит в отображение на базу.

⚠️ Оговорка, которую делает сам Фаулер: знаменитый график «сложность против трудозатрат» он называет ненаучным — оси не измерены, это его ощущение, а не данные. Так что выбор делается по ожидаемой сложности правил и опыту команды, а не по картинке.

Паттерны источника данных

Паттерн Идея
Table Data Gateway один объект-шлюз на таблицу, набор SQL-методов
Row Data Gateway объект на строку, только доступ к данным, без логики
Active Record объект на строку, доступ к данным плюс доменная логика
Data Mapper отдельный слой, переносящий данные между объектами и БД; объекты о базе не знают

Плюс два паттерна, без которых первые четыре не живут: Unit of Work (что изменилось за транзакцию) и Identity Map (один объект на одну строку в пределах сеанса). И над всем этим — Repository как коллекциеподобный доступ к домену.


Блок 3. Active Record

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

Проще говоря: класс = таблица, экземпляр = строка, у объекта есть Save, Delete, Find — и здесь же бизнес-правила.

%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#c9a227'}}}%%
classDiagram
    class Customer {
        +int Id
        +string Email
        +decimal Discount
        +Find(int id)$ Customer
        +Save() void
        +Delete() void
        +IsEligibleForBonus() bool
    }
    note for Customer "Одна и та же сущность:<br/>и строка таблицы, и бизнес-объект"
Исходник диаграммы
classDiagram
    class Customer {
        +int Id
        +string Email
        +decimal Discount
        +Find(int id)$ Customer
        +Save() void
        +Delete() void
        +IsEligibleForBonus() bool
    }
    note for Customer "Одна и та же сущность:<br/>и строка таблицы, и бизнес-объект"
public class Customer                                   // Active Record
{
    public int Id { get; private set; }
    public string Email { get; set; } = "";
    public decimal TotalSpent { get; set; }

    public static Customer? Find(int id) { /* SELECT ... WHERE Id = @id */ return null; }

    public void Save()                                  // объект сам себя сохраняет
    {
        if (Id == 0) { /* INSERT */ } else { /* UPDATE */ }
    }

    public bool IsEligibleForBonus() => TotalSpent > 10_000;   // и логика тут же
}

var customer = Customer.Find(42)!;
customer.Email = "new@example.com";
customer.Save();

Когда применять (по Фаулеру). Логика простая: создание, чтение, обновление, удаление плюс немного правил. Структура объекта близка к структуре таблицы — «изоморфная схема».

Плата. Доменный объект намертво связан со схемой БД: изменить одно без другого нельзя. Как только доменная модель начинает расходиться с таблицами (наследование, объекты-значения, агрегаты), Active Record ломается. Тестировать логику без базы почти невозможно — это нарушение SRP из лекции 2: у класса две причины меняться, бизнес-правила и схема хранения.

Где встречается. Ruby on Rails (паттерн там дал имя целой библиотеке), Django ORM, Laravel Eloquent, в .NET — Castle ActiveRecord и подход «сущность с методами доступа». Заметьте закономерность: Active Record доминирует в динамических языках, где схема подхватывается из базы автоматически.


Блок 4. Data Mapper как контраст

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

%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#c9a227'}}}%%
classDiagram
    direction LR
    class Customer {
        +string Email
        +Money TotalSpent
        +bool IsEligibleForBonus()
    }
    class CustomerMapper {
        +Find(int id) Customer
        +Insert(Customer c) void
        +Update(Customer c) void
    }
    class Database
    CustomerMapper ..> Customer : создаёт и заполняет
    CustomerMapper ..> Database : SQL
    note for Customer "Ничего не знает ни о БД,<br/>ни о преобразователе"
Исходник диаграммы
classDiagram
    direction LR
    class Customer {
        +string Email
        +Money TotalSpent
        +bool IsEligibleForBonus()
    }
    class CustomerMapper {
        +Find(int id) Customer
        +Insert(Customer c) void
        +Update(Customer c) void
    }
    class Database
    CustomerMapper ..> Customer : создаёт и заполняет
    CustomerMapper ..> Database : SQL
    note for Customer "Ничего не знает ни о БД,<br/>ни о преобразователе"

Ключевое различие с Active Record — направление знания. В Active Record объект знает о базе. В Data Mapper объект не знает о ней ничего, а знание сосредоточено в отдельном слое. Это позволяет доменной модели и схеме БД развиваться независимо: объекты-значения, наследование, агрегаты, денормализация — всё это ложится на маппер, а не на модель.

Плата. Дополнительный слой, который надо писать и поддерживать. Именно поэтому Data Mapper почти всегда берут готовым: EF Core, NHibernate, Hibernate, SQLAlchemy — это реализации Data Mapper, причём с Unit of Work и Identity Map внутри.

Подробное сравнение двух подходов — в материале для самостоятельной работы по этому разделу.


Блок 5. Repository

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

Ключевая метафора Фаулера: репозиторий ведёт себя как коллекция доменных объектов в памяти, а не как «класс доступа к БД». Клиент рассуждает так, будто все объекты уже загружены: orders.Add(order), orders.FindOverdue(). Где они лежат физически — не его дело.

Второй важный тезис из книги: клиент формулирует запрос декларативно — спецификацией условий, а не SQL. Отсюда родство с шаблоном Query Object: репозиторий часто строится поверх него, и в современных ORM эту роль играет IQueryable. И третий: репозиторий поддерживает одностороннюю зависимость домена и слоя отображения данных — ровно то направление, о котором говорил DIP.

%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#c9a227'}}}%%
classDiagram
    direction LR
    class IOrderRepository {
        <<interface>>
        +GetById(Guid id) Order
        +FindOverdue(DateTime at) IReadOnlyList~Order~
        +Add(Order order) void
        +Remove(Order order) void
    }
    class EfOrderRepository
    class InMemoryOrderRepository
    class OrderService
    IOrderRepository <|.. EfOrderRepository
    IOrderRepository <|.. InMemoryOrderRepository
    OrderService --> IOrderRepository : зависит от абстракции
    EfOrderRepository ..> DbContext
Исходник диаграммы
classDiagram
    direction LR
    class IOrderRepository {
        <<interface>>
        +GetById(Guid id) Order
        +FindOverdue(DateTime at) IReadOnlyList~Order~
        +Add(Order order) void
        +Remove(Order order) void
    }
    class EfOrderRepository
    class InMemoryOrderRepository
    class OrderService
    IOrderRepository <|.. EfOrderRepository
    IOrderRepository <|.. InMemoryOrderRepository
    OrderService --> IOrderRepository : зависит от абстракции
    EfOrderRepository ..> DbContext
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> FindOverdueAsync(DateTime at, CancellationToken ct = default);
    void Add(Order order);
    void Remove(Order order);
}

public sealed class EfOrderRepository : IOrderRepository
{
    private readonly ShopDbContext _db;
    public EfOrderRepository(ShopDbContext db) => _db = db;

    public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
        _db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);

    public Task<IReadOnlyList<Order>> FindOverdueAsync(DateTime at, CancellationToken ct = default) =>
        _db.Orders.Where(o => o.DueDate < at && o.Status == OrderStatus.Paid)
                  .ToListAsync(ct)
                  .ContinueWith(t => (IReadOnlyList<Order>)t.Result, ct);

    public void Add(Order order) => _db.Orders.Add(order);      // не Save! см. Unit of Work
    public void Remove(Order order) => _db.Orders.Remove(order);
}

Что даёт. Домен и прикладной слой не знают о технологии доступа к данным (это DIP из лекции 1 на уровне слоёв); запросы получают имена из языка предметной области (FindOverdue вместо Where(...) в пяти местах); реализацию можно подменить в тестах.

Три правила, которые я буду спрашивать.

  1. Репозиторий работает с корнем агрегата. IOrderLineRepository — почти всегда ошибка проектирования (лекция по моделированию предметной области).
  2. Репозиторий не сохраняет. Add помещает объект в коллекцию; фиксация изменений — дело Unit of Work.
  3. Методы называются по смыслу, а не по механике: FindOverdue, а не GetByStatusAndDate.

Главный спор вокруг репозитория. Обобщённый IRepository<T> со Where(Expression<...>) не даёт ничего: он повторяет DbSet<T>, протекает деталями EF наружу и мешает оптимизировать запросы. Позиция, которую я считаю правильной и которую надо уметь защищать: либо репозиторий специфичен для агрегата и говорит на языке домена, либо его не должно быть вовсе и прикладной слой работает с DbContext напрямую (DbContext сам уже реализует Repository + Unit of Work). Промежуточный вариант — «обобщённый репозиторий на всё» — худший из трёх.


Блок 6. Unit of Work

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

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

%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#c9a227'}}}%%
sequenceDiagram
    autonumber
    participant S as OrderService
    participant R as IOrderRepository
    participant U as IUnitOfWork
    participant DB as База данных

    S->>R: GetById(id)
    R-->>S: order (отслеживается)
    S->>S: order.Pay(method)
    S->>R: Add(newInvoice)
    Note right of U: изменения накоплены,<br/>но в базу ещё не ушли
    S->>U: SaveChangesAsync()
    U->>DB: BEGIN TRANSACTION
    U->>DB: UPDATE orders / INSERT invoices
    U->>DB: COMMIT
    U-->>S: количество записей
Исходник диаграммы
sequenceDiagram
    autonumber
    participant S as OrderService
    participant R as IOrderRepository
    participant U as IUnitOfWork
    participant DB as База данных

    S->>R: GetById(id)
    R-->>S: order (отслеживается)
    S->>S: order.Pay(method)
    S->>R: Add(newInvoice)
    Note right of U: изменения накоплены,<br/>но в базу ещё не ушли
    S->>U: SaveChangesAsync()
    U->>DB: BEGIN TRANSACTION
    U->>DB: UPDATE orders / INSERT invoices
    U->>DB: COMMIT
    U-->>S: количество записей
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

public sealed class PlaceOrderHandler
{
    private readonly IOrderRepository _orders;
    private readonly IUnitOfWork _uow;

    public async Task<Guid> HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
    {
        var order = Order.Create(cmd.Lines);      // доменная логика
        _orders.Add(order);                       // изменение зафиксировано в списке
        await _uow.SaveChangesAsync(ct);          // одна транзакция на всю операцию
        return order.Id;
    }
}

Что решает.

  • Атомарность бизнес-операции. Резерв товара, списание бонусов и создание заказа либо происходят вместе, либо не происходят.
  • Меньше обращений к базе. Изменения группируются, а не пишутся по одному.
  • Конкурентный доступ. Здесь же живёт оптимистическая блокировка: версия строки (rowversion, xmin) проверяется при записи, и конфликт превращается в исключение, а не в тихую потерю данных.

Кто отвечает за границу транзакции. Не репозиторий и не домен, а прикладной слой — обработчик сценария. Правило: одна бизнес-операция — один вызов SaveChanges. Если в сценарии их два, спросите себя, что произойдёт при падении между ними (и вспомните диаграмму последовательности из лекции 4 с компенсацией).

Реализации. Явная (свой IUnitOfWork с реестрами новых, изменённых и удалённых объектов) и неявная (ORM отслеживает изменения сам). Классический вопрос: как ORM узнаёт, что объект изменился? Ответ — отслеживание через прокси или сравнение со снимком, сделанным при загрузке; отсюда и стоимость отслеживания, и смысл AsNoTracking.

Кто регистрирует изменение — отдельный вопрос из книги, и он объясняет поведение EF Core:

  • регистрация вызывающим: пользователь объекта сам обязан сообщить о правке. Гибко — можно менять объект «в памяти» и не сохранять, — но забывчивость дорого стоит; Фаулер считает, что путаницы от этого больше, чем пользы.
  • регистрация самим объектом: сеттеры и загрузка регистрируют объект автоматически. Именно так работает отслеживание изменений в ORM, и поэтому загруженная сущность сохраняется без единого вызова Update.

Ещё одна работа, которую берёт на себя Unit of Work, — порядок записи: при ссылочной целостности вставки и удаления нужно выполнять в правильной последовательности, и это делает он, а не вы.


Блок 7. Service Layer

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

Это ответ на вопрос: «где живёт сценарий целиком?» Не в контроллере (он тонкий — лекция 2), не в сущности (она не знает про транзакции и почту), а в отдельном слое.

%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#c9a227'}}}%%
flowchart TB
    C["Контроллеры / gRPC / фоновые задачи"] --> SL["Слой сервисов приложения<br/>сценарии, транзакции, авторизация"]
    SL --> DM["Доменная модель<br/>правила и инварианты"]
    SL --> R["Репозитории и внешние сервисы"]
    DM -.-> R
Исходник диаграммы
flowchart TB
    C["Контроллеры / gRPC / фоновые задачи"] --> SL["Слой сервисов приложения<br/>сценарии, транзакции, авторизация"]
    SL --> DM["Доменная модель<br/>правила и инварианты"]
    SL --> R["Репозитории и внешние сервисы"]
    DM -.-> R
public sealed class OrderAppService                       // Service Layer
{
    private readonly IOrderRepository _orders;
    private readonly IInventory _inventory;
    private readonly IUnitOfWork _uow;
    private readonly IEmailSender _email;

    public async Task<OrderDto> PlaceAsync(PlaceOrderRequest request, CancellationToken ct)
    {
        var reservation = await _inventory.ReserveAsync(request.Items, ct);   // координация

        var order = Order.Create(request.ToLines());                          // домен решает
        if (!order.CanBePlacedBy(request.CustomerId))
            throw new DomainException("Клиент не может размещать заказы");

        _orders.Add(order);
        await _uow.SaveChangesAsync(ct);                                      // транзакция
        await _email.SendConfirmationAsync(order, ct);                        // побочный эффект

        return OrderDto.From(order);                                          // наружу — DTO
    }
}

Два варианта реализации (по книге).

  • Фасад над доменом — тонкие фасады поверх доменной модели, без единой строки бизнес-логики: они лишь задают границу и набор операций.
  • Сценарные операции — классы потолще, которые сами реализуют прикладную логику (координацию, транзакции, уведомления), делегируя доменную логику сущностям. Имена таких классов обычно оканчиваются на Service.

Разница именно в толщине, а не в наличии бизнес-правил: в обоих вариантах правила предметной области живут в домене.

Что лежит в слое сервисов: оркестрация шагов, границы транзакций, авторизация операции, работа с внешними системами, преобразование в DTO, публикация событий.

Чего в нём быть не должно: бизнес-правил. Если сервис считает скидки и проверяет инварианты, значит домен анемичен (антипаттерн из лекции 2), а сервис превратился в Transaction Script с классом вокруг.

Два вида сервисов, которые путают. Доменный сервис — операция предметной области, не принадлежащая ни одной сущности (перевод между счетами). Прикладной сервис — граница приложения и сценарий. Первый не знает о транзакциях и DTO, второй занимается именно ими.

В .NET. Роль слоя сервисов часто играют обработчики команд/запросов (MediatR, CQRS-стиль): один класс на сценарий вместо одного класса на агрегат. Это тот же Service Layer, только нарезанный тоньше — и потому лучше соответствующий принципу единственной ответственности.


Блок 8. Что из этого уже реализовано в EF Core

Половина сегодняшнего каталога встроена в вашу ORM — и это надо понимать, иначе вы напишете второй слой поверх первого.

Шаблон Фаулера Где он в EF Core
Data Mapper сам EF Core: сущности не знают о базе, отображение задаётся конфигурацией
Unit of Work DbContext — накапливает изменения, SaveChanges пишет одной транзакцией
Repository DbSet<T> — коллекциеподобный доступ к агрегатам
Identity Map отслеживание изменений: один экземпляр на ключ в пределах контекста
Lazy Load ленивая загрузка навигационных свойств через прокси
Query Object IQueryable<T> и деревья выражений
Optimistic Offline Lock [Timestamp] / IsRowVersion() и DbUpdateConcurrencyException
// Identity Map в действии: два запроса — один объект
var a = await db.Orders.FindAsync(id);
var b = await db.Orders.FindAsync(id);
Console.WriteLine(ReferenceEquals(a, b));      // True — контекст вернул тот же экземпляр

Практический вывод. Свой репозиторий поверх EF Core оправдан, когда он говорит на языке домена и скрывает EF от прикладного слоя ради тестируемости и независимости. Свой IUnitOfWork поверх DbContext — это обычно один интерфейс с одним методом, и он оправдан ровно тем же: чтобы прикладной слой не ссылался на EF. А вот обобщённый IRepository<T> { IQueryable<T> Query(); } — это переименованный DbSet<T>: работы много, пользы ноль.

Вопрос аудитории: если DbContext уже Repository и Unit of Work, зачем их писать самому? Ответ, который я жду: чтобы направление зависимостей осталось правильным — домен и прикладной слой не должны знать о конкретной ORM. Если такой цели нет (маленький проект, одна база, короткая жизнь) — не пишите, работайте с DbContext напрямую и не изображайте архитектуру.


Блок 9. Выбор, ошибки и домашнее задание

Как выбирать

%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#c9a227'}}}%%
flowchart TD
    A{"Логика сложная<br/>и будет расти?"} -- нет --> B["Transaction Script<br/>+ Active Record или Dapper"]
    A -- да --> C["Domain Model<br/>+ Data Mapper (ORM)"]
    C --> D{"Домен должен быть<br/>независим от ORM?"}
    D -- да --> E["Репозитории по агрегатам<br/>+ Unit of Work + Service Layer"]
    D -- нет --> F["DbContext напрямую<br/>в обработчиках сценариев"]
Исходник диаграммы
flowchart TD
    A{"Логика сложная<br/>и будет расти?"} -- нет --> B["Transaction Script<br/>+ Active Record или Dapper"]
    A -- да --> C["Domain Model<br/>+ Data Mapper (ORM)"]
    C --> D{"Домен должен быть<br/>независим от ORM?"}
    D -- да --> E["Репозитории по агрегатам<br/>+ Unit of Work + Service Layer"]
    D -- нет --> F["DbContext напрямую<br/>в обработчиках сценариев"]

Ошибки

  1. Обобщённый репозиторий поверх ORM — самая частая и самая бессмысленная абстракция в корпоративном .NET.
  2. SaveChanges внутри репозитория — транзакция разваливается на части, атомарность теряется.
  3. Репозиторий на каждую таблицу вместо репозитория на агрегат.
  4. Анемичный домен + толстые сервисы — Service Layer превратился в Transaction Script.
  5. Active Record в сложном домене — схема БД начинает диктовать модель.
  6. Утечка IQueryable наружу — прикладной слой пишет запросы, а репозиторий перестаёт быть границей.
  7. Сущности домена наружу вместо DTO — об этом отдельная самостоятельная работа.

Домашнее задание

  1. Реализовать один и тот же сценарий (например, «оформить заказ») двумя способами: Active Record и Domain Model + репозиторий. Сравнить по числу классов, тестируемости без базы и по тому, что придётся менять при добавлении правила.
  2. Написать репозиторий для одного агрегата с методами на языке предметной области (минимум три запроса) и IUnitOfWork с одним методом. Показать в тесте, что при исключении в середине сценария в базу не попало ничего.
  3. Добавить оптимистическую блокировку и написать тест, воспроизводящий конфликт двух одновременных изменений.
  4. Оформить прикладной сервис для сценария: границы транзакции, авторизация, DTO наружу. Доменные правила при этом должны остаться в сущностях — покажите это в отчёте.
  5. Диаграммы: классов (слои и зависимости) и последовательности (сценарий с ветвью отказа) в Mermaid.

Литература

  • М. Фаулер. Шаблоны корпоративных приложений — основной источник раздела: Repository, Unit of Work, Active Record, Data Mapper, Service Layer, Identity Map, Transaction Script, Domain Model. Онлайн-каталог с краткими описаниями: martinfowler.com/eaaCatalog.
  • Э. Эванс. Предметно-ориентированное проектирование — репозиторий и сервисы со стороны домена, границы агрегатов.
  • Р. Мартин. Чистая архитектура — направление зависимостей между слоями.
  • Microsoft. .NET Microservices: Architecture for Containerized .NET Applications — бесплатная книга с примерами репозиториев, Unit of Work и слоёв на EF Core.
  • В. Хорикова. Unit Testing — как выбор этих шаблонов влияет на тестируемость.