Если вы хоть немного писали код на TypeScript, особенно в среде с реальными задачами и коллегами, то, скорее всего, сталкивались с запутанными классами, непредсказуемыми зависимостями и странным поведением при доработках. Один неосторожный шаг — и вроде бы безобидное изменение ломает половину проекта. Это обычная история в жизни любого разработчика.

Чтобы таких ситуаций было меньше, существует набор принципов под названием SOLID. Это не фреймворк, не библиотека и даже не панацея. Это всего лишь рекомендации, которые помогают писать код, с которым потом не стыдно работать — неважно, вам или кому-то ещё.

Принципы SOLID пришли из объектно-ориентированного программирования, но отлично применимы в TypeScript, особенно когда вы работаете с классами, интерфейсами, типами и слоями логики. Они помогают:

  • делать код гибким для изменений, но устойчивым к поломкам;
  • разделять ответственность так, чтобы каждая часть делала что-то одно, но хорошо;
  • писать тестируемый код;
  • уменьшать зависимость между модулями.

Принципы SOLID

S — Single Responsibility Principle (Принцип единственной ответственности)

Идея проста: один класс (или модуль) должен иметь только одну причину для изменения. Это не значит, что он должен делать одну строчку кода. Это значит, что он отвечает за один конкретный аспект поведения. И если завтра этот аспект изменится, затронут будет только этот модуль.

Почему это важно?

Когда один класс делает всё сразу — например, и обрабатывает заказы, и валидирует формы, и сохраняет данные, и пишет в лог — он становится хрупким и неуправляемым. Любое изменение в одной части логики может ненароком сломать другую.

Разделяя ответственность, вы получаете:

  • читаемый и понятный код;
  • простую отладку;
  • возможность переиспользовать части логики;
  • более надёжные изменения: вы уверены, что затрагиваете только нужную часть.

Пример без SRP (антипаттерн)

class OrderService {
  createOrder(orderData: any) {
    // 1. Валидация
    if (!orderData.productId || orderData.quantity <= 0) {
      throw new Error("Invalid data");
    }

    // 2. Сохранение в базу
    console.log("Saving order to DB...");

    // 3. Отправка письма
    console.log("Sending confirmation email...");
  }
}

Этот OrderService делает три вещи:

  • валидирует заказ;
  • работает с хранилищем;
  • отправляет почту.

Изменится логика валидации? Нужно лезть сюда. Изменится SMTP-сервер? Тоже сюда. А теперь представьте, что этим методом пользуются десятки мест в коде. Опасно.

Пример с SRP (правильный подход)

class OrderValidator {
  static validate(orderData: any): void {
    if (!orderData.productId || orderData.quantity <= 0) {
      throw new Error("Invalid data");
    }
  }
}

class OrderRepository {
  save(orderData: any): void {
    console.log("Saving order to DB...");
  }
}

class EmailService {
  sendConfirmationEmail(orderData: any): void {
    console.log("Sending confirmation email...");
  }
}

class OrderService {
  constructor(
    private repository: OrderRepository,
    private emailService: EmailService
  ) {}

  createOrder(orderData: any): void {
    OrderValidator.validate(orderData);
    this.repository.save(orderData);
    this.emailService.sendConfirmationEmail(orderData);
  }
}

Теперь у нас:

  • OrderValidator — проверяет данные;
  • OrderRepository — сохраняет в хранилище;
  • EmailService — рассылает письма;
  • OrderService — просто связывает эти кусочки.

Если нужно поменять способ хранения или формат валидации — меняется только нужный модуль, остальные не страдают.

В реальности

Не обязательно всё дробить на миллионы классов — важно осознанно отделять логические зоны ответственности. Иногда достаточно просто вынести функции в отдельные файлы или модули, и уже станет легче.

O — Open/Closed Principle (Принцип открытости/закрытости)

Идея: Модули, классы, функции должны быть открыты для расширения, но закрыты для изменения. Проще говоря: когда вам нужно изменить поведение, не трогайте старый код — добавьте новый.

Пример нарушения OCP

Допустим, у вас есть расчёт стоимости доставки:

class DeliveryCostCalculator {
  calculate(type: string, weight: number): number {
    if (type === "regular") {
      return weight * 10;
    } else if (type === "express") {
      return weight * 20;
    } else {
      throw new Error("Unknown delivery type");
    }
  }
}

Работает? Да. А теперь пришёл новый тип доставки — "pickup". Придётся лезть внутрь класса и править if. Это нарушение принципа.

Решение с OCP — через полиморфизм

interface DeliveryStrategy {
  calculate(weight: number): number;
}

class RegularDelivery implements DeliveryStrategy {
  calculate(weight: number): number {
    return weight * 10;
  }
}

class ExpressDelivery implements DeliveryStrategy {
  calculate(weight: number): number {
    return weight * 20;
  }
}

class PickupDelivery implements DeliveryStrategy {
  calculate(weight: number): number {
    return 0; // бесплатно
  }
}

class DeliveryCostCalculator {
  constructor(private strategy: DeliveryStrategy) {}

  calculate(weight: number): number {
    return this.strategy.calculate(weight);
  }
}

Использование:

const express = new DeliveryCostCalculator(new ExpressDelivery());
console.log(express.calculate(2)); // 40

const pickup = new DeliveryCostCalculator(new PickupDelivery());
console.log(pickup.calculate(5)); // 0

Что мы выиграли?

  • Не трогаем старые классы;
  • Добавляем новый тип доставки — просто создаём класс;
  • Упрощается тестирование;

Поведение изолировано: если баг, он только в конкретной стратегии.

А если без классов?

В TypeScript часто удобнее использовать функции и объекты:

type DeliveryStrategy = (weight: number) => number;

const strategies: Record<string, DeliveryStrategy> = {
  regular: (w) => w * 10,
  express: (w) => w * 20,
  pickup: (_) => 0,
};

function calculateDelivery(type: string, weight: number): number {
  const strategy = strategies[type];
  if (!strategy) throw new Error("Unknown delivery type");
  return strategy(weight);
}

Та же идея: расширяете объект strategies, не трогая логику функции calculateDelivery.

L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)

Если коротко: объект подкласса должен спокойно заменять объект родительского класса, не ломая поведение. То есть, если у вас есть код, который работает с базовым классом, то он должен также работать с любым его наследником — без сюрпризов.

Что это значит на практике?

Если вы создаёте класс, который наследуется от другого, не нужно:

  • менять смысл методов;
  • выбрасывать исключения, где родитель их не выбрасывал;
  • делать методы “заглушками”;
  • нарушать логические ожидания.

Если это происходит — скорее всего, ваш подкласс не подходит для такого наследования

Плохой пример: нарушение LSP

class Bird {
  fly() {
    console.log("Птица летит");
  }
}

class Duck extends Bird {}

class Penguin extends Bird {
  fly() {
    throw new Error("Пингвины не умеют летать");
  }
}

function makeBirdFly(bird: Bird) {
  bird.fly();
}

Выглядит логично — Penguin тоже птица. Но как только вы вызываете makeBirdFly(new Penguin()), программа падает с ошибкой. Это нарушение принципа Лисков: наследник (Penguin) не может быть подставлен туда, где ожидается родитель (Bird), без изменения логики.

Как исправить? Через разделение абстракций

interface FlyingBird {
  fly(): void;
}

interface Bird {
  eat(): void;
}

class Duck implements Bird, FlyingBird {
  eat() {
    console.log("Утка ест хлеб");
  }

  fly() {
    console.log("Утка летит");
  }
}

class Penguin implements Bird {
  eat() {
    console.log("Пингвин ест рыбу");
  }
}

📢 Подписывайтесь на наш Telegram-канал.

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

👉 https://t.me/codelab_channel

Теперь мы не делаем ложных предположений: если птица умеет летать — она реализует интерфейс FlyingBird. Если не умеет — просто Bird.

Использование:

function makeItFly(bird: FlyingBird) {
  bird.fly();
}

makeItFly(new Duck());      // ✅ Работает
// makeItFly(new Penguin()); // ❌ Ошибка на этапе компиляции

Пример ближе к реальности: формы и сохранение

class Form {
  save() {
    console.log("Сохраняем данные формы");
  }
}

class ReadOnlyForm extends Form {
  save() {
    throw new Error("Нельзя сохранить: форма только для чтения");
  }
}

Кто-то напишет такой код в надежде переиспользовать Form, но при вызове save() поведение неожиданное. Лучше сделать ReadOnlyForm не наследником, а отдельной сущностью, где метода save просто нет.

Наследование — штука полезная, но если его использовать неосознанно, оно легко приводит к поломкам. Принцип Лисков говорит: если вы создаёте подкласс — он должен вести себя честно и предсказуемо. Если это не получается — возможно, наследование здесь неуместно.

I — Interface Segregation Principle (Принцип разделения интерфейсов)

Суть: интерфейсы не должны заставлять вас реализовывать то, что вам не нужно. Лучше несколько узких интерфейсов, чем один “всё включено”.

В чём проблема?

Иногда разработчики делают огромные интерфейсы “на всякий случай” — вдруг пригодится. Но когда вы начинаете использовать такой интерфейс, приходится реализовывать методы, которые не имеют смысла в вашем контексте.

В итоге:

  • появляется “мёртвый” код;
  • нарушается смысл;
  • растёт технический долг.

Пример плохого интерфейса (нарушение ISP)

interface Worker {
  work(): void;
  eat(): void;
}

class HumanWorker implements Worker {
  work() {
    console.log("Человек работает");
  }

  eat() {
    console.log("Человек ест");
  }
}

class RobotWorker implements Worker {
  work() {
    console.log("Робот работает");
  }

  eat() {
    // 🤨 Роботы не едят — а метод реализовать приходится
    throw new Error("Робот не ест");
  }
}

Выглядит нелепо. Зачем RobotWorker метод eat, если он ему не нужен?

Как исправить? Разделить интерфейс

interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

class HumanWorker implements Workable, Eatable {
  work() {
    console.log("Человек работает");
  }

  eat() {
    console.log("Человек ест");
  }
}

class RobotWorker implements Workable {
  work() {
    console.log("Робот работает");
  }
}

Теперь каждый реализует только то, что относится к нему. Интерфейсы — узкие и осмысленные.

Пример из жизни: API клиента

interface APIClient {
  get(): void;
  post(): void;
  put(): void;
  delete(): void;
}

Кажется логично, но что если вы делаете ReadOnlyApiClient?

class ReadOnlyApiClient implements APIClient {
  get() {
    console.log("Получаем данные");
  }

  post() {
    throw new Error("Метод POST запрещён");
  }

  put() {
    throw new Error("Метод PUT запрещён");
  }

  delete() {
    throw new Error("Метод DELETE запрещён");
  }
}

Такое использование — сигнал: интерфейс слишком широкий.

Разделяем правильно:

interface Gettable {
  get(): void;
}

interface Postable {
  post(): void;
}

interface Puttable {
  put(): void;
}

interface Deletable {
  delete(): void;
}

class ReadOnlyApiClient implements Gettable {
  get() {
    console.log("Получаем данные (только чтение)");
  }
}

Теперь клиент использует только нужный набор интерфейсов. Остальной код становится понятнее, жёстче типизируется, и легче тестируется.

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

D — Dependency Inversion Principle (Принцип инверсии зависимостей)

Последний принцип из SOLID — и, возможно, самый абстрактный на первый взгляд. Но идея на деле простая: Не зависьте напрямую от конкретных реализаций. Зависите от абстракций.

Что это значит?

Вместо того чтобы класс сам создавал и настраивал свои зависимости (например, логгер, репозиторий, почтовый сервис), он получает их извне — в виде интерфейсов или абстракций.

Это даёт вам:

  • лёгкую подмену реализаций (например, для тестов);
  • слабую связанность между частями приложения;
  • более чистый и расширяемый код.

Плохой пример: жёсткая зависимость

class NotificationService {
  private emailClient = new EmailClient(); // ⚠ жёсткая связка

  send(message: string) {
    this.emailClient.sendEmail(message);
  }
}

Если вдруг вам понадобится логгер вместо почты, или мок для тестов — придётся лезть внутрь NotificationService. Это нарушение DIP.

Хороший пример: через абстракцию

interface Notifier {
  send(message: string): void;
}

class EmailClient implements Notifier {
  send(message: string): void {
    console.log("📧 Отправляем по email:", message);
  }
}

class LoggerNotifier implements Notifier {
  send(message: string): void {
    console.log("📝 Логируем сообщение:", message);
  }
}

class NotificationService {
  constructor(private notifier: Notifier) {}

  notifyUser(message: string) {
    this.notifier.send(message);
  }
}

Теперь NotificationService зависит не от конкретного клиента, а от абстракции — интерфейса Notifier.

Использование:

const emailService = new NotificationService(new EmailClient());
emailService.notifyUser("Ваш заказ оформлен");

const logService = new NotificationService(new LoggerNotifier());
logService.notifyUser("Тестовое уведомление");

Вы легко можете заменить реализацию на любую нужную — без изменений в NotificationService.

А что насчёт тестов?

Теперь можно просто передать заглушку:

class StubNotifier implements Notifier {
  send(message: string): void {
    console.log("🔧 [Заглушка] Сообщение отправлено:", message);
  }
}

const testService = new NotificationService(new StubNotifier());
testService.notifyUser("Тестовое сообщение"); // не уходит никуда, но поведение проверено

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

 

 

Комментарии

0

Без регистрации и смс