Многие компании, использующие C# для разработки, стремятся к микросервисной архитектуре. Этот подход обеспечивает гибкость, отказоустойчивость и масштабируемость.
В этой статье описанны основные принципы построения микросервисов, популярные библиотеки и подходы. Расчитана на новичков.
Шаблон:
Если хочешь быстро запустить свой первый микросервис на .NET без лишней возни — загляни в Template.Net.Microservice после прочтения статьи.
Готовая структура, поддержка описываемых в статье паттернов — просто ставь шаблон и сразу кодь свой сервис. Минимум настройки, максимум пользы 🔥
Что такое микросервисы?
Микросервисы — это подход к архитектуре системы, при котором приложение разбивается на набор небольших, автономных сервисов, каждый из которых реализует отдельную бизнес-функциональность. Каждый сервис обладает собственной базой данных, своим циклом разработки, развёртывания и масштабирования. Такой подход позволяет:
Независимо развивать отдельные части системы.
Улучшать отказоустойчивость: сбой одного сервиса не приводит к остановке всей системы.
Гибко масштабировать компоненты в зависимости от нагрузки.
Легкое внедрение новых технологий и обновление версий библиотек без ущерба основной системы инструментов.
Разделение по слоям
Чтобы обеспечить масштабируемость и простоту сопровождения, рекомендуется разделять приложение на отдельные слои:
Domain (Домен): Содержит основные сущности, агрегаты, бизнес-правила и доменные события. Здесь происходит моделирование предметной области с помощью DDD.
Application (Приложение): Реализует бизнес-логику, команды, запросы и сервисы, объединяя функциональность домена и инфраструктуры. Здесь часто применяется паттерн CQRS.
Infrastructure (Инфраструктура): Отвечает за взаимодействие с внешними ресурсами: базы данных, очереди сообщений, сторонние API, файловые системы.
UI/Presentation (Пользовательский интерфейс): Реализует интерфейсы взаимодействия (например, REST API, Minimal API, GraphQL).
Polyglot Persistence
Каждый микросервис должен иметь свою базу данных, подобранную с учётом его требований. Возможны следующие варианты:
Реляционные базы данных: PostgreSQL, SQL Server — для хранения структурированных данных с транзакционной целостностью.
NoSQL решения: MongoDB, Cassandra — для масштабируемости и работы с неструктурированными данными.
Кэширование и быстрый доступ: Redis — для кэширования, хранения сессий и временных данных.
Такой подход, называемый «Database per Service», позволяет избежать жёстких связей между сервисами и оптимизировать каждую часть системы под её конкретную задачу.
CQRS и MediatR: Разделение обязанностей чтения и записи
Command Query Responsibility Segregation (CQRS) разделяет операции записи (команды) и чтения (запросы) в приложении. Это позволяет оптимизировать каждую часть системы отдельно, снижая сложность и повышая производительность.
Преимущества CQRS:
Масштабируемость: Можно оптимизировать производительность операций чтения и записи независимо.
Упрощение логики: Каждая команда или запрос отвечает только за свою часть функциональности.
Лёгкость тестирования: Изолированное тестирование команд и запросов.
Единое место обработки сообщения: У вас могут быть разные виды API, но все они будут идти в общий обработчик.
Использование MediatR
Библиотека MediatR позволяет реализовать паттерн CQRS, объеденяя команды и запросы в общее понятие request и создавая обработчики к request.
Добавление в DI:
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(assemblies));
Команды или запросы должны реализовывать интерфейс IRequest<out TResponse>. Где T это тип, который вернет этот request при обработке
// Определение команды создания заказа
public record OrderCreateCommand(decimal Price) : IRequest<bool>; //Обычно команды возвращают флаг статуса выполнения команды
// Определение запроса для получения заказа по идентификатору
public record OrderGetByIdRequest(Guid OrderId) : IRequest<OrderDto>;
Обработчики к request`ам должны реализовывать интерфейс IRequestHandler<in TRequest, out TResponse>
// Обработчик команды
public class OrderCreateCommandHandler(IOrderRepository orderRepository) : IRequestHandler<OrderCreateCommand, bool>
{
public Task<bool> Handle(OrderCreateCommand request, CancellationToken cancellationToken)
{
var order = new Order(request.Price);
await orderRepository.InsertAsync(order);
return true;
}
}
// Обработчик запроса
public class OrderGetByIdRequestHandler(IOrderRepository orderRepository, IMapper mapper) : IRequestHandler<OrderGetByIdRequest, OrderDto>
{
public async Task<OrderDto> Handle(OrderGetByIdRequest request, CancellationToken cancellationToken)
{
var order = await orderRepository.GetByIdAsync(request.OrderId);
return mapper.Map<OrderDto>(order);
}
}
Очереди сообщений: RabbitMQ и MassTransit
RabbitMQ — популярный брокер сообщений, который позволяет организовать очередь сообщений. MassTransit — библиотека для упрощения работы с брокерами (поддерживает RabbitMQ, Azure Service Bus и др.).
Добавление MassTransit в DI:
services.AddMassTransit(x =>
{
//Добавление всех слушателей Message Consumer по Assembly
var assembly = typeof(Application).Assembly;
x.AddConsumers(assembly);
//При использовании RabbitMq необходимо скачать дополнительный пакет MassTransit.RabbitMQ
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("rabbitmq://localhost", h =>
{
h.Username("user");
h.Password("password");
});
cfg.ConfigureEndpoints(context); //Автоматическая конфигурация endpoint
});
});
Message Consumer совместно с CQRS
Представим, что в сервисе заказов есть модель запроса (OrderGetByIdRequest) и её обработчик (OrderGetByIdRequestHandler). Чтобы другие сервисы могли ходить в обработчик запроса нужно описать слушателя для очереди сообщения:
public class OrderGetByIdRequestConsumer(IMediator mediator) : IConsumer<OrderGetByIdRequest>
{
//Метод вызовется, когда в очередь придет сообщение OrderGetByIdRequest
public async Task Consume(ConsumeContext<OrderGetByIdRequest> context)
{
//Перенаправление сообщение в обработчик
var result = await mediator.Send(context.Message, context.CancellationToken);
await context.RespondAsync(result); //Ответ от обработчка вернуть в очередь
}
}
В другом сервисе можно обратиться к этому слушателю с помощью клиента:
private readonly IRequestClient<OrderGetByIdRequest> _orderGetByIdRequestClient;
try
{
var response = await _profilePaymentOrderCreateClient.GetResponse<OrderDto>(
new OrderGetByIdRequest(Guid.Empty), cancellationToken, RequestTimeout.Default);
//В response.Message находится ответ из очереди сообщений
}
catch (RequestException e)
{
//Обработка исключения при ошибке в очереди сообщений
}
В случае, если вы используете несколько очередей в одном проекте, то необходимо явно указать RequestClient к нужной очереди
services.AddMassTransit(x =>
{
x.AddRequestClient<OrderGetByIdRequest>();
});
Отправка и обработка событий (Publisher/Subscriber)
Отправка события из сервиса заказов:
private readonly IPublishEndpoint _publishEndpoint
await _publishEndpoint.Publish(new OrderCreatedEvent(order.Id, order.ProductName, order.Price));
Потребитель, обрабатывающий событие (например, сервис уведомлений):
public class OrderCreatedConsumer : IConsumer<OrderCreatedEvent>
{
public async Task Consume(ConsumeContext<OrderCreatedEvent> context)
{
// Логика обработки события: отправка уведомления, обновление статистики и т.д.
Console.WriteLine($"Получено событие о создании заказа: {context.Message.OrderId}");
}
}
При Publisher/Subscriber подходе необходимо чтобы каждый подписчик имел свою очередь иначе ивент будет приходить не всем подписчикам сразу, а по очереди:
services.AddMassTransit(x =>
{
x.UsingRabbitMq((context, cfg) =>
{
//Обязательно указывать разное имя очереди в разных сервисах при Publisher/Subscriber
cfg.ReceiveEndpoint("order-created-queue", e =>
{
e.ConfigureConsumer<OrderCreatedConsumer>(context);
});
});
});
Контракт-First подход
Contract First - это подход к разработке микросервисов, при котором API определяется заранее, а затем на его основе пишется сервис. Для определения контрактов можно использовать как спецификации (например, Avro, Protobuf, AsyncAPI), так и общий пакет с моделями. Рассмотрим последний вариант.
Общий пакет (проект) контрактов может храниться в solution, если вы используете Mono-Repo, или же храниться в отдельном nuget-пакете при Multi-Repo подходе.
Что хранить в пакете контракта?
На самом деле хранить можно все что может помочь при интеграции с сервисов. В рабочих проектах я использую подобную струткуру:
Model - Внешние модели сервиса, например, viewmodel, dto и т.п.
Enum - Enum, которые нужны для моделей контракта.
Validator - Правила валидации для моделей контрактов.
Event - Ивенты для паттерна Publisher/Subscriber.
Request или Query/Command - CQRS модели для Message Consumer паттерна.
SagaEvent - Ивенты для паттерна SagaStateMashine.
Дабы в других сервисах не было потаницы из какого контракта та или иная модель, в имени модели можно добавлять префикс с именем сервиса.
Например, есть сервис интернет магазина (Shop), в нем есть модель корзины (Cart) и команда по записи корзины в базу (c). Если в каком-то другом сервисе будут модели с такими же названиями в контрактах, то это вызовет проблемы при разработке. В таком случае можно добавить префикс перед названиями моделей: ShopCart, ShopCartInsertCommand.
Minimal API
Minimal API позволяет создать легковесное и быстрое HTTP API без необходимости писать лишний шаблонный код. Это особенно актуально для микросервисов, где важно быстрое прототипирование и простота.
Пример Minimal API с CQRS
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediatR(typeof(Program));
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
//Если EndPoint`ов в сервисе много, то стоит разнести их по файлам
app.MapPost("/orders", async ([FromBody] OrderCreateCommand command, [FromServices] IMediator mediator, HttpContext context) =>
{
var result = await mediator.Send(command, context.RequestAborted)
return result ? Results.Ok() : Results.BadRequest();
});
app.Run();
Запрос идет в тот же обработчик что используется в очереди сообщений.
Документирование API
Интеграция Swagger через пакет Swashbuckle.AspNetCore позволяет автоматически генерировать документацию для вашего API, что упрощает разработку и тестирование.
builder.Services.AddSwaggerGen();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
Свагер автоматически сгенерирует ui со всеми http методами. Если необходимо дополнительно описать то нужно можно воспользоваться подобным синтаксисом:
app.MapPost("/orders", CreateOrder)
.WithTags("Orders")
.WithSummary("Краткое описание")
.WithDescription("Основное описание")
.WithOpenApi();
[ProducesResponseType(200, Type = typeof(bool))]
[ProducesResponseType(400)]
private async Task<IResult> CreateOrder(
[FromBody] OrderCreateCommand command,
[FromServices] IMediator mediator,
HttpContext context)
{
var result = await mediator.Send(command, context.RequestAborted);
return (bool)result ? Results.Ok(true) : Results.BadRequest();
}
Лучше http методы всегда выносить отдельно, указывать все возвращаемые значения и описание. Вот как это будет выглядеть в свагере:

Безопасность: аутентификация и авторизация
Безопасность является критически важным аспектом любой системы. Рекомендуется использовать централизованный Identity-сервис, который может базироваться на таких решениях, как:
OpenIddict(IdentityServer4) / Duende IdentityServer: Обеспечивают поддержку протоколов OpenID Connect и OAuth 2.0.
ASP.NET Core Identity с JWT: Легковесное решение для аутентификации через JSON Web Token.
Или же вы можите использовать уже готовые opensource приложения по типу KeyСloak или ознакомиться с мои решением на основе OpenIddict.
Централизованный Identity-сервис позволяет:
Управлять пользователями, ролями и разрешениями.
Обеспечить единую точку аутентификации для всех микросервисов.
Гарантировать безопасность и соответствие стандартам.
Контейнеризация и структура
Структура директорий проекта
Всегда стоит использовать одну струтуру директорий проекта, чтобы упростить работу DevOps инженера и понимание другими разработчиками, особенно если вы используете Multi-Repo. Пример простой структуры:
.gitignore: Файл описывающий ограничение по сканированию git
.gitlab-ci.yml или .github/workflows/main.yml: CI/CD файлы
LICENSE: Лецензия по которой распространяется проект
README.md: Описание проета
src: Все файлы, которые непосредственно связанны с кодом проекта
docker-compose.yml: Помогает собрать образы, если есть несколько запускаемых приложений
.nuget/NuGet/NuGet.Config: Если используются закрытые nuget резитории
Проект/
Контейнеризация с Docker
Упаковка микросервисов в Docker-контейнеры позволяет обеспечить воспроизводимость среды и легкость развёртывания. Пример Dockerfile для микросервиса:
#Определение окружения контейнера
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
#Рестор пакетов и билд проекта
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["MyService/MyService.PL.Api/MyService.PL.Api.csproj", "MyService/MyService.PL.Api/"]
COPY ["MyService/MyService.Application/MyService.Application.csproj", "MyService/MyService.Application/"]
COPY ["MyService/MyService.Domain/MyService.Domain.csproj", "MyService/MyService.Domain/"]
COPY ["MyService/MyService.Infrastructure/MyService.Infrastructure.csproj", "MyService/MyService.Infrastructure/"]
RUN dotnet restore "MyService/MyService.PL.Api/MyService.PL.Api.csproj"
COPY . .
WORKDIR "/src/MyService/MyService.PL.Api"
RUN dotnet build "MyService.PL.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build
#Публикация
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "MyService.PL.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
#Финальная конфигурация запуска
FROM base AS final
ENV ASPNETCORE_URLS=https://+:10001;http://+:10000 #Можно отдельно указать http порты
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyService.PL.Api.dll"]
Этот Dockerfile должен запускаться из контекста выше проекта т.е. над директорией проекта должна быть общая директория:
docker build
-t MyService:latest
-f src/MyService/MyService.UI.Api/Dockerfile
src/