← Все статьи
1 августа 2026 г.Разработка

ООП: инкапсуляция, наследование, полиморфизм, композиция и DI

Разбираю столпы ООП, разницу между инкапсуляцией и сокрытием, композицию и агрегацию, а также как всё это соотносится с Dependency Injection и Dependency Inversion Principle.

Что такое ООП и зачем оно вообще нужно?

Ну смотрите, раньше все писали в процедурном стиле: были просто данные и функции-процедуры, которые их как-то переваривали. Пока проект маленький — всё ок, но когда кода становится много, управлять этим «спагетти» становится невозможно: сложно делать декомпозицию и что-то менять, не ломая всё остальное.

Тут и приходит ООП (Объектно-ориентированное программирование). Мы начинаем мыслить не просто процедурами, а классами и объектами

  • Класс — это «чертеж» или описание характеристик (свойств) и действий (методов).
  • Объект — это конкретный «экземпляр», созданный по этому чертежу, где у каждой характеристики есть свое значение (например, класс «Человек», а объект — «Вася»)

Нюанс тут в том, что ООП — это не “серебряная пуля”, хотя когда-то комьюнити восприняло его именно так, как что-то способное решить вообще все проблемы архитектуры. На деле ООП держится на трёх (иногда говорят четырёх) столпах — инкапсуляция, наследование, полиморфизм

Что такое инкапсуляция ?

Ну смотрите, инкапсуляция на самом деле очень простое понятие, которое почему-то любят усложнять. Суть в том, что класс — это как капсула, которая объединяет в себе данные (свойства) и методы (поведение) для работы с этими данными

Но рядом с инкапсуляцией всегда идёт сокрытие (information hiding), и вот тут уже чуть сложнее. Смысл в том, что часть данных объекта — публичная (с ней можно работать откуда угодно), а часть — закрытая, до которой снаружи дотянуться нельзя. Для этого есть модификаторы доступа public/private/protected

UPD: Нюанс тут в том, что просто пометить свойство private — это полдела. Часто нужно дать контролируемый доступ через геттер/сеттер, причём сеттер — это не просто “присвоить значение”, а место для валидации

class Rectangle {
  private _width: number;

  set width(value: number) {
    this._width = value <= 0 ? 1 : value; // не даём поставить отрицательную ширину
  }

  get width() {
    return this._width;
  }
}

Что такое Наследование ?

Ну смотрите, наследование — это механизм, который позволяет одному классу взять себе все свойства и методы другого класса, и при этом ещё добавить что-то своё сверху. То есть классу не нужно заново описывать то, что уже описано в родителе — он это просто получает “по умолчанию”.

Извините но вот классический пример это: вот есть класс Person (имя, фамилия, возраст), от него наследуется Employee (добавляет ИНН, ЗП, номер паспорта), а от Employee наследуется Developer (добавляет язык программирования, номер команды).

class Person {
  constructor(
    protected firstName: string,
    protected lastName: string,
    protected age: number,
  ) {}
}

class Employee extends Person {
  constructor(
    firstName: string,
    lastName: string,
    age: number,
    private inn: string,
  ) {
    super(firstName, lastName, age); // сначала родительский конструктор
    this.inn = inn;
  }
}

class Developer extends Employee {
  constructor(
    firstName: string,
    lastName: string,
    age: number,
    inn: string,
    private language: string,
  ) {
    super(firstName, lastName, age, inn);
  }
}

Что такое Полиморфизм ?

Ну смотрите, полиморфизм — дословно “много форм”, и суть в том, что мы работаем с разными объектами через один и тот же интерфейс/метод, но при этом каждый объект ведёт себя по-своему при вызове этого метода. Хочется еще добавить что полиморфизм по большой части всегда идет вместе с одним из 3 всадников: наследованием, абстракцией, интерфейсы. То есть отдельно рассматривать полиморфизом смысла нет мне кажется.

Классический пример: есть иерархия Person → Employee → Developer, и у каждого переопределён метод greeting():

class Person {
  greeting() {
    console.log('Я человек');
  }
}

class Employee extends Person {
  greeting() {
    console.log('Я работник');
  }
}

class Developer extends Employee {
  greeting() {
    console.log('Я разработчик');
  }
}

const entities: Person[] = [new Person(), new Employee(), new Developer()];

entities.forEach((e) => e.greeting());
// Я человек
// Я работник
// Я разработчик

А какая разница между инкапсуляцией и сокрытием?

Все довольно просто и на самом деле они находятся по соседству. Так вот

  1. Когда у нас есть состояние и поведение в контексте одной абстракции это: Инкапсуляция
  2. А если мы это состояние как то изолируем чтоб никто допустим через точку не лез и не мог менять то это: Сокрытие.

Очень близкие понятия на самом деле

В чем разница с SOLID(Принцип проектирования)?

Ну смотрите, тут важно не путать:

  • ООП — это фундамент. Это сам инструментарий, те самые кирпичи: классы, интерфейсы и три столпа (инкапсуляция, наследование, полиморфизм).
  • SOLID — это правила проектирования поверх этого фундамента

Прикол в том, что можно использовать ООП и при этом написать ужасный код. SOLID — это 5 по сути принципов, которые говорят, ну как именно нужно организовывать классы и зависимости, чтобы приложение было гибким. Например, тотже самый Dependency Injection (внедрение зависимостей) — это как раз про гибкость: мы передаем зависимости в конструктор сервиса к примеру, и сервису становится плевать, работает он с Postgres или Mongo

Композиция

Ну смотрите, суть композиции — класс-владелец сам создаёт вложенный объект (обычно прямо в конструкторе) и полностью управляет его жизненным циклом. Это отношение “часть-целое”, причём часть без целого просто не имеет смысла — если умирает владелец, умирают и его части.

class Engine {
  private isRunning = false;
  start() {
    this.isRunning = true;
    console.log('двигатель заведён');
  }
}

class Wheel {
  drive() {
    console.log('колесо крутится');
  }
}

class Car {
  private engine: Engine;
  private wheels: Wheel[];

  constructor() {
    this.engine = new Engine(); // создан ВНУТРИ, никто снаружи не передаёт
    this.wheels = Array.from({ length: 4 }, () => new Wheel());
  }

  drive() {
    this.engine.start();
    this.wheels.forEach((w) => w.drive()); // делегирование — Car не делает работу сам
  }
}

Тут ключевой момент — new Engine() создаётся внутри Car, никто снаружи это не контролирует и не может подменить. Метод drive() у Car не выполняет логику сам, а раздаёт вызов вложенным объектам — это и называется делегированием.

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

Trade-off: композиция даёт простоту и предсказуемость — не нужно думать “а вдруг кто-то снаружи подсунет странный Engine”, всё под полным контролем владельца, чистая инкапсуляция. Цена — низкая гибкость и плохая тестируемость. Обычно композицию используют там, где части реально неотделимы по смыслу (например value object внутри агрегата в DDD), а не для сервисов с бизнес-логикой, где нужна гибкость.

Агрегация

Ну смотрите, суть та же самая — “объект содержит объект другого класса”, но принципиальная разница в том, что владелец не создаёт объект и не управляет его жизнью. Объект приходит откуда-то извне (обычно через конструктор) и продолжает жить своей жизнью независимо от владельца.

class Freshener {
  constructor(private scent: string) {}
  refresh() {
    console.log(`пахнет ${this.scent}`);
  }
}

class Car {
  constructor(private freshener: Freshener) {}
  airFreshen() {
    this.freshener.refresh();
  }
}

const treeScent = new Freshener('ёлочка');
const car = new Car(treeScent);

// удалим car — освежитель продолжит жить своей жизнью
// его же можно передать в другую машину
const anotherCar = new Car(treeScent);

Freshener создаётся снаружи Car и может переиспользоваться где угодно — удаление car никак его не затронет.

Нюанс тут в том, что по коду агрегация и композиция выглядят почти одинаково — что там что там поле плюс возможно конструктор. Разница чисто семантическая, и на собесе как раз этим и подлавливают: “композиция это или агрегация?” — надо смотреть не на синтаксис, а на то, кто владеет жизненным циклом: создаётся объект внутри или передаётся снаружи.

Trade-off: агрегация даёт больше гибкости — объект можно шарить между несколькими владельцами, переиспользовать, подменять — но платишь тем, что владелец теряет полный контроль: он не знает, изменится ли переданный объект где-то ещё снаружи. Это уже шаг в сторону слабой связности, но ещё не полноценный DI в архитектурном смысле — это просто общий термин из ООП про совместное использование любого объекта (хоть освежителя воздуха, хоть сервиса).

Dependency Injection(способ организации зависимостей)

Ну смотрите, тут важно сразу отделить одну вещь: DI — это не третий, отдельный вид отношения между объектами. Это тот же принцип, что и агрегация (“объект передан снаружи, не создан внутри”), просто термин применяется конкретно к функциональным зависимостям — сервисам, репозиториям, клиентам, а не к произвольным объектам вроде освежителя воздуха.

class MongoUserRepository {
  getUsers() {
    return [
      /* запрос в mongo */
    ];
  }
}

class UserService {
  constructor(private repo: MongoUserRepository) {}
  // структурно — это агрегация (repo передан снаружи)
  // архитектурно — это уже DI (UserService не создаёт себе зависимость сам)
}

Тут важный момент: DI не требует абстракции сам по себе. Выше уже полноценный DI, просто зависимость типизирована конкретным классом, а не интерфейсом — и это нормально, DI просто про то, что зависимость приходит снаружи, а не создаётся внутри конструктора через new.

Нюанс тут в том, что DI бывает разных видов — через конструктор (constructor injection, самый распространённый, потому что явно фиксирует обязательные зависимости в сигнатуре), через сеттер (property injection) или через метод. И отдельно стоит различать DI как паттерн и DI-контейнер как инструмент: код выше — уже DI без единого фреймворка. Контейнер (в NestJS — через @Injectable()) нужен не чтобы “включить” DI, а чтобы автоматизировать сборку графа зависимостей, когда сервисов много и вручную городить new по всему приложению становится больно, плюс контейнер управляет lifecycle (singleton/request-scoped/transient).

Trade-off: DI даёт тестируемость (в тесте можно подставить фейковую реализацию) и убирает жёсткую привязку “кто создаёт кого”, но платишь тем, что понять “а какая конкретно реализация используется в runtime” уже не всегда видно прямо в коде сервиса — приходится смотреть туда, где происходит создание (composition root или конфиг контейнера).

Агрегация vs Dependency Injection

Ну смотрите, если совсем просто — это одно и то же, просто разные слова для разных ситуаций.

Агрегация и DI — это одно и то же по механизму, просто:

  • Агрегация — термин из классического ООП (описывает отношение между объектами вообще)
  • DI — тот же механизм, но термин из мира архитектуры/паттернов проектирования (описывает конкретно передачу зависимостей сервисам)

Никакой технической разницы нет, разница только в том, о чём мы говорим: про объекты вообще — агрегация, про зависимости сервисов конкретно — DI. Один паттерн — два словаря, в зависимости от контекста разговора.

Dependency Inversion Principle(Принцип проектирования)

Ну смотрите, а вот DIP — это уже не механизм, а принцип проектирования: высокоуровневые модули (бизнес-логика) не должны зависеть от низкоуровневых модулей (конкретных реализаций — БД, HTTP-клиенты), обе стороны должны зависеть от абстракции.

// БЕЗ DIP — зависимость идёт "вниз" напрямую
class UserService {
  private repo = new MongoUserRepository(); // жёстко завязан на конкретику
}
// С DIP — зависимость инвертирована через абстракцию
interface UserRepository {
  getUsers(): User[];
}

class MongoUserRepository implements UserRepository {
  getUsers() {
    return [
      /* mongo */
    ];
  }
}

class UserService {
  constructor(private repo: UserRepository) {} // тип — интерфейс
}

Смотри что произошло: раньше стрелка зависимости шла UserService → MongoUserRepository (высокий уровень знает про конкретный низкий). Теперь обе стороны — и UserService, и MongoUserRepository — зависят от UserRepository, а стрелка от реализации идёт вверх, к абстракции. Отсюда и “инверсия”.

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

Trade-off: DIP даёт возможность полностью менять реализацию (Mongo → Postgres → in-memory мок для тестов) вообще не трогая бизнес-логику, максимальная слабая связанность между слоями. Цена — лишняя абстракция там, где вариативность реализации никогда не понадобится: если у тебя одна БД и это железно не изменится, городить интерфейс “на будущее” — чистый overengineering, YAGNI. DIP оправдан, когда реально ожидается вариативность реализации или нужна изоляция для тестов — а не как рефлекс “на каждый класс пиши интерфейс”.