
Привет, Хабр! Меня зовут Роман Мельник, я фронтенд-разработчик во «ВКонтакте для Бизнеса». Наша команда создаёт инструменты, которые помогают владельцам сообществ управлять и развивать свои проекты. Сегодня я расскажу про Dependency Injection (DI) через библиотеку Tsyringe.
Почему это важно? Крупные проекты сталкиваются со следующими проблемами: разрастающимся глобальным стором, сложностями тестирования, масштабирования и переиспользования кода. Внедрение зависимостей помогает решить эти вопросы, делая код гибким и управляемым. На практике это выглядит гораздо интереснее. Давайте разберёмся!
Начнём с архитектурных принципов и паттернов.
Архитектурные принципы и паттерны

Внедрение зависимостей напрямую связано с архитектурой, одно невозможно без другого. Разберём их по отдельности и разделим на три вида:

Принципы, такие как Inversion of Control (IoC) и Dependency Inversion Principle (DIP).
Паттерны, например Dependency Injection (DI).
Библиотеки (IoC-контейнеры).
Inversion of Control (IoC)
Inversion of Control передаёт управление созданием зависимостей внешним компонентам, меняя поток управления от классов низкого уровня к классам высокого уровня — и наоборот.

На диаграмме видно, как абстракция связывает классы различных уровней, а IoC инвертирует поток управления. В одном случае класс сам создаёт зависимости, а в другом — получает их извне, следуя принципу.
Пример без IoC:
export class UserService {
private authService: AuthService;
public constructor() {
this.authService = new AuthService();
}
public registerUser(username: string, password: string) {
this.authService.login(username, password);
}
}
Здесь класс жёстко связан с зависимостью.
Пример с IoC:
export class UserService {
private authService: AuthService;
public constructor(authService: AuthService) {
this.authService = authService;
}
public registerUser(username: string, password: string) {
this.authService.login(username, password);
}
}
Теперь зависимости передаются извне, при необходимости можем их подменить, что упрощает тестирование и масштабирование.

Помимо Dependency Injection, принцип Inversion of Control включает в себя и другие подходы к управлению зависимостями. Среди них Dependency Lookup, Template Method, Service Locator и Event-Driven Architecture — каждая из этих техник решает специфические задачи и используется в разных архитектурных сценариях.
Dependency Inversion Principle (DIP)

Dependency Inversion Principle (DIP) — один из ключевых принципов SOLID, который уменьшает связанность между компонентами системы. Он обеспечивает независимость модулей высокого уровня от подробностей реализации низкоуровневых модулей, позволяя им взаимодействовать через абстракции.
В традиционном подходе классы напрямую связаны друг с другом, создавая жёсткие зависимости. DIP решает эту проблему, вводя промежуточный слой абстракции, через который происходит взаимодействие. Это делает систему гибкой: если нужно заменить класс высокого, не потребуется делать изменения в классе низкого уровня, так как он зависит от абстракции.

На диаграмме видно, как без DIP UserService
напрямую зависит от EmailService
, что затрудняет изменение логики. Внедрение DIP позволяет UserService
работать через абстрактный MessageService
, и при необходимости можно заменить EmailService
без необходимости переписывать код UserService
.
Пример без DIP:
export class UserService {
private emailService: EmailService;
public constructor(emailService: EmailService) {
this.emailService = emailService;
}
public sendUserMessage(to: string, message: string) {
void this.emailService.sendMessage(to, message);
}
}
Здесь UserService
жёстко привязан к конкретной реализации EmailService
.
Пример с DIP:
interface MessageService {
sendMessage: (to: string, message: string) => void;
}
class EmailService implements MessageService {
public sendMessage(to: string, message: string)Так в чём же разница между Inverse of Control и DIP? {
// Логика отправки сообщений
}
}
export class UserService {
private emailService: MessageService;
public constructor(emailService: MessageService) {
this.emailService = emailService;
}
public sendUserMessage(to: string, message: string) {
void this.emailService.sendMessage(to, message);
}
}
Теперь UserService
зависит не от конкретной реализации EmailService
, а от абстракции MessageService
. Это позволяет легко заменить EmailService
на другой сервис без изменений в UserService
.
Так DIP делает код гибче и удобнее для поддержки, позволяя разделять ответственность и избегать жёсткой связанности между модулями.
Так в чём же разница между Inversion of Control и DIP?
IoC управляет потоком выполнения программы, определяя, кто контролирует создание и управление зависимостями. DIP же сосредоточен на снижении зависимости модулей от конкретных реализаций, обеспечивая взаимодействие через абстракции.
IoC реализуется через библиотеки и фреймворки, которые передают контроль над зависимостями внешним механизмам. DIP, в свою очередь, применяется через интерфейсы, позволяя заменять компоненты без изменения их связей. В первом случае речь идёт о проектировании управления потоком, во втором — о проектировании зависимостей.
Использование обоих принципов делает архитектуру гибче, упрощает поддержку и снижает связанность модулей. Реализация этих принципов осуществляется паттерном Dependency Injection который избавляет классы от создания собственных зависимостей, получая их извне.
Помимо Constructor Injection, который мы уже рассмотрели, существуют и другие способы передачи зависимостей. Setter Injection выполняется через метод класса, а Property Injection — через поле класса, зачастую с использованием декораторов.
Завершающий элемент системы — IoC-контейнер. Он берёт на себя регистрацию зависимостей, управление их жизненным циклом и их внедрение в классы. Таким образом, архитектура становится предсказуемой и легко управляемой.

Теперь, когда мы рассмотрели все элементы системы, можем перейти к готовым решениям IoC-контейнера.
Библиотеки с готовыми решениями IoC-контейнера
Популярных библиотек много, но наиболее востребованные — InversifyJS, Tsyringe и TypeDI. Они работают по схожему принципу, используя декораторы для управления зависимостями.

На графике видно, что InversifyJS — наиболее популярная библиотека, а Tsyringe и TypeDI занимают второе и третье места. Разница заметна не только в количестве скачиваний, но и в размере: InversifyJS — самая тяжёлая из трёх, а Tsyringe, напротив, компактнее, что делает её удобной для использования в больших проектах.
Во ВКонтакте мы выбрали Tsyringe по нескольким причинам. Она поддерживает TypeScript, что для нас критично, имеет достаточную функциональность для наших задач, а её компактность важна для оптимизации размера бандла.
Теперь пойдём дальше.
Принцип работы Tsyringe

parent
— ссылка на родительский контейнер;registry
— хранилище зарезовленных сущностей;interceptors
— хранилище перехватчиков;disposables
— хранилище сущностей которые будут удалены при вызове функции размонтирования.
Tsyringe управляет зависимостями через dependency-контейнер, который рекурсивно обходит, регистрирует и возвращает экземпляры сущностей. Помимо основного контейнера, возможна мультиконтейнеризация, позволяющая одному контейнеру наследовать другой. Они связываются через ссылку parent
, а зависимости хранятся в registry
. В системе также предусмотрены interceptors
в виде функций обратного вызова, которые будут вызваны до или после создания экземпляров сущностей, и disposables
для удаления сущностей при размонтировании.
Контейнер поддерживает методы создания, регистрации и получения зависимостей, а также настройки жизненного цикла. Последний делится на четыре режима:
Transient (новый экземпляр при каждом запросе);
Singleton (единый экземпляр для всех контейнеров);
ContainerScoped (один экземпляр на контейнер);
ResolutionScoped (общий экземпляр для контейнера и его дочерних контейнеров).
Для установки жизненного цикла используются декораторы: Injectable для Transient, Singleton — одноимённый режим, а Scoped управляет ResolutionScoped и ContainerScoped.
Tsyringe позволяет внедрять зависимости как классовые, так и неклассовые сущности. В первом случае библиотека использует метаданные TypeScript, а во втором необходимо явно указать нужную сущность в виде ключа, который может быть строкой или символом (symbol), через декораторы параметров, такие как inject
, injectAll
, injectWithTransform
и injectAllWithTransform
.
Для корректной работы требуется включить в конфигурации Typescript emitDecoratorMetadata
и experimentalDecorators
(обязательно для версий ниже пятой) и подключить reflect-metadata
для чтения метаданных классов.
С теорией разобрались, теперь можно переходить к практике.
Пример реализации DI, используя Tsyringe
Tsyringe позволяет эффективно управлять зависимостями в проекте, интегрируя DI-контейнер в архитектуру приложения. В нашем случае используется связка React, TypeScript и MobX, а в качестве IoC-контейнера — Tsyringe.
Здесь следует сказать про использование MobX в качестве (state management), изначально мы использовали Effector, но его использование оказалось неудачным. Подробнее об этом можно узнать из доклада «Особенности Effector, которые почему-то никто не обсуждает: опыт ВКонтакте спустя год использования» или из статьи на Хабре. В итоге выбор пал на MobX, который обеспечивает удобное управление состоянием и хорошо интегрируется с Tsyringe.
export const App = () => {
return (
<BrowserRouter>
<Header />
<Routes>
<Route path="cats" element={<Cats />} />
<Route path="users" element={<Users />} />
</Routes>
</BrowserRouter>
);
};
const imagesStore = new CatsImagesStore();
const catsFactsService = new CatsFactsService(imagesStore);
const catsStore = new CatsModel(catsFactsService)
export const Сats = observer(() => {
const cats = catsStore.catsList;
const isLoading = catsStore.isLoading;
useEffect(() => {
catsStore.fetchCatsInfo();
}, []);
return (
<>
{isLoading? (<Spinner />) : (<CatsList cats={cats} />)}
</>
);
});
Базовая структура приложения включает страницы «Котиков» и «Пользователей». В примере с котиками видно, как компоненты зависят друг от друга: модель CatsModel
получает данные из CatsFactsService
, который использует CatsImagesStore
для загрузки изображений.
export class CatsModel {
public constructor(private catsFactsService: CatsFactsService) {
makeAutoObservable(this);
}
public fetchCatsInfo = () => {
void this.catsFactsService.fetchFacts();
}
public get catsList() {
return this.catsFactsService.factsList;
}
public get isLoading() {
return this.catsFactsService.isLoading;
}
}
Чтобы подключить Tsyringe к этим моделям, их необходимо обернуть в декораторы, в данном случае это @injectable с жизненным циклом Transient, которые обеспечивают автоматическое управление зависимостями.
import { injectable } from 'tsyringe’;
@injectable()
export class CatsFactsService {
public constructor(private imagesStore: CatsImagesStore) {
makeAutoObservable(this);
}
// Код сервиса
}
@injectable()
export class CatsModel {
public constructor(private catsFactsService: CatsFactsService) {
makeAutoObservable(this);
}
// Код сервиса
}
Теперь мы можем создание экземпляров класса и внедрение зависимости переложить на tsyringe, заменим явное создание экземпляров на автоматическое регистрируя модель страницы в DI контейнере tsyringe.
import { container } from 'tsyringe’;
const catsStore = container.resolve(CatsModel);
export const Сats = observer(() => {
// Код компонента
});
Благодаря этому процесс регистрации и передачи зависимостей происходит без явного создания экземпляров классов — их управление берёт на себя DI-контейнер.
Но есть две проблемы. Во-первых, регистрация абстрактных сервисов требует дополнительной настройки. Во-вторых, все зависимости хранятся в root-контейнере, что может приводить к утечке памяти при смене страниц.
Начнем с создания базового провайдера для доступа к DI контейнеру tsyringe в любом месте приложения.
import { container } from 'tsyringe’;
const ContainerContext = createContext<DependencyContainer>(container);
export const useContainer = () => useContext(ContainerContext);
export const DIProvider = ({ children }: PropsWithChildren<{}>) => {
const container = useContainer();
return (
<ContainerContext.Provider value={container}>
{children}
</ContainerContext.Provider>
)
};
Теперь для решения первой проблемы добавим в базовый провайдер регистрацию общих сервисов.
import { singleton } from 'tsyringe’;
@singleton()
export abstract class Logger {
public abstract log(message: string): void;
}
@singleton()
export class LoggerService implements Logger {
public log(message: string) {
// Код исполнения
}
}
export interface MessageService {
sendMessage: (to: string, message: string) => void;
}
@singleton()
export class EmailService implements MessageService {
public sendMessage(to: string, message: string) {
// Логика отправки сообщений
}
}
@singleton()
export class SmsService implements MessageService {
public sendMessage(to: string, message: string) {
// Логика отправки сообщений
}
}
Абстракции могут быть представлены как интерфейсы (например, MessageService
с реализациями EmailService
и SmsService
) или классы (Logger
). Добавим регистрацию общих сервисов в наш базовый провайдер.
import { container } from 'tsyringe’;
const ContainerContext = createContext<DependencyContainer>(container);
export const useContainer = () => useContext(ContainerContext);
export const DIProvider = ({ children }: PropsWithChildren<{}>) => {
const container = useContainer();
container.register<MessageService>("MessageService", EmailService);
container.register(Logger, LoggerService);
return <ContainerContext.Provider value={container}>{children}</ContainerContext.Provider>;
};
При регистрации не классовых сущностей например интерфейсы требуется указывать токен как в данном случае токеном является строка, так как metadata существуют только у классов.
@injectable()
export class CatsFactsService {
public constructor(
private imagesStore: CatsImagesStore,
private logger: Logger,
@inject("MessageService") private messageService: MessageService
) {
makeAutoObservable(this);
}
public async fetchFacts() {
// Код исполнения
}
public get factsList() {
// Код исполнения
}
}
Чтобы корректно внедрять интерфейсы, необходимо явно указывать токен через @inject()
.
C первой проблемой мы справились теперь перейдем ко второй. Все зависимости хранятся в root-контейнере, и при переходе между страницам не будет очистки зависимостей, так root-контейнер будет существовать на протяжении всего времени работы приложения.
Вторая проблема решается путём создания дочерних контейнеров для отдельных страниц. DI-контейнер должен создаваться при загрузке страницы и автоматически уничтожаться при её закрытии. Для этого пишут провайдер, который регистрирует модели, а затем передаёт их в контекст.
export const createProvider = <ClassType, ModelType = InstanceType<ClassType>>(
ModelClass: ClassType,
) => {
const ModelContext = createContext<ModelType | null>(null);
const Provider = (props: PropsWithChildren) => {
const di = useContainer();
const childContainer = di.createChildContainer();
const instance = childContainer.resolve(ModelClass);
useEffect(() => () => {
childContainer.dispose();
}, [],);
return (
<ModelContext.Provider value={instance}>
{props.children}
</ModelContext.Provider>;
};
const useModel = (): ModelType => useContext(ModelContext);
return { Provider, useModel };
};
Передаем модель страницы в функцию, и получаем Provider и хук для страницы
@injectable()
export class CatsModel {
public constructor(private catsFactsService: CatsFactsService) {
makeAutoObservable(this);
}
// Код сервиса
}
export const {
Provider: CatsModelProvider, useModel: useCatsModel
} = createProvider(CatsModel);
Теперь мы можем обернуть нашу страницу в провайдер, и получить внутри компонента доступ к данным модели, также можно сделать HOC для оборачивания компонента страницы в провайдер
import { CatsModelProvider, useCatsModel } from ‘./model';
const CatsInner = observer(() => {
const { catsList, fetchCatsInfo, isLoading } = useCatsModel();
useEffect(() => {
fetchCatsInfo();
}, []);
// Код компонента
});
export const Cats = createWidget(CatsModelProvider, CatsInner);
Теперь все зависимости внедряются корректно, и приложение функционирует без проблем

Использование Tsyringe позволяет автоматизировать регистрацию зависимостей, управлять их жизненным циклом и легко заменять реализации. В результате система становится гибкой, поддерживаемой и удобной для работы с DI в React-приложениях.
Общая структура DI, используемая во ВКонтакте
Во ВКонтакте DI используют на трёх уровнях: Platform Layer, Domain Layer и View Layer.

Platform Layer управляет платформозависимыми сервисами, разделяя их для веба и сервера. Здесь применяются плагины, которые адаптируются под окружение.
Domain Layer инкапсулирует продуктовые сущности: пользователей, маркет, сообщества и других. В нём выделяются сущности, обеспечивающие взаимодействие частей архитектуры, и инкапсулированные сервисы, выполняющие конкретные задачи и переиспользуемые в разных частях приложения.
View Layer отвечает за логику отображения. Здесь есть модели, управляющие интерфейсом, виджеты — React-компоненты с ViewModel, и обычные компоненты, которые взаимодействуют с ViewModel через родительскую структуру.
Рассмотрим простую страницу которая имеет ViewModel и виджет для отображения пользователей, который имеет также свою ViewModel. Для виджетов предусмотрены свои собственные провайдеры, но которые имеют схожую структуру с провайдерами страниц.

Если разобрать верхнеуровнево взаимодействия зависимостей, то получим следующую схему

На иллюстрации видно, как эти слои связаны. Чем выше уровень, тем больше переиспользуемости: плагины Platform Layer наиболее гибкие, Domain Layer более специализированы, а View Layer сосредоточен на интерфейсе.
Пример структуры реальной страницы показывает, как DI управляет зависимостями. В модели страницы используются GroupsService
и StatsService
, а также плагины локализации и конфигурации. StatsService
зависит от GroupsApi
и StatsApi
, которые работают через API-клиент. Виджет пользователей использует UsersService
, который в свою очередь взаимодействует с errorLogger
и API-клиентом.
Вся работа по управлению зависимостями выполняется DI-контейнером — разработчику нужно лишь указать нужные сервисы. Дальше Tsyringe берёт на себя регистрацию, инкапсуляцию и связывание компонентов.

Основные преимущества DI: масштабируемость, контейнеризация и упрощённое тестирование. На практике этот подход доказал свою эффективность, сделав архитектуру более гибкой и удобной для поддержки.
Если DI ещё не стал частью вашего проекта, самое время попробовать.