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

3. Repository и Service Layer

Цель

Реализовать слой доступа к данным и сервис бизнес-логики для событий активности.

Объяснение

Repository отвечает за запросы к БД. Service Layer отвечает за правила: проверку данных, создание события, преобразование в DTO.

На предыдущем этапе вы создали таблицы. Теперь нужно научить приложение пользоваться ими правильно. Можно было бы обращаться к AppDbContext прямо из контроллера, но тогда HTTP-слой оказался бы связан с деталями базы данных.

Repository делает работу с БД отдельной задачей. Service Layer делает отдельной задачей бизнес-сценарий. В результате код легче тестировать и изменять.

Пример разделения:

  • репозиторий знает, как применить Where, Include, OrderBy, Skip, Take;
  • сервис знает, что Type не должен быть пустым и что после сохранения нужно вернуть DTO;
  • контроллер на следующем этапе будет знать только, какой HTTP-ответ отправить.

Код

Сначала задаются объекты обмена данными. Они отделяют внешний формат запроса и ответа от доменной сущности ActivityEvent: клиенту не нужно знать все внутренние поля EF Core, а сервер получает удобную модель для фильтрации.

ActivityMonitoring.Application/Events/ActivityEventDtos.cs:

namespace ActivityMonitoring.Application.Events;

public record CreateActivityEventRequest(
    string Type,
    string Source,
    string Description,
    string Severity);

public record ActivityEventDto(
    int Id,
    int UserId,
    string UserName,
    string Type,
    string Source,
    string Description,
    string Severity,
    DateTime CreatedAt);

public class ActivityEventFilter
{
    public int? UserId { get; set; }
    public string? Type { get; set; }
    public string? Severity { get; set; }
    public DateTime? From { get; set; }
    public DateTime? To { get; set; }
    public int Page { get; set; } = 1;
    public int PageSize { get; set; } = 20;
}

Затем в слое Application описывается контракт репозитория. Интерфейс показывает, какие операции нужны бизнес-логике, но не раскрывает, будет ли хранение реализовано через SQLite, PostgreSQL или другой источник данных.

ActivityMonitoring.Application/Events/IActivityEventRepository.cs:

using ActivityMonitoring.Domain.Entities;

namespace ActivityMonitoring.Application.Events;

public interface IActivityEventRepository
{
    Task<ActivityEvent> AddAsync(ActivityEvent entity, CancellationToken ct);
    Task<IReadOnlyList<ActivityEvent>> GetAsync(ActivityEventFilter filter, CancellationToken ct);
    Task<ActivityEvent?> GetByIdAsync(int id, CancellationToken ct);
    Task<bool> DeleteAsync(int id, CancellationToken ct);
}

Реализация репозитория находится в Infrastructure, потому что здесь уже используется EF Core и конкретный AppDbContext. Этот класс собирает запросы к базе: применяет фильтры, сортировку, пагинацию и загружает связанного пользователя.

ActivityMonitoring.Infrastructure/Repositories/ActivityEventRepository.cs:

using ActivityMonitoring.Application.Events;
using ActivityMonitoring.Domain.Entities;
using ActivityMonitoring.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

namespace ActivityMonitoring.Infrastructure.Repositories;

public class ActivityEventRepository : IActivityEventRepository
{
    private readonly AppDbContext _db;

    public ActivityEventRepository(AppDbContext db)
    {
        _db = db;
    }

    public async Task<ActivityEvent> AddAsync(ActivityEvent entity, CancellationToken ct)
    {
        _db.ActivityEvents.Add(entity);
        await _db.SaveChangesAsync(ct);

        await _db.Entry(entity).Reference(x => x.User).LoadAsync(ct);
        return entity;
    }

    public async Task<IReadOnlyList<ActivityEvent>> GetAsync(ActivityEventFilter filter, CancellationToken ct)
    {
        var query = _db.ActivityEvents
            .AsNoTracking()
            .Include(x => x.User)
            .AsQueryable();

        if (filter.UserId.HasValue)
            query = query.Where(x => x.UserId == filter.UserId.Value);

        if (!string.IsNullOrWhiteSpace(filter.Type))
            query = query.Where(x => x.Type == filter.Type);

        if (!string.IsNullOrWhiteSpace(filter.Severity))
            query = query.Where(x => x.Severity == filter.Severity);

        if (filter.From.HasValue)
            query = query.Where(x => x.CreatedAt >= filter.From.Value);

        if (filter.To.HasValue)
            query = query.Where(x => x.CreatedAt <= filter.To.Value);

        var page = Math.Max(filter.Page, 1);
        var pageSize = Math.Clamp(filter.PageSize, 1, 100);

        return await query
            .OrderByDescending(x => x.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(ct);
    }

    public Task<ActivityEvent?> GetByIdAsync(int id, CancellationToken ct)
    {
        return _db.ActivityEvents
            .AsNoTracking()
            .Include(x => x.User)
            .FirstOrDefaultAsync(x => x.Id == id, ct);
    }

    public async Task<bool> DeleteAsync(int id, CancellationToken ct)
    {
        var entity = await _db.ActivityEvents.FindAsync([id], ct);
        if (entity is null)
            return false;

        _db.ActivityEvents.Remove(entity);
        await _db.SaveChangesAsync(ct);
        return true;
    }
}

Следующий интерфейс описывает уже не работу с базой, а бизнес-сценарии, доступные остальному приложению. Контроллер будет обращаться именно к сервису, чтобы не принимать решения о валидации и преобразовании данных самостоятельно.

ActivityMonitoring.Application/Events/IActivityEventService.cs:

namespace ActivityMonitoring.Application.Events;

public interface IActivityEventService
{
    Task<ActivityEventDto> CreateAsync(CreateActivityEventRequest request, int userId, CancellationToken ct);
    Task<IReadOnlyList<ActivityEventDto>> GetAsync(ActivityEventFilter filter, CancellationToken ct);
    Task<bool> DeleteAsync(int id, CancellationToken ct);
}

Сервис объединяет правила приложения: проверяет входные данные, создаёт доменную сущность, вызывает репозиторий и возвращает DTO. Благодаря этому контроллер на следующем этапе останется тонким и будет отвечать только за HTTP.

ActivityMonitoring.Application/Events/ActivityEventService.cs:

using ActivityMonitoring.Domain.Entities;
using Microsoft.Extensions.Logging;

namespace ActivityMonitoring.Application.Events;

public class ActivityEventService : IActivityEventService
{
    private readonly IActivityEventRepository _repository;
    private readonly ILogger<ActivityEventService> _logger;

    public ActivityEventService(IActivityEventRepository repository, ILogger<ActivityEventService> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public async Task<ActivityEventDto> CreateAsync(CreateActivityEventRequest request, int userId, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(request.Type))
            throw new ArgumentException("Тип события обязателен.");

        var entity = new ActivityEvent
        {
            UserId = userId,
            Type = request.Type.Trim(),
            Source = request.Source.Trim(),
            Description = request.Description.Trim(),
            Severity = string.IsNullOrWhiteSpace(request.Severity) ? "Info" : request.Severity.Trim(),
            CreatedAt = DateTime.UtcNow
        };

        _logger.LogInformation("Create event {Type} for user {UserId}", entity.Type, userId);

        var saved = await _repository.AddAsync(entity, ct);
        return ToDto(saved);
    }

    public async Task<IReadOnlyList<ActivityEventDto>> GetAsync(ActivityEventFilter filter, CancellationToken ct)
    {
        var events = await _repository.GetAsync(filter, ct);
        return events.Select(ToDto).ToList();
    }

    public Task<bool> DeleteAsync(int id, CancellationToken ct)
    {
        return _repository.DeleteAsync(id, ct);
    }

    private static ActivityEventDto ToDto(ActivityEvent entity)
    {
        return new ActivityEventDto(
            entity.Id,
            entity.UserId,
            entity.User.UserName,
            entity.Type,
            entity.Source,
            entity.Description,
            entity.Severity,
            entity.CreatedAt);
    }
}

В конце зарегистрируйте интерфейсы и их реализации в DI-контейнере. Без этих строк ASP.NET Core не сможет создать сервис и репозиторий при обработке HTTP-запроса.

builder.Services.AddScoped<IActivityEventRepository, ActivityEventRepository>();
builder.Services.AddScoped<IActivityEventService, ActivityEventService>();

Результат

Приложение имеет рабочую бизнес-логику для создания, чтения и удаления событий. Контроллеры пока не добавлены, но сервис уже можно тестировать модульными тестами.

На этом этапе особенно важно проверить регистрацию зависимостей. Если забыть AddScoped<IActivityEventRepository, ActivityEventRepository>(), приложение соберётся, но упадёт при первом запросе к контроллеру, потому что DI-контейнер не поймёт, какой класс подставить вместо интерфейса.