Привет, Хабр! В Монолите весь код должен быть в едином стиле, a в разных микросервисах можно использовать разные подходы, языки программирования и фреймворки. Для простых микросервисов с 1 — 2 контроллерами и 1 — 10 действиями особо смысла городить слои абстракций нет. Для сложных микросервисов с различными состояниями и логикой перехода между ними наоборот лучше изначально не лениться. Я хочу рассказать о моем опыте организации кода и использования подходов DDD, Портов и Адаптеров для обоих случаев. Есть кратко суть статьи: Джун — пишет код в контроллере. Мидл — пишет кучу абстракций. Сеньор — знает когда нужно писать код в контроллере, а когда нужны абстракции. Тут нужно сразу поставить точку над И — Порты в С# это interface или abstract, а Адаптеры это конкретные реализации (class, struct). В целях ознакомления рекомендую почитать этот перевод DDD, Hexagonal, Onion, Clean, CQRS… как я собрал всё это вместе и вот эту статью Заблуждения Clean Architecture. Тут только имейте в виду что описывается подход для большого и сложного монолита. Я же хочу рассказать о вариациях этого подхода в микросервисах 80 процентов из которых простые и 20 средней или высокой сложности.
Терминология
1) PrimaryAdapters
Представляют интерфейс удобный для потребления для внешней вызывающей системой. Вызывают и используют SecondaryApdapters и Logic. Они говорят приложению что-то сделать. Точки входа в ваш код. Типичные представители: ItemsService, CreateItemCommandHandler. Используют Logic и SecondaryAdapters для своей работы.
2) SecondaryAdapters
Предоставляет интерфейс к внешней вызываемой системе удобный для использования нашей системой. Он получает команды от приложения. Типичные представители: ItemsRepository, EmailSender, Logger, DistributedCache. Используют библиотеки и фреймворки вроде EntityFramework для своей работы.
3) Logic
3.1) Entities
Объединяют в себе данные и логику. Типичные ООП объекты. Item, Money, User. Тут так же лежат ValueObjects и Events. Используют только другие объекты из слоя Entities. По идее этот слой это ядро приложения которое ни от кого не зависит. Все зависят от него.
3.2) DomainServices
Голые вычисления. Нужны для логики которую не получается привязать к одной конкретной сущности. Используют Entities или другие DomainServices. Никогда не используют PrimaryAdapters (эти вообще всегда сами всех используют) и SecondaryAdapters. Обычно это всякие AmountCalculator, Validator и прочее.
Поток вызова кода всегда должен быть в одном направлении PrimaryAdapters -> Logic и SecondaryAdapters.
Domain
Это предметная область для которой разрабатывается система. Например для Интернет Магазина Domain это E-Commerce.
BoundedContext
BoundedContext используется для разбиения системы на изолированные части с определенной ответственностью. Один из путей к успеху в проектировании системы это найти и выделить в ней все ее BoundedContexts. В микросервисной архитектуре 1 микросервис = 1 BoundedContext. Например: У интернет магазина может быть BoundedContext Корзины товаров и BoundedContext работы с заказами, BoundedContext работы с файлами. Причем один большой BoundedContext может быть разбит на маленькие куски внутри. Например для BoundedContext корзины товаров можно сделать разделения на контекст добавления товаров в корзину и контекст удаления товаров из корзины. В монолите 1 большой BoundedContext это один Модуль. Тут надо сделать замечание что все Entities живут в пределах какого-то определенного контекста т.е. Item из контекста корзины товаров и Item из контекста витрины товаров это могут быть разные классы с разными полями и методами. В монолите их просто маппят друг в друга и используют для работы с БД какую нибудь ItemDto которую передают EF и которая имеет поля характерные для всех т. е. если у Item из контекста (модуля) Корзины есть свойство Property1, а у Item из контекста витрины товаров есть свойство Property2 то у ItemDto будет и Property1 и Property2. В каждом контексте будет свой репозиторий который будет вытаскивать из базы уже характерную для этого контекста Item. В микросервисах с этим просто. У каждого своя БД и свои сущности. У каждого миркросервиса свои классы. Просто помните что Item и ItemsRepository из разных BoundedContext это могут быть разные объекты с разными методами и свойствами. Часто BoundedContext в микросервисах нарушают введением каких-то общих библиотек. В результате получается класс у которого несколько десятков методов или свойств и каждый микросервис использует 1 — 2 нужных только ему поэтому с общими библиотеками в микросервисах надо быть аккуратными.
Зависимости между слоями
Поток вызова кода
Кто, кого и в какой последовательности вызывает.
Иерархия зависимостей
Вариант для простых микросервисов коих по закону Парето 80 процентов
Выбрасываем слой PrimaryAdatapters и SecondaryAdapters. Оставляем только слой Logic. Точнее в типичном ASP.NET приложении используем — PimaryAdater это Controller, а SecondaryAdapter это DbContext от EF. Если вдруг Controller становиться слишком большим, то режем его на части с помощью partial. Это намного лучше чем разводить не нужные абстракции и тратить на них время. Например так Microsoft в примере своего EShop приложения на докер контейнерах так сделала для BasketMicrotservice. Да, просто пишем код в контроллер. Только не надо писать спагетти-код. Важно помнить что слой Logic у нас остается и мы все еще используем Entities с их логикой и DomainServices с их вычислениями а не просто пишем стену спагетти-код в контроллере. Просто мы выбрасываем типичные ItemService и ItemRepository. Старые, добрые ItemValidator, ItemMapper, AmountCalculator и прочее в этом духе у нас все еще остается. Так как у нас сформированный слой Logic остается то мы можем в любой момент перейти к более сложному варианту накрутив дополнительных абстракций ItemsService, ItemsRepository.
Пример
Сервис, который вычисляем итоговую цену продукта со скидкой. По идее он часть Domain Интернет Магазина и представляет собой его BoundedContext каталога товаров. Для простоты всю логику валидации я пропустил. Ее можно описать в каком нибудь ItemValidator и DiscountValidator.
1) Папка Logic:
1.1) Папка Entities:
Скидка:
public class Discount
{
[Key]
public Guid Id { get; set; }
public decimal Value { get; set; }
}
Деньги:
[Owned]
public class Money
{
public decimal Amount { get; set; }
public Money Apply(Discount discount)
{
var amount = Amount * (1 - discount.Value);
return new Money()
{
Amount = amount
};
}
}
Товар:
public class Item
{
[Key]
public Guid Id { get; set; }
public Money Price { get; set; } = new Money();
}
1.2) Папка DomainServices
Калькулятор цены продукта с учетом скидки:
public interface IPriceCalculator
{
Money WithDiscounts(Item item, IEnumerable<Discount> discounts);
}
public sealed class PriceCalculator:IPriceCalculator
{
public Money WithDiscounts(Item item, IEnumerable<Discount> discounts)
{
return discounts.Aggregate(item.Price, (m, d) => m.Apply(d));
}
}
2) DbContext
public class ItemsDbContext : DbContext
{
public ItemsDbContext(DbContextOptions<ItemsDbContext> options) : base(options)
{
}
public DbSet<Item> Items { get; set; }
public DbSet<Discount> Discounts { get; set; }
}
3) Controller
[ApiController]
[Route("api/v1/items")]
public class ItemsController : ControllerBase
{
private readonly ItemsDbContext _context;
private readonly IPriceCalculator _priceCalculator;
public ItemsController(ItemsDbContext context, IPriceCalculator priceCalculator)
{
_context = context;
_priceCalculator = priceCalculator;
}
[HttpGet("{id}/price-with-discounts")]
public async Task<decimal> GetPriceWithDiscount(Guid id)
{
var item = await _context.Items.FindAsync(id);
var discounts = await _context.Discounts.ToListAsync();
return _priceCalculator.WithDiscounts(item, discounts).Amount;
}
}
Вариант для сложных микросервисов коих 20 процентов и для большинства монолитов
Тут используем стандартный подход с максимальной изоляцией слоев друг от друга. Добавляем класс для PrimaryAdapter (ItemService) и для SecondaryAdapter (ItemRepository). В папке Logic все остается как было до этого.
Пример
1) Папка SecondaryAdapters
public interface IItemsRepository
{
Task<Item> Get(Guid id);
}
public sealed class ItemsRepository : IItemsRepository
{
private readonly ItemsDbContext _context;
public ItemsRepository(ItemsDbContext context)
{
_context = context;
}
public async Task<Item> Get(Guid id)
{
var item = await _context.Items.FindAsync(id);
return item;
}
}
public interface IDiscountsRepository
{
Task<List<Discount>> Get();
}
public sealed class DiscountsRepository : IDiscountsRepository
{
private readonly ItemsDbContext _context;
public DiscountsRepository(ItemsDbContext context)
{
_context = context;
}
public Task<List<Discount>> Get()
{
return _context.Discounts.ToListAsync();
}
}
2) Папка PrimaryAdapters
public interface IItemService
{
Task<decimal> GetPriceWithDiscount(Guid id);
}
public class ItemService : IItemService
{
private readonly IItemsRepository _items;
private readonly IDiscountsRepository _discounts;
private readonly IPriceCalculator _priceCalculator;
public ItemService(IItemsRepository items, IDiscountsRepository discounts, IPriceCalculator priceCalculator)
{
_items = items;
_discounts = discounts;
_priceCalculator = priceCalculator;
}
public async Task<decimal> GetPriceWithDiscount(Guid id)
{
var item = await _items.Get(id);
var discounts = await _discounts.Get();
return _priceCalculator.WithDiscounts(item, discounts).Amount;
}
}
Наш контроллер теперь использует наш IItemService
[ApiController]
[Route("api/v1/items")]
public class ItemsController : ControllerBase
{
private readonly IItemService _service;
public ItemsController(IItemService service)
{
_service = service;
}
[HttpGet("{id}/price-with-discounts")]
public async Task<decimal> GetPriceWithDiscount(Guid id)
{
var result = await _service.GetPriceWithDiscount(id);
return result;
}
}
Добавилось много дополнительного кода. Взамен повысилась гибкость системы. Теперь проще сменить Contoller и вообще ASP.NET Core на что-то другое или сменить DbContext и EntityFramework Core на что-то другое или добавить кеширование в Redis. Первый подход выигрывает в простых микросервисах и очень простых и маленьких монолитах. Второй во всех остальных случаях, когда добавлять и допиливать вам этот код надо будет больше года. Ну и во втором подходе кроме Entity Item и Discount полезно сделать еще отдельные DTO которые будет использовать DbContex EF и отдельные DTO (Models) которые будет использовать ASP.NET Controller т.е. ItemDto и ItemModel. Это позволить сделать доменные модели еще более независимыми. Ну и напоследок пример простого приложения которое я написал с применением 2 го подхода TestRuvds. На самом деле он тут излишен и все эти абстракции тут для примера.
Пример реализации
VirtualServersModule
Благодарности
Спасибо canxes и AndrewDemb за найденные грамматические ошибки.