Организация Backend
Версия: 1.0 Дата: 2026-01-19 Проект: MSP
Все сервисы бекенда организованы по общим принципам и правилам.
Архитектурные принципы
Слоистая архитектура
Domain (сущности, бизнес-логика)
↓
Application (команды, queries, интерфейсы)
↓
Infrastructure (EF Core, Redis, S3, репозитории)
↓
API (контроллеры, middleware)
CQRS через MentKit
- Команды (Commands) — изменяют состояние, возвращают void или ID, всегда работают через интерфейсы репозиториев или "условно внешние" сервисы
- Запросы (Queries) — читают данные, используют
IReadModel, возвращают DTO, НЕ используют репозитории, НЕ изменяют данные
Зависимости между слоями
- Domain — без внешних зависимостей (чистая бизнес-логика)
- Application — зависит от Domain, определяет интерфейсы для Infrastructure
- Infrastructure — зависит от Domain и Application, реализует интерфейсы
- API — зависит от Application, регистрирует handlers через DI
Domain Layer (Service.Domain)
Расположение: src/Service.Domain/
Домен организован по принципам DDD.
- Aggregate — агрегат (корневая сущность) с инвариантами, для каждого агрегата свой каталог
<AggregateName> - AggregateRootId - ID агрегата, обязательно имеется у каждого агрегата, именование -
<AggregateName>Id - Entity — сущность агрегата, реализуется в каталоге
<AggregateName>, связана с агрегатом через Shadow Property<AggregateName>Id - Identity - ID сущности агрегата, обязательно имеется у каждого агрегата, именование -
<EntityName>Id - Value Object — неизменяемый объект-значение, реализуется в каталоге
<AggregateName>(если используется только в этом агрегате) или в корневом каталоге домена (если используется в разных агрегатах) - Domain Event — событие жизненного цикла агрегата (
<AggregateName>/Events/) - Domain Exception — доменное исключение (
Exceptions/или<AggregateName>/Exceptions/)
Конвенции:
- Агрегаты инкапсулируют бизнес-логику
- Value objects неизменяемые (readonly, init-only)
- Доменные события наследуются от базового класса MentKit
Примеры:
Файл src/Service.Domain/Exercises/Exercise.cs:
using MentKit.Domain;
using Service.Domain.Exercises.Events;
namespace Service.Domain.Exercises;
public sealed class Exercise : AggregateRoot<Guid, ExerciseId>
{
private readonly List<ExerciseTask> _tasks = [];
private Exercise()
{
}
public Exercise(ExerciseId id, ExerciseName name) : base(id)
{
AssertionConcern.AssertArgumentNotNull(name, "The name must be provided.");
ProduceEvent(new ExerciseCreated(id, name), Apply);
}
public ExerciseName Name { get; private set; } = null!;
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
public IReadOnlyCollection<ExerciseTask> Tasks => _tasks.AsReadOnly();
public void Change(ExerciseName name)
{
AssertionConcern.AssertArgumentNotNull(name, "The name must be provided.");
if (Name == name)
return;
ProduceEvent(new ExerciseChanged(Id, name), Apply);
}
public void AddTask(ExerciseTaskId taskId, ExerciseTaskName taskName)
{
AssertionConcern.AssertArgumentNotNull(taskId, "The task ID must be provided.");
AssertionConcern.AssertArgumentNotNull(taskName, "The task name must be provided.");
var task = _tasks.FirstOrDefault(t => t.Id == taskId);
if (task != null)
{
if (task.Name != taskName)
throw new InvalidOperationException($"The task with ID '{taskId}' already exists.");
return;
}
ProduceEvent(new ExerciseTaskAdded(Id, taskId, taskName), Apply);
}
private void Apply(ExerciseCreated @event)
{
Name = @event.Name;
CreatedAt = @event.OccurredOn;
}
private void Apply(ExerciseChanged @event)
{
Name = @event.Name;
UpdatedAt = @event.OccurredOn;
}
private void Apply(ExerciseTaskAdded @event)
{
var task = new ExerciseTask(@event.TaskId, @event.TaskName, @event.OccurredOn);
_tasks.Add(task);
UpdatedAt = @event.OccurredOn;
}
}
Файл src/Service.Domain/Exercises/ExerciseId.cs:
using MentKit.Domain;
namespace Service.Domain.Exercises;
public sealed class ExerciseId(Guid value) : AggregateRootId<Guid, ExerciseId>(value)
{
public static new ExerciseId With(Guid value) => new(value);
public static ExerciseId Parse(string value) => new(Guid.Parse(value));
}
Файл src/Service.Domain/Exercises/ExerciseName.cs:
using MentKit.Domain;
namespace Service.Domain.Exercises;
public sealed class ExerciseName : SingleValueObject<string>
{
public ExerciseName(string value) : base(value) =>
AssertionConcern.AssertArgumentNotEmpty(value, "The value must be provided.");
public static ExerciseName With(string value) => new(value);
public static implicit operator string(ExerciseName value) => value.Value;
}
Файл src/Service.Domain/Exercises/Events/ExerciseCreated.cs:
using MentKit.Domain;
namespace Service.Domain.Exercises.Events;
public sealed class ExerciseCreated(ExerciseId exerciseId, ExerciseName name)
: DomainEvent<Guid, ExerciseId>(DomainEventId.New, exerciseId, DomainDateTime.Current)
{
public ExerciseName Name { get; private set; } = name;
}
Файл src/Service.Domain/Exercises/ExerciseTask.cs:
using MentKit.Domain;
namespace Service.Domain.Exercises;
public sealed class ExerciseTask : Entity<Guid, ExerciseTaskId>
{
private ExerciseTask()
{
}
internal ExerciseTask(ExerciseTaskId id, ExerciseTaskName name, DateTime createdAt) : base(id)
{
AssertionConcern.AssertArgumentNotNull(name, "The name must be provided.");
Name = name;
CreatedAt = createdAt;
}
public ExerciseTaskName Name { get; private set; } = null!;
public DateTime CreatedAt { get; private set; }
}
Application Layer (Service.Application)
Расположение: src/Service.Application/
<Feature> = <AggregateName> + s (во множественном числе)
Commands (изменение состояния)
- Файл команды:
Commands/<Feature>/<CommandName>/<CommandName>.cs - Handler:
Commands/<Feature>/<CommandName>/<CommandName>.Handler.cs - Results:
Commands/<Feature>/<CommandName>/<CommandName>.Results.cs - Использование репозиториев через интерфейсы из Application
Пример команды создания Exercise:
Файл src/Service.Application/Commands/Exercises/CreateExerciseCommand/CreateExerciseCommand.cs:
using MentKit.UseCases;
using OneOf;
using Service.Domain.Exercises;
namespace Service.Application.Commands.Exercises.CreateExerciseCommand;
public sealed partial class CreateExerciseCommand(ExerciseId exerciseId, ExerciseName name)
: ICommand<OneOf<CreateExerciseCommand.Results.SuccessResult, CreateExerciseCommand.Results.ConflictResult>>
{
private ExerciseId ExerciseId { get; } = exerciseId;
private ExerciseName Name { get; } = name;
}
Файл src/Service.Application/Commands/Exercises/CreateExerciseCommand/CreateExerciseCommand.Handler.cs:
using MentKit.Persistence;
using MentKit.UseCases;
using OneOf;
using Service.Domain.Exercises;
namespace Service.Application.Commands.Exercises.CreateExerciseCommand;
public sealed partial class CreateExerciseCommand
{
internal sealed class Handler(IExercisesRepository exercisesRepository)
: ICommandHandler<CreateExerciseCommand, OneOf<Results.SuccessResult, Results.ConflictResult>>
{
public async Task<OneOf<Results.SuccessResult, Results.ConflictResult>> Handle(
CreateExerciseCommand request, CancellationToken cancellationToken)
{
var exercise = await exercisesRepository.FindById(request.ExerciseId, cancellationToken);
if (exercise != null)
{
if (exercise.Name != request.Name)
return Conflict("Exercise with the same ID already exists.");
return Success();
}
exercise = new Exercise(request.ExerciseId, request.Name);
try
{
await exercisesRepository.Save(exercise);
}
catch (OptimisticConcurrencyException)
{
return Conflict("The exercise has been created in another transaction.");
}
return Success();
}
}
}
Файл src/Service.Application/Commands/Exercises/CreateExerciseCommand/CreateExerciseCommand.Results.cs:
namespace Service.Application.Commands.Exercises.CreateExerciseCommand;
public sealed partial class CreateExerciseCommand
{
private static Results.SuccessResult Success() => new();
private static Results.ConflictResult Conflict(string message) => new(message);
public static class Results
{
public sealed class SuccessResult;
public sealed class ConflictResult(string message)
{
public string Message { get; private set; } = message;
}
}
}
Пример команды изменения Exercise:
Файл src/Service.Application/Commands/Exercises/ChangeExerciseCommand/ChangeExerciseCommand.cs:
using MentKit.UseCases;
using OneOf;
using Service.Domain.Exercises;
namespace Service.Application.Commands.Exercises.ChangeExerciseCommand;
public sealed partial class ChangeExerciseCommand(ExerciseId exerciseId, ExerciseName name)
: ICommand<OneOf<ChangeExerciseCommand.Results.SuccessResult, ChangeExerciseCommand.Results.NotFoundResult,
ChangeExerciseCommand.Results.ConflictResult>>
{
private ExerciseId ExerciseId { get; } = exerciseId;
private ExerciseName Name { get; } = name;
}
Файл src/Service.Application/Commands/Exercises/ChangeExerciseCommand/ChangeExerciseCommand.Handler.cs:
using MentKit.Persistence;
using MentKit.UseCases;
using OneOf;
namespace Service.Application.Commands.Exercises.ChangeExerciseCommand;
public sealed partial class ChangeExerciseCommand
{
internal sealed class Handler(IExercisesRepository exercisesRepository)
: ICommandHandler<ChangeExerciseCommand,
OneOf<Results.SuccessResult, Results.NotFoundResult, Results.ConflictResult>>
{
public async Task<OneOf<Results.SuccessResult, Results.NotFoundResult, Results.ConflictResult>> Handle(
ChangeExerciseCommand request, CancellationToken cancellationToken)
{
var exercise = await exercisesRepository.FindById(request.ExerciseId, cancellationToken);
if (exercise == null)
return NotFound("Exercise not found.");
exercise.Change(request.Name);
try
{
await exercisesRepository.Save(exercise);
}
catch (OptimisticConcurrencyException)
{
return Conflict("The exercise has been modified in another transaction.");
}
return Success();
}
}
}
Queries (чтение данных)
- Файл query:
Queries/Api/<Feature>/<QueryName>/<QueryName>.cs - Handler:
Queries/Api/<Feature>/<QueryName>/<QueryName>.Handler.cs, используетIReadModel(не репозитории!) - DTO для ответа:
Queries/Api/<Feature>/<QueryName>/<QueryName>.Results.cs
Пример query получения Exercise по ID:
Файл src/Service.Application/Queries/Api/Exercises/GetExerciseByIdQuery/GetExerciseByIdQuery.cs:
using MentKit.UseCases;
using OneOf;
namespace Service.Application.Queries.Api.Exercises.GetExerciseByIdQuery;
public sealed partial class GetExerciseByIdQuery(Guid exerciseId)
: IQuery<OneOf<GetExerciseByIdQuery.Results.SuccessResult, GetExerciseByIdQuery.Results.NotFoundResult>>
{
private Guid ExerciseId { get; set; } = exerciseId;
}
Файл src/Service.Application/Queries/Api/Exercises/GetExerciseByIdQuery/GetExerciseByIdQuery.Handler.cs:
using MentKit.ReadModels;
using MentKit.UseCases;
using OneOf;
using Service.Application.ReadModels;
namespace Service.Application.Queries.Api.Exercises.GetExerciseByIdQuery;
public sealed partial class GetExerciseByIdQuery
{
internal sealed class Handler(IReadModel readModel, IReadModelQueryExecutor readModelExecutor)
: IQueryHandler<GetExerciseByIdQuery, OneOf<Results.SuccessResult, Results.NotFoundResult>>
{
public async Task<OneOf<Results.SuccessResult, Results.NotFoundResult>> Handle(GetExerciseByIdQuery request,
CancellationToken cancellationToken)
{
var exerciseQuery = readModel.Exercises
.Where(e => e.ExerciseId == request.ExerciseId)
.Select(e => new ExerciseView
{
ExerciseId = e.ExerciseId,
Name = e.Name,
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt
});
var exercise = await readModelExecutor.FirstOrDefaultAsync(exerciseQuery, cancellationToken);
if (exercise == null)
return NotFound($"The exercise '{request.ExerciseId}' not found.");
var tasksQuery = readModel.ExerciseTasks
.Where(t => t.ExerciseId == request.ExerciseId)
.Select(t => new ExerciseTaskView
{
ExerciseTaskId = t.ExerciseTaskId,
Name = t.Name,
CreatedAt = t.CreatedAt
})
.OrderBy(t => t.CreatedAt);
var tasks = await readModelExecutor.ToListAsync(tasksQuery, cancellationToken);
exercise.Tasks = tasks;
return Success(exercise);
}
}
}
Файл src/Service.Application/Queries/Api/Exercises/GetExerciseByIdQuery/GetExerciseByIdQuery.Results.cs:
namespace Service.Application.Queries.Api.Exercises.GetExerciseByIdQuery;
public sealed partial class GetExerciseByIdQuery
{
private static Results.SuccessResult Success(ExerciseView value) => new(value);
private static Results.NotFoundResult NotFound(string message) => new(message);
public static class Results
{
public sealed class SuccessResult(ExerciseView value)
{
public ExerciseView Value { get; } = value;
}
public sealed class NotFoundResult(string message)
{
public string Message { get; private set; } = message;
}
}
public sealed class ExerciseView
{
public Guid ExerciseId { get; set; }
public string Name { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public List<ExerciseTaskView> Tasks { get; set; } = new();
}
public sealed class ExerciseTaskView
{
public Guid ExerciseTaskId { get; set; }
public string Name { get; set; } = null!;
public DateTime CreatedAt { get; set; }
}
}
Пример query получения списка Exercise с пагинацией:
Файл src/Service.Application/Queries/Api/Exercises/GetExercisesByPageQuery/GetExercisesByPageQuery.cs:
using MentKit.UseCases;
using OneOf;
namespace Service.Application.Queries.Api.Exercises.GetExercisesByPageQuery;
public sealed partial class GetExercisesByPageQuery(uint offset, uint count)
: IQuery<OneOf<GetExercisesByPageQuery.Results.SuccessResult>>
{
private uint Offset { get; } = offset;
private uint Count { get; } = count;
}
Файл src/Service.Application/Queries/Api/Exercises/GetExercisesByPageQuery/GetExercisesByPageQuery.Handler.cs:
using MentKit.Query;
using MentKit.ReadModels;
using MentKit.UseCases;
using OneOf;
using Service.Application.ReadModels;
namespace Service.Application.Queries.Api.Exercises.GetExercisesByPageQuery;
public sealed partial class GetExercisesByPageQuery
{
internal sealed class Handler(IReadModel readModel, IReadModelQueryExecutor readModelExecutor)
: IQueryHandler<GetExercisesByPageQuery, OneOf<Results.SuccessResult>>
{
public async Task<OneOf<Results.SuccessResult>> Handle(GetExercisesByPageQuery request,
CancellationToken cancellationToken)
{
var query = readModel.Exercises
.Select(e => new ExerciseReference
{
ExerciseId = e.ExerciseId,
Name = e.Name,
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt
});
var total = await readModelExecutor.CountAsync(query, cancellationToken);
if (total <= 0)
return Success(Page<ExerciseReference>.Empty(request.Offset));
var exercises = await readModelExecutor.ToListAsync(
query
.OrderBy(p => p.CreatedAt)
.Skip((int)request.Offset)
.Take((int)request.Count),
cancellationToken);
return Success(exercises.AsPage(total, request.Offset));
}
}
}
Файл src/Service.Application/Queries/Api/Exercises/GetExercisesByPageQuery/GetExercisesByPageQuery.Results.cs:
using MentKit.Query;
namespace Service.Application.Queries.Api.Exercises.GetExercisesByPageQuery;
public sealed partial class GetExercisesByPageQuery
{
private static Results.SuccessResult Success(Page<ExerciseReference> value) => new(value);
public static class Results
{
public sealed class SuccessResult(Page<ExerciseReference> value)
{
public Page<ExerciseReference> Value { get; } = value;
}
}
public sealed class ExerciseReference
{
public Guid ExerciseId { get; set; }
public string Name { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
}
Интерфейсы репозиториев
- Расположение: в корне Application-слоя
- Определяют контракт для Infrastructure-слоя
- Методы для работы с агрегатами (базовые методы FindById и Save наследуются от IAggregateRootRepository; можно описать новые - Find(Name), Find(TenantId) и т.д.)
Read Models
- Read Models — проекции для оптимизированного чтения
- Файлы моделей:
ReadModels/Models/<AggregateName>Model.csилиReadModels/Models/<EntityName>Model.cs(отдельный файл на одру модель) - IReadModel:
ReadModels/IReadModel.cs
Infrastructure Layer (Service.Infrastructure)
Расположение: src/Service.Infrastructure/
DbContext конфигурация
- Aggregate:
<Feature>/Configurations/<AggregateName>Configuration.cs - Entity configurations (если есть связанные сущности агрегата):
<Feature>/Configurations/<EntityName>Configuration.cs - Использовать Fluent API для маппинга
- Определить индексы, связи, ограничения
Примеры:
Файл src/Service.Infrastructure/Exercises/Configurations/ExerciseConfiguration.cs:
using MentKit.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Service.Domain.Exercises;
namespace Service.Infrastructure.Exercises.Configurations;
internal sealed class ExerciseConfiguration : AggregateRootTypeConfiguration<Guid, ExerciseId, Exercise>
{
public override void Configure(EntityTypeBuilder<Exercise> builder)
{
builder.Property(e => e.Name)
.HasConversion<string>(vo => vo, v => ExerciseName.With(v));
builder.HasMany(e => e.Tasks)
.WithOne()
.HasForeignKey("ExerciseId")
.HasPrincipalKey(e => e.Id)
.IsRequired();
base.Configure(builder);
}
}
Файл src/Service.Infrastructure/Exercises/Configurations/ExerciseTaskConfiguration.cs:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Service.Domain.Exercises;
namespace Service.Infrastructure.Exercises.Configurations;
internal sealed class ExerciseTaskConfiguration : IEntityTypeConfiguration<ExerciseTask>
{
public void Configure(EntityTypeBuilder<ExerciseTask> builder)
{
builder.ToTable("ExerciseTask");
builder.Property(o => o.Id)
.HasColumnName("ExerciseTaskId")
.HasConversion<Guid>(vo => vo, v => ExerciseTaskId.With(v));
builder.Property<ExerciseId>("ExerciseId")
.HasConversion<Guid>(vo => vo, v => ExerciseId.With(v));
builder.Property(o => o.Name)
.HasConversion<string>(vo => vo, v => ExerciseTaskName.With(v));
builder.HasKey(r => r.Id);
}
}
Файл src/Service.Infrastructure/Exercises/ExercisesDbContext.cs:
using MentKit.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Service.Domain.Exercises;
using Service.Infrastructure.Exercises.Configurations;
namespace Service.Infrastructure.Exercises;
internal sealed class ExercisesDbContext(DbContextOptions<ExercisesDbContext> options)
: AggregateRootDbContext<Guid, ExerciseId, Exercise, ExerciseConfiguration>(options)
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new ExerciseTaskConfiguration());
base.OnModelCreating(modelBuilder);
}
}
Дополнительный пример: DbContext для агрегата без связанных сущностей:
Файл src/Service.Infrastructure/JitRequests/JitRequestsDbContext.cs:
using MentKit.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Service.Domain.JitRequests;
using Service.Infrastructure.JitRequests.Configurations;
namespace Service.Infrastructure.JitRequests;
internal sealed class JitRequestsDbContext(DbContextOptions<JitRequestsDbContext> options)
: AggregateRootDbContext<Guid, JitRequestId, JitRequest, JitRequestConfiguration>(options);
Репозитории
- Реализация:
<Feature>/<Feature>Repository.cs - Имплементирует интерфейс из Application
- Использует DbContext для работы с БД
Пример:
Файл src/Service.Infrastructure/Exercises/ExercisesRepository.cs:
using MentKit.Persistence.EntityFramework;
using Microsoft.EntityFrameworkCore;
using Service.Application;
using Service.Domain.Exercises;
using Service.Infrastructure.Exercises.Configurations;
namespace Service.Infrastructure.Exercises;
internal sealed class ExercisesRepository(ExercisesDbContext context) :
AggregateRootRepository<Guid, ExerciseId, Exercise, ExerciseConfiguration, ExercisesDbContext>(context),
IExercisesRepository
{
public override Task<Exercise?> FindById(ExerciseId aggregateRootId,
CancellationToken cancellationToken = default)
=> Aggregates
.Include(a => a.Tasks)
.Where(t => t.Id == aggregateRootId)
.FirstOrDefaultAsync(cancellationToken);
}
API Layer (Service.Api)
Расположение: src/Hosts/Service.Api/
-
<Feature>=<AggregateName>s(множественное число агрегата) -
<ControllerName>=<Feature>Controller -
<BindingName>=<Action><AggregateName>Binding(чаще всего, но не всегда) -
Контроллеры:
Controllers/<ControllerName>.cs -
Биндинги:
Bindings/<Feature>/<BindingName>.cs -
Предпочтительно изменения в Application, а не в контроллерах
-
Контроллеры тонкие — только маршрутизация к MentKit handlers
Пример:
Файл src/Hosts/Service.Api/Controllers/ExercisesController.cs:
using System.ComponentModel.DataAnnotations;
using MentKit.Query;
using MentKit.RestApi;
using MentKit.RestApi.Controllers;
using MentKit.UseCases;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Service.Api.Bindings.Exercises;
using Service.Application.Commands.Exercises.AddExerciseTaskCommand;
using Service.Application.Commands.Exercises.ChangeExerciseCommand;
using Service.Application.Commands.Exercises.CreateExerciseCommand;
using Service.Application.Queries.Api.Exercises.GetExerciseByIdQuery;
using Service.Application.Queries.Api.Exercises.GetExercisesByPageQuery;
using Service.Domain.Exercises;
namespace Service.Api.Controllers;
[Consumes("application/json")]
[Produces("application/json")]
[Route("api/v{version:apiVersion}/exercises")]
[Authorize]
public sealed class ExercisesController(IErrorActionResultFactory errorActionResultFactory)
: ApiController(errorActionResultFactory)
{
/// <summary>
/// Create exercise
/// </summary>
/// <response code="204"/>
/// <response code="409"/>
[ProducesResponseType(204)]
[HttpPost]
public async Task<IActionResult> CreateAsync(
[FromServices] ICommandExecutor commandExecutor,
[FromBody] [Required] CreateExerciseBinding binding,
CancellationToken cancellationToken = default) =>
(await commandExecutor.Execute(
new CreateExerciseCommand(
ExerciseId.With(binding.ExerciseId),
ExerciseName.With(binding.Name)),
cancellationToken))
.Match(
_ => NoContent(),
conflict => Conflict(conflict.Message)
);
/// <summary>
/// Get list of exercises
/// </summary>
/// <response code="200"/>
[ProducesResponseType(typeof(Page<GetExercisesByPageQuery.ExerciseReference>), 200)]
[HttpGet]
public async Task<IActionResult> GetExercisesByPageAsync(
[FromServices] IQueryExecutor queryExecutor,
[FromQuery] uint offset = 0,
[FromQuery] [Range(5, 100)] uint count = 20,
CancellationToken cancellationToken = default) =>
(await queryExecutor.Execute(new GetExercisesByPageQuery(offset, count), cancellationToken))
.Match(
success => Ok(success.Value)
);
/// <summary>
/// Get exercise by ID
/// </summary>
/// <response code="200"/>
/// <response code="404"/>
[ProducesResponseType(typeof(GetExerciseByIdQuery.ExerciseView), 200)]
[HttpGet("{exerciseId:guid}")]
public async Task<IActionResult> GetExerciseByIdAsync(
[FromServices] IQueryExecutor queryExecutor,
[FromRoute] [Required] Guid exerciseId,
CancellationToken cancellationToken = default) =>
(await queryExecutor.Execute(new GetExerciseByIdQuery(exerciseId), cancellationToken))
.Match(
success => Ok(success.Value),
notFound => NotFound(notFound.Message)
);
/// <summary>
/// Change exercise
/// </summary>
/// <response code="204"/>
/// <response code="404"/>
/// <response code="409"/>
[ProducesResponseType(204)]
[HttpPut("{exerciseId:guid}")]
public async Task<IActionResult> ChangeAsync(
[FromServices] ICommandExecutor commandExecutor,
[FromRoute] [Required] Guid exerciseId,
[FromBody] [Required] ChangeExerciseBinding binding,
CancellationToken cancellationToken = default) =>
(await commandExecutor.Execute(
new ChangeExerciseCommand(
ExerciseId.With(exerciseId),
ExerciseName.With(binding.Name)),
cancellationToken))
.Match(
_ => NoContent(),
notFound => NotFound(notFound.Message),
conflict => Conflict(conflict.Message)
);
/// <summary>
/// Add exercise task
/// </summary>
/// <response code="204"/>
/// <response code="404"/>
/// <response code="409"/>
/// <response code="422"/>
[ProducesResponseType(204)]
[HttpPost("{exerciseId:guid}/tasks")]
public async Task<IActionResult> AddTaskAsync(
[FromServices] ICommandExecutor commandExecutor,
[FromRoute] [Required] Guid exerciseId,
[FromBody] [Required] AddExerciseTaskBinding binding,
CancellationToken cancellationToken = default) =>
(await commandExecutor.Execute(
new AddExerciseTaskCommand(
ExerciseId.With(exerciseId),
ExerciseTaskId.With(binding.TaskId),
ExerciseTaskName.With(binding.TaskName)),
cancellationToken))
.Match(
_ => NoContent(),
notFound => NotFound(notFound.Message),
invalidOperation => UnprocessableEntity(invalidOperation.Message),
conflict => Conflict(conflict.Message)
);
}
Файл src/Hosts/Service.Api/Bindings/Exercises/CreateExerciseBinding.cs:
namespace Service.Api.Bindings.Exercises;
public sealed class CreateExerciseBinding
{
public Guid ExerciseId { get; set; }
public string Name { get; set; } = null!;
}
Множественность API и компонентов
Сервис обычно содержит несколько API - отдельно для роли (админ, клиент) или варианта использования (межсервисное взаимодействие и фоновые операции).
Типовые API и компоненты:
- ClientApi: для веб-интерфейса (WebClient)
- AdminApi: для панели администратора (AdminClient)
- InternalApi (или Internal): для фоновых операций и межсервисного взаимодействия; доступность только внутри облака, нет доступности извне.
- Background: для фоновых операций.
- Worker: для фоновых операций.
Обычно сервис обязательно имеет компоненты ClientApi, AdminApi и InternalApi.
Фоновые операции
Фоновые операции:
- публикация доменных событий в Kafka
- обработка доменных событий из Kafka
- задачи Hangfire
Правила:
- Если есть компонент
InternalApiилиBackground, тогдаClientApiиAdminApiне обрабатывают фоновые операции. - Если есть компонент
Background, только он выполняет фоновые операции,InternalApiэтого не делает. - Если есть компонент
Worker, обычно он только выполняет задачи Hangfire.
Миграции базы данных
Создаются необходимые сущности в Entities.
Для создания миграций всегда используется dotnet ef. НЕ создавать миграции вручную!
cd src/Infrastructure/Service.Migrations
dotnet ef migrations add <MigrationName>
Ключевые конвенции
Именование
- Команды:
<Verb><Noun>Command(CreateExerciseCommand, ChangeExerciseCommand) - Queries:
Get<Noun>ByIdQuery,Get<Noun>sByPageQuery - Repositories:
I<Aggregate>sRepository,<Aggregate>sRepository - ReadModel:
<Noun>Model
Организация кода
- Команды в
Commands/<Feature> - Queries в
Queries/Api/<Feature> - Онда команда или query — три разных файла
Обработка ошибок
- Доменные ошибки — через
InvalidOperationExceptionили кастомные исключения из Domain - Валидация — в handler-ах (не используем FluentValidation!)
- Оптимистичная конкурентность через MentKit (
OptimisticConcurrencyException) - HTTP-статусы обрабатываются в контроллерах или глобальном middleware
Типичные ошибки (результат команды => HTTP-код и результат метода контроллера):
- Success => 200 Ok
- Success => 201 Created
- Success => 202 Accepted
- Success => 204 NoContent
- NotFound => 404 NotFound
- Conflict => 409 Conflict
- InvalidOperation => 422 UnprocessableEntity
Безопасность
- Избегать SQL-инъекций (использовать параметризованные запросы через EF)
- Избегать command injection
- Валидировать входные данные
- Не логировать чувствительные данные (пароли, ключи)
Производительность
- Queries используют
AsNoTracking()для read-only операций - Использовать
Include()для жадной загрузки связанных данных - Избегать N+1 запросов
- Рассматривать индексы для часто используемых полей
Интеграции
PostgreSQL
- Основное хранилище через EF Core
- Конфигурация в
appsettings.json:ConnectionStrings:postgres
Redis
- Кэш и распределенные блокировки
- Конфигурация:
ConnectionStrings:redis
S3-совместимое хранилище
- Для blob-данных (файлы, вложения)
JWT-аутентификация
- Middleware в API-слое
- Конфигурация:
Jwtsection