Перейти к основному содержимому

Организация 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-слое
  • Конфигурация: Jwt section