IUserRepository в CsTestShop
правильная подстановка через DI
Возьмём пример из CsTestShop .
В проекте есть интерфейс IUserRepository, который описывает работу с
пользователями в базе данных, и его единственная реализация — UserRepository.
В Program.cs они связываются через DI-контейнер:
builder.Services.AddScoped<IUserRepository, UserRepository>();На первый взгляд — обычная строка регистрации. На самом деле это живая
демонстрация принципа подстановки Лисков: контейнер гарантирует, что везде,
где запрошен IUserRepository, будет подставлен UserRepository, и код будет
работать так, как ожидает клиент. Разберём, почему это работает.
Код
Интерфейс IUserRepository
public interface IUserRepository
{
Task<User> GetUser(int id);
Task<User> GetUserByEmail(string email);
Task<User> CreateUser(
string email,
string name,
string passwordHash,
string accessToken,
string refreshToken
);
Task<User> UpdateUser(int id, string? email, string? name, string? role);
Task<bool> DeleteUser(int id);
}Реализация UserRepository
public class UserRepository : IUserRepository
{
private readonly string _connection_string =
DatabaseService.CONNECTION_STRING();
public async Task<User> GetUser(int id)
{
// ... SELECT по id
}
public async Task<User> GetUserByEmail(string email)
{
// ... SELECT по email
}
public async Task<User> CreateUser(
string email,
string name,
string passwordHash,
string accessToken,
string refreshToken
)
{
// ... INSERT
}
public Task<User> UpdateUser(
int id,
string? email,
string? name,
string? role
)
{
throw new NotImplementedException(); // 🚧 WIP
}
public Task<bool> DeleteUser(int id)
{
throw new NotImplementedException(); // 🚧 WIP
}
}Регистрация в DI
builder.Services.AddScoped<IUserRepository, UserRepository>();Клиент — AuthenticationRouter
internal class AuthenticationRouter(
IUserRepository userRepository,
IUserService userService
) : Authenticator.AuthenticatorBase
{
public override async Task<UserReply> Register(
UserInit request,
ServerCallContext context
)
{
UserCredentials credentials = userService.CreateUserCredentials(
new BaseUser { Email = request.Email },
request.Password
);
User user = await userRepository.CreateUser(
request.Email,
request.Name,
credentials.Password,
credentials.AccessToken,
credentials.RefreshToken
);
return userService.CreateReply(user);
}
public override async Task<UserReply> Login(
UserLogin request,
ServerCallContext context
)
{
User user = await userRepository.GetUserByEmail(request.Email);
if (!userService.ComparePassword(user, request.Password))
{
throw new HttpRequestException();
}
return userService.CreateReply(user);
}
}Обрати внимание: AuthenticationRouter никогда не знает о UserRepository.
Он принимает IUserRepository в конструкторе и работает только с методами,
объявленными в интерфейсе.
Что здесь хорошо
1. Подстановка работает без сюрпризов
AuthenticationRouter объявляет зависимость от IUserRepository. DI-контейнер
подставляет UserRepository. Клиент вызывает CreateUser и GetUserByEmail —
и получает ровно то, что обещает сигнатура:
CreateUserвозвращаетTask<User>— и действительно возвращает созданного пользователя с заполненными полями.GetUserByEmailвозвращаетTask<User>— и действительно возвращает пользователя, если он есть.
Никаких «вернётся, но не всегда», никаких «может упасть, если что-то не так». Контракт соблюдён — значит, подстановка безопасна. Это и есть LSP в действии.
2. AddScoped<IUserRepository, UserRepository>() — это и есть обещание
В C# регистрация в DI — это декларация подстановки. Строка
AddScoped<IUserRepository, UserRepository>() говорит контейнеру:
«Везде, где кто-то запросит
IUserRepository, подставь емуUserRepository. И будь уверен, что клиент не заметит разницы».
Это ровно та самая формулировка LSP: «Объекты в программе должны быть
заменяемы экземплярами их подтипов без изменения правильности выполнения
программы». Регистрация в DI — это техническая реализация этой идеи. Если бы
UserRepository нарушал контракт, DI бы этого не заметил (C# не проверяет LSP
на этапе компиляции), но клиент — заметил бы. В нашем случае этого не
происходит.
3. Клиент не знает о реализации
AuthenticationRouter использует только методы интерфейса. Он не знает:
- Что под капотом PostgreSQL.
- Что используется
NpgsqlConnection. - Как формируются SQL-запросы.
Он знает только контракт. Это и есть преимущество LSP: клиент может не думать о деталях реализации, потому что уверен — любая корректная реализация ведёт себя одинаково.
4. Легко мокать в тестах
Благодаря тому, что UserRepository полностью соответствует контракту, в тестах
можно подставить мок:
var mockRepository = new Mock<IUserRepository>();
mockRepository
.Setup(r => r.GetUserByEmail("test@example.com"))
.ReturnsAsync(new User { /* ... */ });
var router = new AuthenticationRouter(mockRepository.Object, userService);Тест работает, потому что мок ведёт себя как UserRepository с точки зрения
клиента: возвращает Task<User> для корректного email. Если бы UserRepository
где-то бросал неожиданное исключение или возвращал null вместо User, мок бы
этого не воспроизвёл — и тест бы прошёл, а прод упал. Но контракт соблюдён,
поэтому мок и реальная реализация взаимозаменяемы.
5. ProductRepository, OrderRepository — та же история
В Program.cs зарегистрированы ещё два репозитория:
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();Обе реализации — завершены и полностью соответствуют своим интерфейсам.
ProductRepository.UpdateProduct возвращает Product или бросает
KeyNotFoundException, если запись не найдена — ровно как ожидает
IProductRepository. OrderRepository.CancelOrder возвращает bool —
«отменилось или нет», и это часть контракта, а не сюрприз. Клиенты
(ProductRouter, OrderRouter) не знают, что под капотом SQL, и не должны.
🚧 Про WIP-методы
UpdateUser и DeleteUser в UserRepository бросают
NotImplementedException. Это не нарушение LSP, а незавершённая работа:
методы ещё не написаны. В DOCUMENTATION.md проекта прямо указано:
Обновление пользователя и удаление — методы
UpdateUserиDeleteUserне реализованы.
Такие методы не учитываются при анализе LSP. Принцип подстановки применяется к готовому коду, который считается корректным. Если бы мы учитывали WIP, то любая незаконченная фича автоматически «нарушала» бы LSP — что бессмысленно.
Когда методы будут дописаны, они должны соответствовать контракту интерфейса:
UpdateUser вернёт Task<User>, DeleteUser — Task<bool>. Тогда подстановка
останется безопасной. Если же в процессе реализации разработчик решит, например,
что UpdateUser при ненайденном пользователе возвращает null (а GetUser при
этом бросает KeyNotFoundException), — вот тогда появится LSP-нарушение, и
его нужно будет чинить.
Важно для читателя:
NotImplementedExceptionв проекте — это маркер «здесь ещё не готово», а не «здесь нарушен LSP». Не путайте эти два состояния. Когда код завершён, он либо соответствует контракту (тогда LSP соблюдён), либо нет (тогда LSP нарушен). WIP — вне этой дихотомии.
Итог
IUserRepository и UserRepository — пример правильной подстановки:
- Реализация полностью соответствует контракту интерфейса — по завершённым методам.
- DI-контейнер подставляет
UserRepositoryвезде, где ожидаетсяIUserRepository, и клиенты этого не замечают. - Клиенты (
AuthenticationRouter) работают только с абстракцией и не знают о деталях реализации. - Тесты легко подставляют моки, потому что мок и реальная реализация ведут себя одинаково.
Запомните: LSP — это не только про иерархии классов. Это про любую подстановку: интерфейс + реализация, базовый класс + наследник, абстракция + конкретика. DI-контейнер — это техническая точка, где подстановка происходит. Если она безопасна (клиент не замечает разницы) — LSP соблюдён. Если нет — нарушен. И самое главное:
NotImplementedException— это WIP, а не нарушение. Анализируйте только завершённый код.
Исходный код доступен в репозитории CsTestShop .