MBL
Csharp / ЛР1: UserService — gRPC, PostgreSQL и слоистая архитектура на троих
Csharp средний

ЛР1: UserService — gRPC, PostgreSQL и слоистая архитектура на троих

grpcpostgresqldapperdiitmo

Доменный сервис на CRUD пользователей, реализованный через gRPC и PostgreSQL. Три человека в команде делали три слоя параллельно — ниже разбор всей работы: что каждый слой делает, зачем именно так, и на какие решения стоит быть готовым ответить на защите.

Задача и разбивка на слои

Сущность User: id, login, password, name, surname, age. Пять методов — CreateUser (с проверкой уникальности login), GetUserById, GetUserByName, UpdateUser (всё кроме id/login), DeleteUser.

Работу разложили на три блока, которые вызывают друг друга по цепочке:

gRPC-контроллер  →  доменный сервис  →  Repository  →  Postgres-функция
(Request/Response)   (Domain: User)      (DbModel)

Правило простое: каждый слой видит только модель соседнего уровня. Контроллер не знает про DbModel, репозиторий не знает про gRPC-Request. Это и есть требуемые лабой "три уровня моделей" — они физически разнесены по разным проектам/папкам, а не просто разные классы в одном месте.

Общий проект UserService.Contracts — это "язык", на котором слои договариваются: доменная модель User, интерфейсы IUserRepository/IUserService, доменные исключения UserNotFoundException/LoginAlreadyExistsException. Каждый слой реализует свой интерфейс и вызывает чужой только через контракт, никогда не заглядывая внутрь чужой реализации.

Таблица: пункт ТЗ → где это лежит в коде

Если на защите попросят "покажите, где у вас X" — вот прямой указатель, без поиска по проекту.

Пункт ТЗ Где реализовано
Entity User (id, login, password, name, surname, age) Contracts/Users/User.cs (домен), Persistence/DbModels/UserDbModel.cs (БД)
CreateUser + уникальность login UserGrpcService.CreateUser → UserService.CreateUserAsync → CreateUserValidator (ExistsByLoginAsync) → UserRepository.CreateAsync → fn_create_user + ux_users_login_lower
GetUserById UserGrpcService.GetUserById → UserService.GetUserByIdAsync → UserRepository.GetByIdAsync → fn_get_user_by_id
GetUserByName UserGrpcService.GetUserByName → UserService.GetUserByNameAsync → UserRepository.GetByNameAsync → fn_get_user_by_name + ix_users_name_surname_lower
UpdateUser (без id/login) UserMapper.ToDomain(UpdateUserRequest) (Login = "") → UserService.UpdateUserAsync → UserRepository.UpdateAsync → fn_update_user (без p_login вообще)
DeleteUser UserGrpcService.DeleteUser → UserService.DeleteUserAsync → UserRepository.DeleteAsync → fn_delete_user
СУБД PostgreSQL docker-compose.yml, образ postgres:16-alpine
Индексы под запросы Database/002_create_indexes.sql
Функция/процедура на метод, в Database/ Database/001_…008_*.sql
Вызов функций по имени из репозитория UserRepository.cs, SELECT * FROM fn_...(...) в каждом методе
Dapper + DynamicParameters UserRepository.cs, аргументы каждого вызова
Repository → массив, не IEnumerable IUserRepository.GetByNameAsync возвращает User[]
FluentValidation, Validate/ValidateAsync Validation/*RequestValidator.cs (структурная), Services/Users/Validators/* (бизнес)
Mapperly, мапперы в отдельных классах Persistence/Mapping/UserDbModelMapper.cs, Mapping/UserMapper.cs
CancellationToken сквозной параметр во всех async-методах всех слоёв, от gRPC до репозитория
3 уровня моделей Request/Response (.proto) ↔ Domain (Contracts/Users/User.cs) ↔ DbModel (Persistence/DbModels/UserDbModel.cs)
DI, по умолчанию Singleton AddUserRepository, AddUserService, AddUserGrpcLayer — везде AddSingleton

Блок 1 — PostgreSQL и Repository

Что вообще такое индекс. Без индекса Postgres ищет строку по условию WHERE login = ... единственным способом — прочитать все строки таблицы подряд и проверить каждую (full table scan, O(n) от числа строк).

Индекс — это отдельная структура рядом с таблицей, заранее отсортированная (в Postgres по умолчанию B-tree), которая хранит значения колонки вместе со ссылкой на физическое место строки. Поиск по индексу — это спуск по дереву, а не перебор таблицы, поэтому и получается O(log n) вместо O(n).

Плата не бесплатная: каждый INSERT/UPDATE по проиндексированной колонке обязан ещё и перестроить индекс — индексировать "на всякий случай" каждую колонку значит платить за это на каждой записи, а не только выигрывать на чтении.

Таблица и индексы в этом проекте. users с SERIAL PRIMARY KEY и CHECK (age BETWEEN 1 AND 120). Два индекса под два реальных паттерна запросов: UNIQUE INDEX ON users (lower(login)) — источник истины для уникальности логина и опора для ExistsByLoginAsync, и обычный INDEX ON users (lower(name), lower(surname)) — под GetUserByName. Оба регистронезависимы через lower(...), потому что Ivan и ivan должны считаться одним и тем же логином.

По функции на метод. Каждая CRUD-операция — отдельная plpgsql-функция (fn_create_user, fn_get_user_by_id, fn_get_user_by_name, fn_update_user, fn_delete_user) плюс шестая, fn_user_exists_by_login, под быструю проверку логина. fn_update_user/fn_delete_user не возвращают строку — они возвращают INT через GET DIAGNOSTICS v_affected_rows = ROW_COUNT, то есть число реально изменённых строк.

Почему вызов через SELECT, а не CommandType.StoredProcedure. Это неочевидное место, проверено прямо в исходниках Npgsql — стоит быть готовым объяснить на защите:

CommandType.StoredProcedure
// По умолчанию Npgsql строит "CALL fn_name(...)".
// CALL в Postgres работает только с PROCEDURE,
// а fn_create_user — это FUNCTION. Вызов упадёт
// с ошибкой "fn_create_user(...) is not a procedure".
var command = new CommandDefinition(
    "fn_create_user",
    parameters,
    commandType: CommandType.StoredProcedure);
CommandType.Text
// FUNCTION умеет то, что PROCEDURE не умеет так же
// просто — вернуть TABLE. Вызываем честным SELECT,
// это тоже вызов "по имени", просто без магии
// CommandType — и работает предсказуемо.
var command = new CommandDefinition(
    "SELECT * FROM fn_create_user(@p_login, @p_password, @p_name, @p_surname, @p_age)",
    parameters);

Repository. UserRepository использует NpgsqlDataSource (не голый NpgsqlConnection) — это пул соединений, живущий как Singleton, каждый метод берёт соединение через OpenConnectionAsync(). Аргументы передаются через DynamicParameters, как требует лаба. CreateAsync дополнительно ловит SQLSTATE 23505 и превращает его в LoginAlreadyExistsException — это защита от race condition поверх предварительной проверки в сервисе (два параллельных запроса с одним логином не проскочат оба).

DbModel (плоский класс под колонки таблицы) в домен превращается через Mapperly-маппер: [Mapper] public sealed partial class UserDbModelMapper { public partial User ToDomain(UserDbModel dbModel); }. Тело ToDomain не написано руками — Mapperly видит совпадающие по имени свойства и генерирует код маппинга на этапе компиляции (можно посмотреть сгенерированный .g.cs файл в obj/), в отличие от AutoMapper, который делает это рефлексией в рантайме и медленнее.

Всё регистрируется одним расширением AddUserRepository(configuration), все зависимости — Singleton (у NpgsqlDataSource, репозитория и маппера нет состояния между запросами, шарить один экземпляр безопасно и дешевле, чем создавать заново на каждый вызов).

Блок 2 — доменный сервис

UserService : IUserService (в Services/Users/) сидит между репозиторием и контроллером и отвечает за бизнес-правила, а не за форму данных.

Два валидатора, две модели вызова. UserValidator проверяет поля синхронно (Password/Name/Surname/Age — длины и диапазон) и вызывается через _validator.Validate(user), не ValidateAsync. Это прямое следование условию лабы: синхронный валидатор — синхронный вызов, лишняя async-машина без асинхронных правил внутри — просто накладные расходы. CreateUserValidator, наоборот, содержит ровно одно асинхронное правило — обращение к ExistsByLoginAsync — и поэтому вызывается через ValidateAsync.

Not-found через affected rows. UpdateUserAsync/DeleteUserAsync не спрашивают репозиторий "нашёл или нет" отдельным запросом — они смотрят на число, которое вернул UpdateAsync/DeleteAsync (int, а не bool, специально ради этого). count == 0 значит "строки с таким id не было" → UserNotFoundException. После успешного UpdateAsync сервис ещё раз перечитывает пользователя через GetByIdAsync — не потому что не доверяет предыдущему шагу, а потому что UpdateAsync возвращает только число, а не обновлённую строку, и её реально больше неоткуда взять.

Слабое место, к которому стоит быть готовым. UserValidator принимает IUserRepository в конструктор, но нигде его не использует — мёртвый параметр, остался, судя по всему, от более раннего варианта дизайна. Если спросят "зачем тут репозиторий" — по коду сейчас ответа нет, это на удаление.

Блок 3 — gRPC-контроллер

.proto описывает 5 rpc-методов один в один по названиям из условия. UserResponse намеренно не содержит password — [MapperIgnoreSource(nameof(User.Password))] на Mapperly-мапперe гарантирует, что пароль физически не попадёт в ответ, это не забытое поле, а явное решение.

Двухуровневая валидация. UserGrpcService сначала прогоняет Request через структурный IValidator<TRequest> (login/password не пустые и ≤ 64, name/surname ≤ 128, age в 1..120 — ровно то, что решено в контракте команды), и только потом мапит в User и идёт в сервис. Бизнес-валидатор (UserValidator) в сервисе проверяет те же диапазоны второй раз — это осознанное дублирование "на всякий случай", а не ошибка: домен не должен слепо доверять вызывающему слою.

Исключения → gRPC-статусы. ExceptionHandlingInterceptor — единая точка перехвата: ValidationException → InvalidArgument, UserNotFoundException → NotFound, LoginAlreadyExistsException → AlreadyExists, всё прочее — залогировать и вернуть Internal, не отдавая наружу детали (не палим стектрейс клиенту).

Почему исключения, а не Result<T>. Result<T> — рабочая альтернатива, но у неё есть цена именно на трёх слоях подряд:

Result<T> на трёх слоях
// Repository
public async Task<Result<User>> GetByIdAsync(int id, CancellationToken ct)
{
    var dbModel = await connection.QuerySingleOrDefaultAsync<UserDbModel>(cmd);
    return dbModel is null
        ? Result<User>.Failure("not found")
        : Result<User>.Success(mapper.ToDomain(dbModel));
}

// Service — обязан явно проверить чужой
// Result и вручную протащить его дальше
public async Task<Result<User>> GetUserByIdAsync(int id, CancellationToken ct)
{
    var result = await repository.GetByIdAsync(id, ct);
    if (!result.IsSuccess) return result;
    return Result<User>.Success(result.Value);
}

// Controller — снова проверка, и только
// здесь наконец решаем gRPC-статус
public override async Task<UserResponse> GetUserById(...)
{
    var result = await service.GetUserByIdAsync(id, ct);
    if (!result.IsSuccess)
        throw new RpcException(new Status(StatusCode.NotFound, result.Error));
    return mapper.ToResponse(result.Value);
}
Исключение + один Interceptor
// Repository
public async Task<User?> GetByIdAsync(int id, CancellationToken ct)
{
    var dbModel = await connection.QuerySingleOrDefaultAsync<UserDbModel>(cmd);
    return dbModel is null ? null : mapper.ToDomain(dbModel);
}

// Service — просто кидает и забывает
public async Task<User> GetUserByIdAsync(int id, CancellationToken ct)
{
    var result = await repository.GetByIdAsync(id, ct);
    if (result is null) throw new UserNotFoundException(id);
    return result;
}

// Controller вообще не думает про ошибки —
// Interceptor разберётся сам, один раз,
// сразу для всех пяти методов
public override async Task<UserResponse> GetUserById(...)
{
    var user = await service.GetUserByIdAsync(id, ct);
    return mapper.ToResponse(user);
}

С Result<T> каждый промежуточный слой обязан явно проверить IsSuccess и вручную протащить ошибку выше — на пять методов это 15 мест, где проверку легко забыть или перепутать код ошибки. С исключениями логика "как превратить ошибку в gRPC-статус" живёт ровно в одном месте, ExceptionHandlingInterceptor, а не размазана по слоям. Плюс сам gRPC и так устроен вокруг похожей модели: любой ответ либо успешен, либо несёт RpcException со StatusCode — граница системы всё равно exception-based, Result<T> пришлось бы превращать в исключение на этой границе в любом случае, просто позже и вручную.

Честная оговорка на случай встречного вопроса: исключения дороже в момент throw (разворачивание стека) и превращаются в антипаттерн, если ими подменяют частый, ожидаемый control flow — кидать исключение на каждый невалидный email в форме на высоконагруженном эндпоинте было бы плохой идеей. Здесь не тот случай: "пользователь не найден" и "логин уже занят" — это по-настоящему исключительные для клиента бизнес-исходы, а не ветка, которая срабатывает на каждый запрос.

Именной коллапс, который стоит понимать. В proto стоит option csharp_namespace = "UserService"; и service UserService, а сам проект тоже называется UserService (совпадает с корневым неймспейсом), и вдобавок доменный сервис — тоже класс UserService. Итого три разных вещи с одним именем в разных неймспейсах. В UserGrpcService.cs это решено явным алиасом:

using GeneratedUserService = global::UserService.UserService;

а в DependencyInjection.cs — явной квалификацией Users.UserService, а не голым UserService. C# резолвит неквалифицированное имя, поднимаясь по родительским неймспейсам — без этих уточнений компилятор в некоторых местах молча схватил бы не тот класс. Команда обошла это корректно, но название компонентов "как у самого проекта" — это риск для любого нового кода, который допишут позже без этой осторожности.

Путь одного запроса: CreateUser от клиента до ответа

Клиент шлёт CreateUserRequest{login, password, name, surname, age}. Дальше по шагам, без пропусков:

  1. UserGrpcService.CreateUser вызывает _createValidator.ValidateAndThrow(request) — это CreateUserRequestValidator, структурная проверка (login/password не пустые и ≤ 64, name/surname ≤ 128, age в 1..120). Провал → ValidationException.
  2. UserMapper.ToDomain(request) превращает CreateUserRequest в User — Mapperly копирует совпадающие по имени поля, Id явно проигнорирован ([MapperIgnoreTarget]), его ещё не существует.
  3. UserService.CreateUserAsync(user, ct) — вход в бизнес-слой: - _validator.Validate(user) — синхронно, ещё раз Password/Name/Surname/Age (домен не доверяет вызывающему слою вслепую); - _createUserValidator.ValidateAsync(user, ct) — единственное правило внутри дёргает IUserRepository.ExistsByLoginAsync(login, ct). Если true → правило падает → сервис кидает LoginAlreadyExistsException до похода в INSERT.
  4. UserRepository.CreateAsync(user, ct) — собирает DynamicParameters (p_login, p_password, p_name, p_surname, p_age), берёт соединение из NpgsqlDataSource, выполняет SELECT * FROM fn_create_user(...).
  5. fn_create_user в Postgres делает INSERT ... RETURNING — если UNIQUE INDEX ux_users_login_lower уже занят (два одновременных запроса проскочили шаг 3 до того, как первый успел записаться) — Postgres бросает ошибку с SQLSTATE 23505, репозиторий её ловит и превращает в тот же LoginAlreadyExistsException, только на уровень позже — это и есть защита от race condition.
  6. Строка из БД возвращается как UserDbModel, UserDbModelMapper.ToDomain превращает её обратно в User — теперь уже с реальным Id из SERIAL.
  7. UserService.CreateUserAsync возвращает User контроллеру, UserMapper.ToResponse(user) строит UserResponse — [MapperIgnoreSource(nameof(User.Password))] гарантирует, что пароль в ответ не попадёт.
  8. Если где-то по пути вылетело исключение — ExceptionHandlingInterceptor перехватывает его один раз, в одном месте, превращает в RpcException с нужным StatusCode, и только тогда это долетает до клиента.

GetUserById/GetUserByName/UpdateUser/DeleteUser идут по такой же схеме, отличаются только тем, какой валидатор на входе и какая функция в конце цепочки.

CancellationToken: что реально происходит при отмене

В шаге 3 и 4 выше ct (CancellationToken) прошёл через все три слоя не для галочки — это единственное, что делает отмену запроса настоящей, а не притворной.

Откуда он берётся. У каждого gRPC-вызова есть ServerCallContext, а у него — CancellationToken, который срабатывает в двух случаях: клиент сам отменил вызов (закрыл соединение, истёк deadline) или сам ASP.NET Core оборвал вызов. UserGrpcService берёт его из context.CancellationToken и передаёт вниз — в UserService, оттуда в UserRepository, оттуда в CommandDefinition для Dapper, а Dapper — в Npgsql.

Почему важно донести токен до самого низа, а не остановиться на полпути:

Без CancellationToken в CommandDefinition
var command = new CommandDefinition(
    "SELECT * FROM fn_get_user_by_id(@p_id)",
    parameters);
// Клиент отменил вызов — await в C# просто
// перестаёт ждать, метод возвращает
// управление раньше. Но Postgres об этом
// ничего не знает: запрос на сервере БД
// продолжает выполняться до конца, просто
// его результат уже никому не нужен.
С CancellationToken в CommandDefinition
var command = new CommandDefinition(
    "SELECT * FROM fn_get_user_by_id(@p_id)",
    parameters,
    cancellationToken: cancellationToken);
// Npgsql отправляет в Postgres настоящий
// протокольный Cancel-запрос. Работающий
// запрос на сервере БД реально прерывается,
// а не просто перестаёт быть кому-то нужным.

Без прокидывания токена вниз отмена — это иллюзия только на уровне C#-await, а реальная работа (чтение с диска, блокировки строк в Postgres) продолжает выполняться впустую. Именно поэтому CancellationToken — обязательный параметр каждого async-метода на каждом уровне здесь, без значения по умолчанию: забыть прокинуть его хотя бы в одном месте цепочки значит сделать отмену настоящей только до этого места.

Program.cs, appsettings и docker-compose — как всё собирается

Program.cs — composition root, он не содержит логики, только сборку DI-контейнера:

builder.Services.AddGrpc(options =>
{
    options.Interceptors.Add<ExceptionHandlingInterceptor>();
});

builder.Services.AddUserGrpcLayer();     // Илья: мапперы, request-валидаторы, интерцептор
builder.Services.AddUserRepository(builder.Configuration); // Руслан: БД
builder.Services.AddUserService();       // Оля: бизнес-логика

var app = builder.Build();
app.MapGrpcService<UserGrpcService>();   // после Build() — раньше сервис-провайдера ещё нет

Порядок трёх Add* между собой не важен — это просто регистрация типов в контейнере, ничего ещё не выполняется. А MapGrpcService обязан идти строго после builder.Build(), потому что до этого момента app (и его IServiceProvider) ещё не существует.

AddUserRepository внутри читает builder.Configuration.GetConnectionString("UserServiceDb") — эта строка живёт в appsettings.json, и она обязана совпадать с POSTGRES_USER/POSTGRES_PASSWORD/POSTGRES_DB из docker-compose.yml (сейчас везде user_service/user_service/user_service) — если поменяли одно, надо поменять и второе, никакой синхронизации между файлами нет.

Практическая ловушка с docker-entrypoint-initdb.d: Postgres выполняет .sql-скрипты из этой папки только при первом старте контейнера на пустом volume. Если контейнер уже когда-то поднимался и в user_service_db_data уже есть данные — правка любого файла в Database/ (новая функция, новый индекс) молча не применится, пока не сделать docker compose down -v (или -v явно удалить volume) и поднять заново. Частая причина "я поправил SQL, а ничего не изменилось" на этой лабе.

Как поднять и проверить

docker compose up -d
dotnet build
dotnet run --project UserService

docker-compose.yml монтирует UserService/Database как /docker-entrypoint-initdb.d — Postgres накатывает таблицу, индексы и все функции сам при первом старте контейнера (файлы пронумерованы 001_…008_, порядок применения = алфавитный = числовой).

Шпаргалка: кто что спросит

  • "Почему Singleton, а не Scoped?" — у репозитория, сервиса и мапперов нет состояния между запросами; NpgsqlDataSource сам по себе пул соединений, ему незачем плодиться на каждый запрос.
  • "Что будет, если два запроса одновременно создадут одного и того же пользователя?" — оба пройдут ExistsByLoginAsync (там ещё нет строки), оба пойдут в INSERT, но UNIQUE INDEX пропустит только первый — второй получит SQLSTATE 23505, репозиторий превратит это в LoginAlreadyExistsException, Interceptor — в AlreadyExists.
  • "Почему GetUserByName с пустым результатом — не ошибка?" — так решено в контракте команды: отсутствие совпадений это валидный ответ (пустой массив), а не исключительная ситуация, в отличие от GetById/Update/Delete по несуществующему id.
Самопроверка 0 / 13
Понимаю, почему DbModel не выходит за пределы Repository, а Request/Response — за пределы gRPC-слоя, и что каждый слой видит только модель соседнего уровня
Могу объяснить двухуровневую проверку уникальности login: ExistsByLoginAsync в сервисе (быстрый отказ) + UNIQUE-индекс с перехватом SQLSTATE 23505 в репозитории (источник истины)
Понимаю, почему SQL-функции вызываются через SELECT * FROM fn(...), а не через CommandType.StoredProcedure
Могу объяснить, что Mapperly генерирует тело partial-методов на этапе компиляции, а не через рефлексию в рантайме, как AutoMapper
Понимаю, почему UserValidator вызывается через Validate, а не ValidateAsync, и когда это было бы неверно
Могу объяснить, зачем UpdateAsync и DeleteAsync в репозитории возвращают int (affected rows), а не bool
Знаю, как UserNotFoundException и LoginAlreadyExistsException превращаются в gRPC-статусы NotFound и AlreadyExists через Interceptor
Понимаю, почему все зависимости в DI зарегистрированы как Singleton
Могу пройти по шагам весь путь CreateUser от клиента до ответа, не подглядывая в код
Знаю, что Postgres применяет скрипты из docker-entrypoint-initdb.d только при первом старте на пустом volume — правку .sql нужно накатывать через docker compose down -v
Понимаю, что такое индекс на уровне B-дерева: почему поиск ускоряется с O(n) до O(log n), и почему не стоит индексировать всё подряд
Понимаю, что непрокинутый в CommandDefinition CancellationToken не отменяет запрос в Postgres, а только перестаёт его ждать на C#-стороне
Могу объяснить, почему выбраны исключения + один Interceptor, а не Result<T> на каждом из трёх слоёв, и какая у этого выбора честная цена
Как усвоено?