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-контейнер не поймёт, какой класс подставить вместо интерфейса.