← назад к разделу

Аббревиатура SOLID — пять принципов проектирования классов, которые сформулировал Роберт Мартин. Их часто объясняют на примерах с геометрическими фигурами, и они кажутся теорией ради теории. На деле принципы — это ответы на конкретные боли: «почему так тяжело менять этот код?», «почему один класс правят сразу несколько команд?», «почему замена библиотеки ломает половину системы?».

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

SRP — одна ответственность

У класса должна быть одна причина для изменения.

Представьте сервис, который со временем вырос в «свалку»:

class OrderService {
  createOrder(request: CreateOrderRequest): Promise<OrderDto> { /* 120 строк */ }
  cancelOrder(id: OrderId): Promise<OrderDto> { /* 80 строк */ }
  searchOrders(filter: OrderFilter): Promise<Page<OrderDto>> { /* 60 строк */ }
  exportOrders(filter: OrderFilter): Promise<Buffer> { /* 90 строк */ }
  recalculateStatistics(): Promise<void> { /* 70 строк */ }
}

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

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

class CreateOrderHandler {

  constructor(
    private readonly orders: OrderRepository,
    private readonly pricing: PricingPolicy,
  ) {}

  async handle(cmd: CreateOrderCommand): Promise<OrderDto> {
    const order = Order.create(cmd.customerId, cmd.lines, this.pricing);
    await this.orders.save(order);
    return OrderDto.from(order);
  }
}

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

Хороший сигнал нарушения SRP — когда класс содержит слово «and» в устном описании: «он создаёт заказ и отправляет письмо и пересчитывает статистику».

OCP — открыт для расширения, закрыт для изменения

Поведение системы расширяется добавлением кода, а не правкой существующего.

Типичная картина нарушения — switch или цепочка if, в которую нужно дописывать ветку каждый раз, когда появляется новый вариант:

function discount(order: Order): number {
  switch (order.customer.type) {
    case 'VIP':      return order.total * 0.10;
    case 'EMPLOYEE': return order.total * 0.20;
    case 'REGULAR':  return 0;
  }
}

Новый тип клиента — правка этого метода. И всех остальных switch по тому же признаку в других местах кода.

Решение — объявить точку расширения (интерфейс), и добавлять новое поведение новым классом, не трогая существующий:

interface DiscountPolicy {
  supports(customer: Customer): boolean;
  discount(order: Order): number;
}

class DiscountCalculator {

  constructor(private readonly policies: DiscountPolicy[]) {}

  discount(order: Order): number {
    const policy = this.policies.find(p => p.supports(order.customer));
    return policy?.discount(order) ?? 0;
  }
}

Новый тип скидки — новый класс, реализующий DiscountPolicy. Существующий код не трогается.

Оговорка: OCP не значит «везде плодить интерфейсы на всякий случай». Если вариантов заведомо два и новых не ожидается — switch честнее. Принцип применяется там, где расширение реально ожидается.

LSP — принцип подстановки Лисков

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

Нарушение обычно выглядит как наследование ради повторного использования кода, когда подкласс нарушает ожидания родителя:

class CachedProductRepository extends TypeOrmProductRepository {

  private readonly cache = new Map<string, Product>();

  override async findById(id: ProductId): Promise<Product | null> {
    const cached = this.cache.get(id.value);
    if (cached) return cached;
    const loaded = await super.findById(id);
    if (loaded) this.cache.set(id.value, loaded);
    return loaded;
  }

  override async delete(id: ProductId): Promise<void> {
    throw new Error('кеш не поддерживает удаление');
  }
}

Класс называет себя репозиторием, но delete бросает исключение. Код, который работал с базовым классом, со «специализированным» ломается — это и есть нарушение LSP: подкласс сузил контракт.

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

class CachingProductRepository implements ProductRepository {

  constructor(
    private readonly delegate: ProductRepository,
    private readonly cache: Cache,
  ) {}

  async findById(id: ProductId): Promise<Product | null> {
    return this.cache.getOrLoad(id.value, () => this.delegate.findById(id));
  }

  async delete(id: ProductId): Promise<void> {
    await this.delegate.delete(id);  // удаление выполняется
    await this.cache.evict(id.value); // и кеш инвалидируется
  }
}

Теперь CachingProductRepository подставляется вместо любого другого ProductRepository без сюрпризов.

Правило-подсказка: если переопределённый метод бросает ошибку «операция не поддерживается» — почти наверняка нарушен LSP.

ISP — разделение интерфейсов

Клиент не должен зависеть от методов, которые он не использует.

Интерфейсы имеют свойство разрастаться: сначала был save и findById, потом добавили findForListing, потом exportOrders, потом archiveOlderThan:

interface OrderStorage {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
  findForListing(filter: OrderFilter, page: Pagination): Promise<Page<OrderListRow>>;
  findForExport(from: Date, to: Date): Promise<OrderExportRow[]>;
  archiveOlderThan(date: Date): Promise<void>;
}

Обработчик команды использует два метода из пяти, но зависит от всех. Изменение сигнатуры метода экспорта вынуждает пересобирать и его. В тестах — приходится заглушать все пять методов, хотя нужны только два.

Решение — разрезать по потребителям, не по таблице:

// для команд — только то, что нужно
interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: OrderId): Promise<Order | null>;
}

// для чтения и отчётов — отдельно
interface OrderViewRepository {
  findForListing(filter: OrderFilter, page: Pagination): Promise<Page<OrderListRow>>;
  findForExport(from: Date, to: Date): Promise<OrderExportRow[]>;
}

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

DIP — инверсия зависимостей

Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.

Простыми словами: бизнес-логика не должна напрямую знать про конкретные инструменты (базу данных, почтовый сервер, внешний API). Иначе при смене инструмента придётся трогать бизнес-логику.

Типичное нарушение — доменная модель знает про конкретный транспорт уведомлений:

class Order {
  cancel(mailer: SmtpMailer): void {
    this.status = 'CANCELLED';
    mailer.send(this.customer.email, 'Заказ отменён');
  }
}

Доменная модель зависит от SmtpMailer — конкретной инфраструктурной детали. Захотите перейти на push-уведомления — придётся менять Order. Захотите протестировать отмену без реального SMTP — сложно.

Инверсия: домен объявляет что ему нужно (интерфейс), а инфраструктура решает как это реализовать:

// интерфейс объявлен рядом с доменом, говорит на языке домена
interface NotificationPort {
  orderCancelled(order: Order): Promise<void>;
}

// реализация — в инфраструктурном слое
@Injectable()
class SmtpNotificationAdapter implements NotificationPort {

  constructor(private readonly mailer: MailerService) {}

  async orderCancelled(order: Order): Promise<void> {
    await this.mailer.sendMail(buildMessage(order));
  }
}

Order больше ничего не знает про SMTP. В тесте NotificationPort легко подменяется заглушкой. Смена транспорта — новый адаптер, домен не трогается.

Обратите внимание: зависимость идёт от SmtpNotificationAdapter к NotificationPort (который живёт рядом с доменом), а не наоборот. Направление зависимостей перевёрнуто относительно направления вызова — отсюда название «инверсия».

Коротко

  • SRP: у класса одна причина для изменения. Если класс делает «и то, и это» — его пора разделить.
  • OCP: новое поведение добавляется новым классом, не правкой существующего. Интерфейс как точка расширения.
  • LSP: подкласс или реализация заменяют оригинал без сюрпризов. Ошибка «операция не поддерживается» в переопределённом методе — красный флаг.
  • ISP: интерфейс содержит только то, что нужно конкретному потребителю. Большой интерфейс режется на несколько узких.
  • DIP: бизнес-логика зависит от интерфейсов, а не от конкретных классов. Направление зависимостей — к домену, не от него.

Принципы работают вместе: класс с одной ответственностью (SRP) зависит от узкого интерфейса (ISP), объявленного в домене (DIP), реализации которого взаимозаменяемы (LSP), а новые варианты поведения добавляются новыми классами (OCP).

Что почитать дальше

  • Паттерны GoF — конкретные приёмы, которые реализуют идеи SOLID на практике.
  • GRASP на примерах — принципы распределения ответственности между классами.
  • Гексагональная архитектура — DIP и ISP, доведённые до структуры модулей.