Олег Марков
Регистрация компонентов component-registration в приложениях с внедрением зависимостей
Введение
Регистрация компонентов (component-registration) — это фундаментальный шаг при настройке любого приложения, где используется инверсия управления (IoC) и внедрение зависимостей (DI). От того, как именно вы регистрируете компоненты, зависит:
- насколько легко будет расширять проект;
- насколько просто вы сможете заменить реализацию интерфейса;
- как удобно будет покрывать код тестами;
- будет ли у вас «разрастание»
new()иmake()по всему коду или все зависимости будут собраны централизованно.
В этой статье я покажу вам, как организовать регистрацию компонентов на примере DI-контейнера, какие есть типы регистрации, как управлять жизненным циклом объектов и как избежать типичных ошибок. Мы не будем привязываться к какому-то одному фреймворку, но для наглядности я буду использовать примеры, похожие на то, как это делается в распространенных DI-контейнерах (например, в .NET, NestJS, Angular или Java Spring с аннотациями).
Смотрите, шаг за шагом вы увидите, как настроить регистрацию так, чтобы архитектура оставалась гибкой и предсказуемой.
Что такое регистрация компонентов
Компонент, служба, сервис — о чем речь
Под «компонентом» в контексте component-registration обычно понимается:
- класс или структура, реализующая некоторую бизнес-логику;
- объект, предоставляющий интерфейс для других частей системы (сервис);
- адаптер к внешней системе (репозиторий, клиент API, драйвер и т.п.).
Главное свойство компонента — он может быть зависимостью для других объектов. То есть другие классы могут ожидать его в конструкторе или в другом месте, не создавая его напрямую.
Зачем вообще нужна регистрация
Регистрация компонентов решает две задачи:
Связать интерфейс с конкретной реализацией.
Например:- есть интерфейс
IUserRepository; - есть конкретный класс
SqlUserRepository; - при регистрации вы указываете: "когда кто-то просит
IUserRepository, отдай экземплярSqlUserRepository".
- есть интерфейс
Определить правила создания и времени жизни объекта.
Вы задаете:- когда создавать объект (один раз на приложение / на запрос / каждый раз новый);
- как его инициализировать;
- как освободить ресурсы, когда он больше не нужен.
Без регистрации вам пришлось бы создавать объекты вручную во всех местах, где они нужны. Это усложняет тестирование и делает систему жестко связанной с конкретными реализациями.
Где обычно происходит регистрация
Обычно регистрация компонентов сосредоточена в одном или нескольких конфигурационных местах:
- точка входа приложения;
- модуль конфигурации (например,
Startup,AppModule,CompositionRoot); - отдельные модули/пакеты, которые экспортируют функцию-расширение для регистрации своих сервисов.
Давайте разберемся, какие вообще есть типы регистрации и как они выглядят в коде.
Основные виды регистрации компонентов
Регистрация по интерфейсу и по конкретному типу
Чаще всего регистрируют реализацию по контракту (интерфейсу). Смотрите, я покажу вам, как это выглядит на условном примере в стиле C#:
// Интерфейс - контракт для работы с пользователями
public interface IUserRepository
{
User GetById(Guid id);
}
// Конкретная реализация интерфейса
public class SqlUserRepository : IUserRepository
{
private readonly DbConnection _connection;
// Зависимость передается через конструктор
public SqlUserRepository(DbConnection connection)
{
_connection = connection;
}
public User GetById(Guid id)
{
// Здесь мы используем подключение к базе
// для выборки пользователя по Id
// Реализация опущена для краткости
return new User();
}
}
// Регистрация компонента в контейнере
services.AddScoped<IUserRepository, SqlUserRepository>();
// Здесь мы говорим контейнеру - когда кто-то запросит IUserRepository,
// отдай экземпляр SqlUserRepository, созданный с учетом его зависимостей
Важно понимать: вы не создаете SqlUserRepository напрямую. Этим занимается контейнер, который знает, какие зависимости нужны и как их достать.
Иногда используют регистрацию по конкретному типу, без интерфейса:
// Регистрация конкретного класса без интерфейса
services.AddSingleton<HealthCheckService>();
// Теперь любые потребители, которые зависят от HealthCheckService,
// получат этот тип напрямую, без абстракции
Так делают, если:
- реализация уникальна и не предполагается альтернатив;
- вы не хотите усложнять архитектуру лишними абстракциями.
Регистрация по имени / токену
В некоторых DI-контейнерах (часто в JavaScript/TypeScript мирах) интерфейсов на уровне рантайма нет, поэтому используют токены или строковые имена.
Пример на псевдо-TypeScript стиле, похожем на NestJS:
// Токен для интерфейсоподобной зависимости
export const USER_REPOSITORY = 'USER_REPOSITORY';
// Класс реализации
export class SqlUserRepository {
// Здесь могла быть реализация методов работы с БД
}
// Регистрация провайдера с использованием токена
{
provide: USER_REPOSITORY, // уникальный токен
useClass: SqlUserRepository, // класс-реализация
}
Теперь любой компонент, который «запрашивает» зависимость по токену USER_REPOSITORY, получит экземпляр SqlUserRepository.
Фабричная регистрация (useFactory / delegate)
Иногда одного new недостаточно. Например, вам надо:
- прочитать конфигурацию;
- проинициализировать подключение к внешнему сервису;
- или выбрать реализацию в зависимости от окружения.
Тогда используют фабричные методы при регистрации.
Давайте посмотрим пример в стиле C#:
// Регистрация через фабрику - используем делегат,
// который сам создает экземпляр нужного типа
services.AddSingleton<IUserRepository>(serviceProvider =>
{
// Получаем зависимости из контейнера
var config = serviceProvider.GetRequiredService<IConfiguration>();
var connectionString = config.GetConnectionString("Default");
// Создаем подключение к БД вручную
var connection = new SqlConnection(connectionString);
connection.Open(); // Инициализируем соединение
// Возвращаем готовый репозиторий
return new SqlUserRepository(connection);
});
Комментарии к примеру:
- контейнер вызывает переданный делегат, когда впервые нужно создать
IUserRepository; - внутри делегата вы можете использовать любые зависимости из того же контейнера;
- вы полностью контролируете процесс инициализации объекта.
В мирах JS/TS это часто выглядит так:
{
provide: USER_REPOSITORY,
useFactory: (configService: ConfigService) => {
// Читаем конфигурацию
const connString = configService.get('DB_CONNECTION');
// Создаем и возвращаем репозиторий
return new SqlUserRepository(connString);
},
inject: [ConfigService], // Указываем, какие зависимости нужны фабрике
}
Регистрация через существующий экземпляр (instance / useValue)
Бывает, что объект уже создан где-то снаружи:
- его создает сторонняя библиотека;
- это объект из среды исполнения (браузерный
window, соединение с нативным SDK и т.п.); - вы хотите иметь строго один заранее инициализированный экземпляр.
Тогда компонент регистрируют через готовое значение.
Пример:
// У нас есть уже созданное подключение к БД
var sharedConnection = new SqlConnection("...");
sharedConnection.Open();
// Регистрируем его как Singleton-экземпляр для интерфейса DbConnection
services.AddSingleton<DbConnection>(sharedConnection);
// Теперь все зависящие от DbConnection получат именно этот объект
В TypeScript/JS:
const configObject = {
env: 'production',
apiBaseUrl: 'https://api.example.com',
};
// Регистрируем объект как значение
{
provide: 'APP_CONFIG',
useValue: configObject,
}
Как видите, это позволяет встроить в DI-контейнер уже существующие экземпляры и использовать их как обычные зависимости.
Управление временем жизни компонентов
Один из самых важных аспектов component-registration — правильно определить lifetime, или время жизни компонента.
Основные типы жизненного цикла
В большинстве DI-контейнеров встречаются три базовых времени жизни:
- Singleton — один экземпляр на все приложение.
- Scoped — один экземпляр на контекст (часто на HTTP-запрос).
- Transient — новый экземпляр при каждом запросе зависимости.
Давайте разберем каждый из них на примерах.
Singleton
Singleton создается один раз за все время работы приложения.
// Сервис логирования - типичный кандидат в Singleton
services.AddSingleton<ILog, ConsoleLog>();
Когда вы регистрируете компонент как Singleton:
- первый запрос к нему создает экземпляр;
- все последующие запросы получают тот же объект.
Используйте Singleton, когда:
- объект легковесный и потокобезопасный;
- он не держит в себе состояние, зависящее от пользователя или запроса;
- вы хотите кэшировать результат дорогостоящей инициализации.
Не стоит делать Singleton из компонентов, которые:
- содержат пользовательский контекст;
- зависят от временных данных;
- не потокобезопасны.
Scoped
Scoped обычно создается один раз на определенный «контекст»:
- HTTP-запрос;
- фоновую задачу;
- транзакцию.
Пример:
// UserService зависит от контекста запроса
services.AddScoped<IUserService, UserService>();
Смотрите, как это обычно работает:
- когда приходит новый запрос, создается «scope» (область контейнера);
- внутри этого scope все
Scoped-сервисы создаются по одному экземпляру; - после окончания запроса scope уничтожается вместе с сервисами.
Это удобно для:
- сервисов бизнес-логики, работающих с данными одного запроса;
- контекста базы данных (например,
DbContextв EF Core); - сервисов, которые должны быть изолированы между запросами.
Важно: Scoped-компонент не должен попадать в Singleton как зависимость (иначе он может жить дольше, чем его область, и вы получите ошибки или утечки ресурсов).
Transient
Transient создается каждый раз, когда кто-то его запрашивает.
// Вспомогательный сервис без состояния - кандидат на Transient
services.AddTransient<IEmailBuilder, EmailBuilder>();
Плюсы Transient:
- всегда чистое состояние;
- нет риска условного «залипания» данных между вызовами.
Минусы:
- может быть дороже по производительности, если создание тяжелое;
- большое количество экземпляров при интенсивной работе.
Используйте Transient для:
- легковесных, статeless-компонентов;
- вспомогательных конструкторов моделей, мапперов, форматтеров.
Как lifetime влияет на зависимости
Есть простое правило: зависимости из контейнера не должны жить меньше, чем те, кто их использует.
Условно:
- Singleton может зависеть только от Singleton;
- Scoped может зависеть от Singleton и Scoped;
- Transient может зависеть от любых.
Если Singleton попытается использовать Scoped-компонент, это часто приводит к ошибкам при построении графа зависимостей.
Стратегии регистрации: модульный подход
Централизованная vs модульная регистрация
Есть два распространенных подхода:
- Централизованная регистрация — все компоненты регистрируются в одном месте (например, в
Program/Startup). - Модульная регистрация — каждый модуль (пакет, сборка) сам «умеет» регистрировать свои зависимости.
Смотрите, пример модульной регистрации в стиле C#:
// Внутри модуля Users
public static class UsersModuleRegistration
{
// Расширение для IServiceCollection
public static IServiceCollection AddUsersModule(this IServiceCollection services)
{
// Регистрируем все, что относится к модулю Users
services.AddScoped<IUserRepository, SqlUserRepository>();
services.AddScoped<IUserService, UserService>();
return services;
}
}
Затем в точке входа приложения:
// В Program или Startup
services.AddUsersModule();
// Здесь мы просто подключаем модуль, не вдаваясь в детали его реализации
Такой подход:
- изолирует знания о внутренних зависимостях модуля;
- упрощает переиспользование модулей между проектами;
- уменьшает размер и сложность конфигурации в точке входа.
Автоматическая регистрация
Некоторые контейнеры умеют автоматически находить и регистрировать компоненты:
- по атрибутам/аннотациям (например,
@Injectable,@Component); - по соглашениям о нейминге или расположении файлов.
Пример в стиле TypeScript (похож на Angular/NestJS):
@Injectable() // Декоратор говорит контейнеру зарегистрировать этот класс
export class UserService {
// Конструкторные зависимости будут внедрены автоматически
constructor(private readonly userRepository: UserRepository) {}
}
Вам остается только связать модуль:
@Module({
providers: [UserService, UserRepository],
exports: [UserService],
})
export class UsersModule {}
Преимущества:
- меньше явного кода регистрации;
- проще поддерживать большие проекты.
Недостатки:
- магия — сложнее понять, откуда взялся тот или иной компонент;
- сложнее контролировать время жизни и конфигурацию, если она не указана явно.
Типичные паттерны регистрации
Регистрация слоями (слой за слоем)
Удобно группировать регистрацию по слоям архитектуры:
- инфраструктурный слой (БД, кэш, логирование);
- доменный/бизнес-слой (сервисы предметной области);
- слой приложений/интерфейса (контроллеры, хендлеры запросов).
Пример:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
{
// Регистрируем инфраструктуру
services.AddSingleton<ILog, ConsoleLog>();
services.AddScoped<IDbContext, AppDbContext>();
return services;
}
public static IServiceCollection AddDomain(this IServiceCollection services)
{
// Регистрируем доменные сервисы
services.AddScoped<IUserService, UserService>();
services.AddScoped<IOrderService, OrderService>();
return services;
}
}
В точке входа:
services
.AddInfrastructure()
.AddDomain();
Как видите, такой подход делает конфигурацию читаемой и логически структурированной.
Регистрация открытых дженериков
Иногда вам нужны дженерик-сервисы, вроде репозиториев:
// Обобщенный интерфейс репозитория
public interface IRepository<T> where T : class
{
T GetById(Guid id);
void Add(T entity);
}
// Обобщенная реализация
public class EfRepository<T> : IRepository<T> where T : class
{
private readonly DbContext _context;
public EfRepository(DbContext context)
{
_context = context;
}
public T GetById(Guid id)
{
// Здесь реализуем выборку по Id
return _context.Set<T>().Find(id);
}
public void Add(T entity)
{
_context.Set<T>().Add(entity);
}
}
Регистрируется это так:
// Регистрируем открытый дженерик
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
// Теперь IRepository<User> и IRepository<Order> будут созданы на основе EfRepository<T>
Контейнер сам подставит конкретный тип параметра T при разрешении зависимости.
Декораторы и обёртки
Иногда нужно обернуть компонент в дополнительное поведение:
- логирование;
- кэширование;
- ретраи при ошибках.
Это можно сделать через декоратор, который регистрируется поверх базовой реализации.
Пример:
// Интерфейс сервиса отправки писем
public interface IEmailSender
{
void Send(EmailMessage message);
}
// Базовая реализация
public class SmtpEmailSender : IEmailSender
{
public void Send(EmailMessage message)
{
// Отправляем письмо через SMTP
}
}
// Декоратор с логированием
public class LoggingEmailSender : IEmailSender
{
private readonly IEmailSender _inner;
private readonly ILog _log;
public LoggingEmailSender(IEmailSender inner, ILog log)
{
_inner = inner;
_log = log;
}
public void Send(EmailMessage message)
{
// Логируем отправку
_log.Info("Sending email...");
_inner.Send(message);
_log.Info("Email sent");
}
}
Регистрация (на примере контейнера, поддерживающего декораторы):
services.AddScoped<IEmailSender, SmtpEmailSender>();
services.Decorate<IEmailSender, LoggingEmailSender>();
// Теперь при запросе IEmailSender вы получите LoggingEmailSender,
// который внутри использует SmtpEmailSender
Это гибкий способ добавлять функциональность, не модифицируя исходную реализацию.
Практический пример: настройка регистрации в веб-приложении
Чтобы связать все кусочки воедино, давайте разберем упрощенное веб-приложение и его схему регистрации.
Структура слоев
Представим, что у нас есть:
- контроллеры (слой API);
- сервисы доменной логики (
UserService,OrderService); - репозитории (
UserRepository,OrderRepository); - инфраструктурные сервисы (
DbContext,Logger).
Теперь вы увидите, как это выглядит в коде.
Базовые интерфейсы и реализации
// Контекст БД
public interface IAppDbContext
{
DbSet<User> Users { get; }
DbSet<Order> Orders { get; }
int SaveChanges();
}
public class AppDbContext : DbContext, IAppDbContext
{
// Здесь могла быть конфигурация контекста
}
// Репозиторий пользователей
public interface IUserRepository
{
User GetById(Guid id);
void Add(User user);
}
public class UserRepository : IUserRepository
{
private readonly IAppDbContext _context;
public UserRepository(IAppDbContext context)
{
_context = context;
}
public User GetById(Guid id)
{
return _context.Users.Find(id);
}
public void Add(User user)
{
_context.Users.Add(user);
}
}
// Домейн-сервис пользователей
public interface IUserService
{
User RegisterUser(string email);
}
public class UserService : IUserService
{
private readonly IUserRepository _userRepository;
public UserService(IUserRepository userRepository)
{
_userRepository = userRepository;
}
public User RegisterUser(string email)
{
// Здесь может быть валидация, бизнес-логика и т.д.
var user = new User { Email = email };
_userRepository.Add(user);
return user;
}
}
Регистрация компонентов в контейнере
Теперь давайте посмотрим, как это регистрируется:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration config)
{
// Регистрируем контекст БД как Scoped
services.AddDbContext<AppDbContext>(options =>
{
var connString = config.GetConnectionString("Default");
// Здесь мы настраиваем параметры подключения к базе
options.UseSqlServer(connString);
});
// Привязываем интерфейс к реализации
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
return services;
}
public static IServiceCollection AddAppServices(this IServiceCollection services)
{
// Репозитории
services.AddScoped<IUserRepository, UserRepository>();
// Доменные сервисы
services.AddScoped<IUserService, UserService>();
return services;
}
}
В точке входа приложения:
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var configuration = builder.Configuration;
// Регистрация компонентов через модули
services
.AddAppInfrastructure(configuration)
.AddAppServices();
var app = builder.Build();
// Здесь настраиваем пайплайн, маршруты и т.д.
app.Run();
Обратите внимание, как компоненты зависят друг от друга:
UserServiceзависит отIUserRepository;UserRepositoryзависит отIAppDbContext;IAppDbContextреализован черезAppDbContext, который настраивается в инфраструктурном модуле.
Все связи описаны через регистрацию — в самом бизнес-коде нет ни одного new UserRepository().
Типичные ошибки при регистрации компонентов
Ошибка: зависимость «меньшего» lifetime в «большем»
Самая частая проблема — попытка использовать Scoped-компонент внутри Singleton.
Пример ошибки:
// Плохо - регистрация
services.AddSingleton<IUserService, UserService>();
services.AddScoped<IUserRepository, UserRepository>();
В этом случае UserService (Singleton) попытается получить IUserRepository (Scoped), который живет только в рамках запроса. Контейнер часто просто не позволит это сделать и выбросит исключение.
Как исправить:
- либо сделать
UserServiceтоже Scoped; - либо изменить архитектуру так, чтобы Singleton не зависел от Scoped.
Ошибка: забытая регистрация
Иногда вы создаете новый сервис, добавляете его как зависимость в конструктор, но забываете зарегистрировать его в контейнере.
Тогда при старте приложения или при первом запросе зависимости вы получите ошибку: «Тип X не зарегистрирован в контейнере».
Решение:
- завести правило «создал сервис — добавь регистрацию в модуль»;
- использовать автогенерацию или автосканирование, если фреймворк это поддерживает;
- держать все регистрации рядом с кодом модуля.
Ошибка: слишком много Singletons
Иногда, чтобы «снизить количество аллокаций», разработчики делают почти все сервисы Singleton. В итоге:
- в них копится состояние, привязанное к пользователям или запросам;
- возникает путаница с потокобезопасностью;
- появляются трудноуловимые баги «данные одного запроса попали в другой».
Подход:
- делайте Singleton только то, что действительно не зависит от контекста;
- предпочитайте Scoped для бизнес-логики в веб-приложениях.
Ошибка: логика регистрации спрятана глубоко в коде
Бывает так, что один компонент внутри себя создаёт другие компоненты вручную через new, вместо того чтобы просить их через DI. В результате регистрация в контейнере становится неполной, а зависимость «зашивается» в код.
Пример:
public class UserService : IUserService
{
public User RegisterUser(string email)
{
// Плохо - создаем UserRepository вручную
var repository = new UserRepository(new AppDbContext());
// ...
}
}
Лучше:
- передавать репозиторий и контекст через конструктор;
- регистрацию всех этих частей выполнять централизованно в контейнере.
Заключение
Регистрация компонентов — это не просто техническая деталь старта приложения, а важная часть архитектуры. Четкая и понятная component-registration помогает:
- управлять зависимостями централизованно;
- легко заменять реализации (например, для тестов или разных окружений);
- контролировать время жизни объектов и избегать утечек ресурсов;
- выстраивать модули приложения так, чтобы они были слабо связаны между собой.
Ключевые моменты, которые стоит учитывать:
- регистрируйте по интерфейсу, когда ожидается несколько реализаций или важна гибкость;
- выбирайте подходящий lifetime — Singleton, Scoped или Transient — в зависимости от характера данных и контекста использования;
- группируйте регистрацию по модулям и слоям, чтобы конфигурация оставалась структурированной;
- используйте фабрики и декораторы, когда нужна дополнительная гибкость или расширение поведения.
Если вы выстроите грамотную схему component-registration, внедрение зависимостей станет естественной частью разработки, а добавление новых возможностей в приложение будет требовать меньше усилий.
Частозадаваемые технические вопросы
Как зарегистрировать несколько реализаций одного интерфейса и выбрать нужную в рантайме
Используйте именованные регистрации или коллекции зависимостей. В C# это часто делают через IEnumerable<IMyService> и внутри сервиса выбирают нужную реализацию по некоторому признаку (например, свойству). В JS/TS можно регистрировать несколько провайдеров с разными токенами (PAYMENT_PROVIDER_STRIPE, PAYMENT_PROVIDER_PAYPAL) и передавать в конструктор именно тот токен, который нужен. Если выбор должен зависеть от данных во время выполнения, создайте фабрику, которая получает все реализации, а затем внутри метода Create выбирает нужную.
Как зарегистрировать компонент, которому нужен асинхронный конструктор
Большинство DI-контейнеров не поддерживают напрямую асинхронные конструкторы. Решение — использовать паттерн инициализации: создаете синхронный конструктор, а асинхронный код выносите в метод InitializeAsync. Регистрацию оставляете обычной, а инициализацию вызываете при старте приложения (например, в Program или в специальном Startup-сервисе), перебирая все компоненты, реализующие интерфейс IAsyncInitializable.
Как правильно регистрировать HttpClient или подобные ресурсы с пулами подключений
Нельзя создавать HttpClient как Transient с новым сокетом каждый раз — это приводит к исчерпанию портов и утечкам. Регистрируйте фабрику или тип, который управляет пулом подключений. В .NET используют IHttpClientFactory и регистрацию через AddHttpClient. В других средах старайтесь иметь один или несколько долгоживущих клиентов, зарегистрированных как Singleton, а конфигурацию (таймауты, базовые URL) передавайте через настройки.
Как организовать регистрацию для фоновых задач и очередей
Создайте отдельный модуль или слой BackgroundProcessing. В нем регистрируйте обработчики задач как Scoped или Transient (в зависимости от вашего паттерна) и фабрику, которая создает scope для каждой задачи. В .NET это делает IServiceScopeFactory. Каждая фонова задача создает scope, резолвит из него обработчик и после выполнения scope утилизируется, освобождая все Scoped-зависимости.
Как регистрировать компоненты для интеграционных тестов отличные от продакшн конфигурации
Используйте отдельный модуль/профиль конфигурации для тестов. В интеграционных тестах вместо продакшн регистрации вызывайте AddTestServices или аналог, где вы переопределяете нужные зависимости с помощью методов AddSingleton/AddScoped, которые «перезаписывают» предыдущие регистрации. В JS/TS мирах можно создавать отдельные тестовые модули или использовать оверрайд провайдеров (например, Test.createTestingModule с overrideProvider). Это позволяет подменять БД на in-memory, внешние API на фейки и так далее.
Постройте личный план изучения Vue до уровня Middle — бесплатно!
Vue — часть карты развития Frontend
100+ шагов развития
30 бесплатных лекций
300 бонусных рублей на счет
Бесплатные лекции
Все гайды по Vue
Лучшие курсы по теме

Vue 3 и Pinia
Антон Ларичев
TypeScript с нуля
Антон Ларичев