[Перевод] Использование DI в DotNet чтобы не таскать UserId сквозь все слои приложения


Зачем всё это
Представьте себе классическое такое ынтерпрайз приложжение с несколькими слоями логики. Хоть те самые презентация, домен, инфраструктура. Хотя большинство реальных приложений имеют еще больше слоев, лазанья обзавидуется.
На самом глубоком уровне вам неожиданно нужна информация о текущем пользователе: его ID и его пермишны. В результате вы получаете множество вложенных пробросов, когда UserContext передается через каждый слой:
[ApiController]
[Route("[controller]")]
public class ExampleController(ApplicationService application) : ControllerBase
{
public IActionResult List()
{
var userId = User.Claims.FirstOrDefault(c => c.Type == "uid")?.Value;
var permissions = User.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList();
var userContext = new UserContext
{
UserId = userId,
Roles = permissions
};
application.PerformApplicationOperation(userContext);
return Ok();
}
}
public class ApplicationService(Layer2Service layer2)
{
public void PerformApplicationOperation(UserContext userContext)
{
layer2.PerformLayerOperation(userContext);
}
}
public class Layer2Service(Layer3Service layer3)
{
public void PerformLayerOperation(UserContext userContext)
{
layer3.PerformLayer3Operation(userContext);
}
}
public class Layer3Service
{
public void PerformLayer3Operation(UserContext userContext)
{
// some logic here
}
}
public class UserContext
{
public string? UserId { get; set; }
public List<string> Roles { get; set; } = [];
}Это работает, но в каждом из промежуточных методов неожиданно оказывается UserId, даже если этим сервисам сам он вообще не особо нужен.
С CQRS всё становится еще хуже
С CQRS все становится еще хуже, так как там из ниоткуда появляется ещёё более глубокая иерархия вызовов. И каждая команда и каждый запрос в итоге тащат за собой пользовательские параметры:
public record ImportDocumentsCommand(
Guid ExternalSystemDocumentIג
Guid? UserId,
HashSet<InspectionCompany> UserCompanies,
bool IsSuperAdmin
) : IQuery<List<MyAmazingDocuments>;Давайте вместе поможем Даше понять зачем тут все пераметры кроме первого, ага.
Более чистый подход: Scoped‑сервис через DI
Как это можно улучшись, особенно для ситуаций, когда вам нужен UserContext только в Layer 3?
Тут можем помочь DI‑сервис, который хранит UserContext и делает его доступным в течение всего запроса. В ASP.NET Core у чаще всего запрос = di scope, поэтому сервис должен быть зарегистрирован как Scoped.
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddScoped<ApplicationService>();
builder.Services.AddScoped<Layer2Service>();
builder.Services.AddScoped<Layer3Service>();К счастью, или к сожалению (в зависимости от взгляда) после создания ServiceProvider вы не можете напихать в него новые сервисы. Но мутабельность пропертей в серсиах никто не отменял:
public class ExampleController(ApplicationService application, UserContext userContext) : ControllerBase
{
[HttpGet]
public IActionResult List()
{
userContext.UserId = User.Claims.FirstOrDefault(c => c.Type == "uid")?.Value;
userContext.Roles = User.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList();
application.PerformApplicationOperation();
return Ok();
}
}Менять промерти у UserScope относительно безопасно, так как он scoped и соседние запросы не затронет. Тут было бы конечно здорово сделать UserContext immutable за пределами контроллера, но это для целей данной статьи — оверкилл.
public class Layer3Service(UserContext userContext)
{
public void PerformDeepestOperation()
{
Log.LogInformation($"Тут был {userContext.UserId}. И всё испортил(а)");
}
}Ну и теперь больше никакого проброса UserContext через ApplicationService и Layer2Service.
Убираем бойлерплейт с помощью Middleware
Копипастить логику создания пользовательского контекста в каждый экшн контроллера — фу, поэтому вынесем ее в middleware:
public async Task Invoke(HttpContext httpContext, UserContext userContext)
{
userContext.UserId = httpContext.User.Claims.FirstOrDefault(c => c.Type == "uid")?.Value;
userContext.Roles = httpContext.User.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList();
await next(httpContext);
}Теперь контекст заполняется один раз за запрос, до того как он дойдет до контроллера, и любой сервис в цепочке может просто заинжектить UserContext, когда он ему надо.
А минусы будут
У этого подхода есть очевидный минус, так как тут работает старое правило: явное лучше неявного. Когда вы передаете параметры пользователя как параметры метода, зависимость полностью видна в сигнатуре. Это прям сильно упрощает отслеживание потока данных, анализ и тестирование. С мутабельным контекстом у новичка на проекте возникнет очевидный вопрос «а это вообще откуда». И кто‑нибудь обязательно воспользуется методом, в окружении без пользователя. В большой семье это легком может деградировать с нормальную такую глобальную переменную посыпанную сахаром, скрывающую реальные архитектурные зависимости.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.