Dependency Injection (DI / Внедрение зависимостей) — это стиль написания кода (паттерн), при котором класс не создает нужные ему для работы объекты сам, а получает их извне (обычно через конструктор).


🧩 Как DI связано с принципами SOLID?

DI напрямую связано с двумя принципами SOLID, которые мы разбирали ранее:

  1. D — Dependency Inversion Principle (Принцип инверсии зависимостей): DIP — это архитектурное правило (теория), которое говорит: «Завись от интерфейсов, а не от реализаций». А Dependency Injection — это инструмент (практика), с помощью которого мы передаем эту конкретную реализацию интерфейса внутрь класса.
  2. S — Single Responsibility Principle (Принцип единственности ответственности): Если класс сам внутри себя создает объекты через new, он берет на себя лишнюю ответственность — создание и настройку чужих объектов. С использованием DI класс занимается только своей бизнес-логикой.

❌ Без Dependency Injection (Плохой код)

Класс Car (Автомобиль) сам создает внутри себя объект Engine (Двигатель). Класс жестко связан с конкретным двигателем (Бензиновым). Мы не можем заменить мотор, не переписывая код машины.

class GasolineEngine {
  public start() { console.log("Бррм! Бензиновый двигатель запущен."); }
}

class Car {
  private engine: GasolineEngine;

  constructor() {
    // Нарушение DI: класс САМ создает свою зависимость
    this.engine = new GasolineEngine(); 
  }

  public drive() {
    this.engine.start();
    console.log("Машина поехала.");
  }
}

const myCar = new Car();
myCar.drive();

С применением Dependency Injection (Хороший код)

Мы применяем DIP (создаем интерфейс-абстракцию) и DI (передаем объект снаружи через конструктор). Теперь машина может работать с абсолютно любым двигателем.

1. Создаем абстракцию

interface Engine {
  start(): void;
}

2. Создаем разные виды двигателей

class GasolineEngine implements Engine {
  public start() { console.log("Бррм! Бензиновый двигатель запущен."); }
}

class ElectricEngine implements Engine {
  public start() { console.log("Уууууу... Электродвигатель запущен."); }
}

3. Класс автомобиля использует Внедрение Зависимостей (DI)

Обратите внимание: в конструктор Car мы передаем готовый объект, который соответствует интерфейсу Engine.

class Car {
  // Зависимость внедряется (injects) через конструктор
  constructor(private engine: Engine) {}

  public drive() {
    this.engine.start(); // Работаем с абстракцией
    console.log("Машина поехала.");
  }
}

// === Использование (Мы сами "внедряем" зависимости вручную) ===

const petrolEngine = new GasolineEngine();
const teslaEngine = new ElectricEngine();

// Создаем бензиновую машину
const bmw = new Car(petrolEngine);
bmw.drive();

// Создаем электрокар, просто "внедряя" другой двигатель
const tesla = new Car(teslaEngine);
tesla.drive();

💡 Три способа внедрения зависимостей (DI)

В примере выше мы использовали Constructor Injection (внедрение через конструктор) — это самый частый и рекомендуемый способ. Но бывают и другие:

  1. Constructor Injection (через конструктор): Зависимость передается при создании объекта. Объект без нее просто не создастся.
  2. Setter Injection (через метод/сеттер): Зависимость можно передать или заменить уже после создания объекта (например, метод setEngine(engine: Engine)).
  3. Interface Injection (через интерфейс): Класс реализует специальный интерфейс, который обязывает его иметь метод для приема зависимости (используется редко).

Зачем нужны DI-фреймворки (InversifyJS, NestJS)?

В больших проектах связывать объекты вручную через new Car(new ElectricEngine()) становится тяжело — кодовая база разрастается. Специальные фреймворки берут эту рутину на себя. Они автоматически смотрят на типы в конструкторе и сами создают и подставляют (внедряют) нужные объекты.

Dependency Injection (DI) в мутабельной реактивности (на примере Vue 3)

В системах с мутабельной реактивностью состояние меняется напрямую, а UI автоматически реагирует на эти изменения. Чтобы изолировать бизнес-логику от компонентов, реактивное хранилище (store) создается в виде класса или объекта, а затем внедряется в дерево компонентов через механизм Provide / Inject (аналог IoC-контейнера).


❌ Без DI: Жесткая связь (Плохой код)

Компонент напрямую импортирует конкретный экземпляр реактивного сервиса. Мы не можем подменить его на тестовый (Mock) или изменить конфигурацию для разных частей приложения.

// user-store.ts
import { reactive } from 'vue';

// Конкретный сервис, намертво завязанный на реальный fetch
class RealUserStore {
  // Мутабельное реактивное состояние
  public state = reactive({
    name: 'Гость',
    isLoading: false
  });

  async loadUser() {
    this.state.isLoading = true;
    const res = await fetch('https://example.com');
    const data = await res.json();
    this.state.name = data.name; // Прямая мутация реактивного свойства
    this.state.isLoading = false;
  }
}

// Экспортируем ОДИН конкретный созданный объект
export const realUserStore = new RealUserStore();
<!-- UserProfile.vue -->
<script setup lang="ts">
import { onMounted } from 'vue';
import { realUserStore } from './user-store'; // ЖЕСТКИЙ ИМПОРТ
</script>

<template>
  <div>
    <p v-if="realUserStore.state.isLoading">Загрузка...</p>
    <p v-else>Привет, {{ realUserStore.state.name }}</p>
    <button @click="realUserStore.loadUser()">Обновить</button>
  </div>
</template>

С применением DI через Provide / Inject (Хороший код)

Мы создаем абстракцию (интерфейс) для реактивного хранилища, регистрируем (provide) нужную реализацию на верхнем уровне приложения и внедряем (inject) её в компоненты.

1. Создаем интерфейс и уникальный токен (Абстракция)

// user-store.interface.ts
import { InjectionKey } from 'vue';

// Описываем, как должно выглядеть наше мутабельное реактивное состояние
export interface IUserStoreState {
  name: string;
  isLoading: boolean;
}

// Интерфейс самого сервиса
export interface IUserStore {
  state: IUserStoreState;
  loadUser(): Promise<void>;
}

// Уникальный токен для DI-контейнера Vue
export const USER_STORE_KEY: InjectionKey<IUserStore> = Symbol('USER_STORE_KEY');

2. Создаем разные реализации сервиса

// real-user-store.ts
import { reactive } from 'vue';
import { IUserStore, IUserStoreState } from './user-store.interface';

export class RealUserStore implements IUserStore {
  // Инициализируем мутабельную реактивность
  public state = reactive<IUserStoreState>({
    name: 'Гость',
    isLoading: false
  });

  async loadUser() {
    this.state.isLoading = true;
    const res = await fetch('https://example.com');
    const data = await res.json();
    this.state.name = data.name; // Мутируем реактивные данные напрямую
    this.state.isLoading = false;
  }
}

// Mock-сервис для тестирования или разработки без бэкенда
export class MockUserStore implements IUserStore {
  public state = reactive<IUserStoreState>({
    name: 'Тестовый Админ (Offline)',
    isLoading: false
  });

  async loadUser() {
    this.state.isLoading = true;
    this.state.name = 'Обновленный Тестовый Пользователь';
    this.state.isLoading = false;
  }
}

3. Регистрируем зависимость на верхнем уровне (App.vue)

Именно здесь мы решаем, какую реализацию реактивного стора предоставить приложению.

<!-- App.vue -->
<script setup lang="ts">
import { provide } from 'vue';
import { USER_STORE_KEY } from './user-store.interface';
import { RealUserStore, MockUserStore } from './real-user-store';
import UserProfile from './UserProfile.vue';

const isDev = process.env.NODE_ENV === 'development';

// ВНЕДРЯЕМ (PROVIDE) нужный класс. Компоненты снизу получат именно его.
const userStore = isDev ? new MockUserStore() : new RealUserStore();
provide(USER_STORE_KEY, userStore);
</script>

<template>
  <UserProfile />
</template>

4. Внедряем зависимость в компонент (UserProfile.vue)

Компонент теперь зависит только от интерфейса. Мутабельная реактивность продолжает работать: при вызове loadUser() состояние внутри класса изменится, и Vue мгновенно перерисует интерфейс.

<!-- UserProfile.vue -->
<script setup lang="ts">
import { inject } from 'vue';
import { USER_STORE_KEY } from './user-store.interface';

// Извлекаем (INJECT) зависимость по токену
const userStore = inject(USER_STORE_KEY);

if (!userStore) {
  throw new Error('UserStore не был предоставлен сверху!');
}
</script>

<template>
  <div>
    <!-- Читаем данные из мутабельного реактивного стейта -->
    <p v-if="userStore.state.isLoading">Загрузка...</p>
    <p v-else>Привет, {{ userStore.state.name }}</p>
    
    <button @click="userStore.loadUser()">Обновить данные</button>
  </div>
</template>

Почему DI + мутабельная реактивность — это мощно?

  1. Изоляция логики: Компоненты превращаются в «глупые» шаблоны. Вся работа с API и мутации данных происходят в чистых TypeScript-классах.
  2. Идеально для тестов: При тестировании компонента UserProfile с помощью Vue Test Utils вам достаточно передать фейковый стор в блок опций: provide: { [USER_STORE_KEY]: new MockUserStore() }. Компонент отрендерится мгновенно, не пытаясь стучаться в сеть.
  3. Контроль жизненного цикла: Создавая экземпляры классов внутри App.vue (или других родительских компонентов), вы можете контролировать время жизни стора — он может быть глобальным (Singleton) или создаваться заново для каждой отдельной вкладки или модального окна.

Dependency Injection (DI) и мутабельная реактивность в React (на примере Valtio)

Valtio превращает обычный объект в реактивное прокси. Вы можете напрямую мутировать (изменять) свойства этого объекта в методах класса, а специальный хук useSnapshot отследит эти мутации и автоматически перерисует компонент React.

Комбинируя классы Valtio с React Context, мы получаем чистую архитектуру с инверсией зависимостей.


❌ Без DI: Жесткая связь (Плохой код)

Компонент намертво привязан к конкретному глобальному экземпляру counterStore. Его нельзя переиспользовать с другими данными или изолированно протестировать.

import { proxy, useSnapshot } from 'valtio';

// Конкретная реализация хранилища
class CounterStore {
  // Мутабельное реактивное состояние внутри Proxy
  public state = proxy({ count: 0 });

  public increment() {
    this.state.count++; // Прямая мутация! Никаких setState()
  }
}

// Экспорт глобального синглтона (жесткая привязка)
export const globalCounterStore = new CounterStore();

// Компонент
export const CounterComponent = () => {
  // Хук создает реактивный слепок для рендеринга
  const snap = useSnapshot(globalCounterStore.state);

  return (
    <div>
      <p>Счетчик: {snap.count}</p>
      <button @click={() => globalCounterStore.increment()}>+</button>
    </div>
  );
};

С применением DI и мутабельной реактивности (Хороший код)

Мы создаем абстракцию (интерфейс) для стора, передаем конкретную реализацию через React Context и внедряем её в компоненты.

1. Создаем интерфейс и контекст (Абстракция)

// counter.interface.ts
import { createContext, useContext } from 'react';

export interface ICounterState {
  count: number;
}

export interface ICounterStore {
  state: ICounterState;
  increment(): void;
}

// Создаем контекст для внедрения интерфейса
export const CounterStoreContext = createContext<ICounterStore | null>(null);

// Кастомный хук для инжекции зависимости
export const useCounterStore = () => {
  const store = useContext(CounterStoreContext);
  if (!store) throw new Error('useCounterStore должен быть внутри провайдера');
  return store;
};

2. Создаем разные детали реализации

// counter.implementations.ts
import { proxy } from 'valtio';
import { ICounterStore, ICounterState } from './counter.interface';

// Реализация №1: Стандартный счетчик, шаг = 1
export class StandardCounterStore implements ICounterStore {
  public state = proxy<ICounterState>({ count: 0 });

  public increment() {
    this.state.count += 1; // Мутируем состояние напрямую
  }
}

// Реализация №2: Супер-счетчик для вип-пользователей, шаг = 10
export class DoubleCounterStore implements ICounterStore {
  public state = proxy<ICounterState>({ count: 0 });

  public increment() {
    this.state.count += 10; // Другая логика мутации
  }
}

3. Компонент (Зависит только от абстракции)

Компонент внедряет стор через хук useCounterStore. Благодаря useSnapshot, React будет автоматически обновлять UI при любой мутации внутри класса, но сам компонент ничего не знает о том, какой именно класс ему передали.

// CounterComponent.tsx
import React from 'react';
import { useSnapshot } from 'valtio';
import { useCounterStore } from './counter.interface';

export const CounterComponent = () => {
  const store = useCounterStore(); // ВНЕДРЕНИЕ ЗАВИСИМОСТИ
  const snap = useSnapshot(store.state); // Подписка на мутабельные изменения

  return (
    <div style={{ border: '1px solid black', padding: '10px', margin: '5px' }}>
      <p>Значение: {snap.count}</p>
      <button onClick={() => store.increment()}>Увеличить</button>
    </div>
  );
};

4. Настройка зависимостей на верхнем уровне (App.tsx)

Теперь мы можем гибко управлять тем, какие зависимости отправляются в компоненты. Мы можем отрендерить два одинаковых компонента UI, но «внедрить» в них абсолютно разную мутабельную логику.

// App.tsx
import React from 'react';
import { CounterStoreContext } from './counter.interface';
import { StandardCounterStore, DoubleCounterStore } from './counter.implementations';
import { CounterComponent } from './CounterComponent';

// Создаем экземпляры разных сервисов
const standardStore = new StandardCounterStore();
const vipStore = new DoubleCounterStore();

export const App = () => {
  return (
    <div>
      <h3>Обычная зона (Шаг +1):</h3>
      {/* Внедряем стандартный мутабельный стор */}
      <CounterStoreContext.Provider value={standardStore}>
        <CounterComponent />
      </CounterStoreContext.Provider>

      <h3>VIP зона (Шаг +10):</h3>
      {/* Внедряем другой мутабельный стор в тот же самый компонент! */}
      <CounterStoreContext.Provider value={vipStore}>
        <CounterComponent />
      </CounterStoreContext.Provider>
    </div>
  );
};

Главные преимущества такого подхода в React:

  1. Производительность: useSnapshot от Valtio делает точечные обновления. Компонент перерисуется только тогда, когда изменится конкретное мутированное свойство, избегая лишних ререндеров React.
  2. Переиспользование UI: Компонент CounterComponent стал максимально абстрактным. Его можно вставить в любую часть приложения, просто обеспечив нужный контекст.
  3. Чистые тесты: В unit-тестах компонента вам не нужно создавать сложные моки для хуков React. Вы просто передаете в Provider объект-заглушку с мутабельным Proxy, имитируя любые сценарии.

Dependency Injection (DI) и мутабельная реактивность в React (на примере MobX)

Комбинация MobX + React Context считается эталонным подходом для крупных enterprise-приложений. Мы заворачиваем реактивную логику в чистые TypeScript-классы, а React Context используем как легковесный IoC-контейнер для их внедрения.


❌ Нарушение DI: Глобальный синглтон (Плохой код)

Компонент намертво импортирует конкретный экземпляр todoStore. Мы не сможем создать две независимые ленты задач на одной странице или подменить стор в тестах.

import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class TodoStore {
  public todos: string[] = [];

  constructor() {
    makeAutoObservable(this); // Делает свойства реактивными, а методы — экшенами
  }

  public addTodo(task: string) {
    this.todos.push(task); // Мутируем массив напрямую!
  }
}

// ЖЕСТКАЯ ПРИВЯЗКА: экспорт единственного экземпляра
export const globalTodoStore = new TodoStore();

// Компонент жестко зависит от глобального объекта
export const TodoList = observer(() => {
  return (
    <div>
      <button onClick={() => globalTodoStore.addTodo('Новая задача')}>Добавить</button>
      <ul>
        {globalTodoStore.todos.map((todo, i) => <li key={i}>{todo}</li>)}
      </ul>
    </div>
  );
});

С применением DI через React Context (Хороший код)

Мы создаем абстракцию (интерфейс), настраиваем Context для внедрения и пишем компонент, который вообще не зависит от конкретной реализации MobX-стора.

1. Создаем интерфейс и контекст (Абстракция)

// todo.interface.ts
import { createContext, useContext } from 'react';

export interface ITodoStore {
  todos: string[];
  addTodo(task: string): void;
}

// Создаем контекст, который ожидает объект, соответствующий интерфейсу
export const TodoStoreContext = createContext<ITodoStore | null>(null);

// Кастомный хук для внедрения (инжекции) зависимости в компоненты
export const useTodoStore = (): ITodoStore => {
  const store = useContext(TodoStoreContext);
  if (!store) throw new Error('useTodoStore должен использоваться внутри TodoStoreContext.Provider');
  return store;
};

2. Создаем разные детали реализации (MobX-классы)

// todo.stores.ts
import { makeAutoObservable } from 'mobx';
import { ITodoStore } from './todo.interface';

// Реализация №1: Обычный список задач
export class StandardTodoStore implements ITodoStore {
  public todos: string[] = [];

  constructor() {
    makeAutoObservable(this); // Включает мутабельную реактивность MobX
  }

  public addTodo(task: string) {
    this.todos.push(task); // Прямая мутация
  }
}

// Реализация №2: Стор, который автоматически добавляет префикс [ВАЖНО] к задачам
export class UrgentTodoStore implements ITodoStore {
  public todos: string[] = [];

  constructor() {
    makeAutoObservable(this);
  }

  public addTodo(task: string) {
    this.todos.push(`🔥 [ВАЖНО] ${task}`); // Другая бизнес-логика мутации
  }
}

3. Создаем компонент UI (Зависит только от абстракции)

Мы оборачиваем компонент в observer из mobx-react-lite. Функция observer заставляет React автоматически перерисовывать компонент, когда мутируют наблюдаемые (observable) данные, к которым компонент обратился во время рендера.

// TodoList.tsx
import React from 'react';
import { observer } from 'mobx-react-lite';
import { useTodoStore } from './todo.interface';

// Обязательно оборачиваем в observer, чтобы React реагировал на мутации MobX
export const TodoList = observer(() => {
  const store = useTodoStore(); // ВНЕДРЕНИЕ ЗАВИСИМОСТИ!

  return (
    <div style={{ padding: '15px', border: '1px dashed gray', marginBottom: '10px' }}>
      <button onClick={() => store.addTodo('Выучить SOLID')}>
        Добавить задачу
      </button>
      
      <ul>
        {store.todos.map((todo, index) => (
          <li key={index}>{todo}</li>
        ))}
      </ul>
    </div>
  );
});

4. Внедрение зависимостей на верхнем уровне (App.tsx)

Теперь мы можем использовать один и тот же компонент отображения TodoList, но изолированно внедрять в него совершенно разную реактивную логику и разные экземпляры данных.

// App.tsx
import React from 'react';
import { TodoStoreContext } from './todo.interface';
import { StandardTodoStore, UrgentTodoStore } from './todo.stores';
import { TodoList } from './TodoList';

// Создаем независимые экземпляры мутабельных хранилищ
const standardTodoStore = new StandardTodoStore();
const urgentTodoStore = new UrgentTodoStore();

export const App = () => {
  return (
    <div>
      <h2>Обычный список задач:</h2>
      {/* Внедряем стандартный стор */}
      <TodoStoreContext.Provider value={standardTodoStore}>
        <TodoList />
      </TodoStoreContext.Provider>

      <h2>Срочный список задач:</h2>
      {/* Внедряем важный стор в тот же самый компонент */}
      <TodoStoreContext.Provider value={urgentTodoStore}>
        <TodoList />
      </TodoStoreContext.Provider>
    </div>
  );
};

В чем главные плюсы связки MobX + DI?

  1. Множественные экземпляры (Multi-instance): Благодаря отказу от глобальных синглтонов, вы можете отрендерить сколько угодно компонентов TodoList на одной странице (например, в разных вкладках). У каждого будет свой собственный изолированный MobX-класс, внедренный через свой Provider.
  2. Нулевая связанность с UI: Ваша бизнес-логика написана на чистом TypeScript. Вы можете полностью протестировать классы StandardTodoStore или UrgentTodoStore обычным инструментом вроде Jest или Vitest вообще без запуска React и рендеринга компонентов.
  3. Высокая производительность: MobX автоматически выстраивает граф зависимостей. Если в UrgentTodoStore изменятся данные, перерисуется только тот TodoList, в который этот стор был внедрен, не затрагивая остальные компоненты приложения.