Принципы SOLID — Примеры

Пример 1 — Один класс, две аудитории

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

class Invoice {
  constructor(private order: Order) {}

  total(): number {
    return this.order.lines.reduce((sum, line) => sum + line.price, 0);
  }

  toHtml(): string {
    return `<h1>Счёт ${this.order.id}</h1>`;
  }

  async send(): Promise<void> {
    await mailer.send(this.order.customer.email, this.toHtml());
  }
}

Решение

class Invoice {
  constructor(private order: Order) {}

  total(): number {
    return this.order.lines.reduce((sum, line) => sum + line.price, 0);
  }

  toHtml(): string {
    return `<h1>Счёт ${this.order.id}</h1>`;
  }
}

class InvoiceEmailer {
  constructor(private mailer: Mailer) {}

  async send(invoice: Invoice, to: string): Promise<void> {
    await this.mailer.send(to, invoice.toHtml());
  }
}

Подсказкой были две аудитории. Финансы просят изменений в toHtml; эксплуатация спрашивает про send, его получателей и правила повторов, и ни одной из групп не нужно читать код другой. После разделения у каждого класса есть одно предложение, описывающее, почему он изменится, а рассыльщик можно заменить, не трогая вёрстку. Ловушка в том, что исходный класс — всего пятнадцать строк: размер не является проверкой, а проверкой является число причин для изменения. Вторая причина при этом остаётся невидимой, пока эксплуатация не попросит чего-нибудь.

Пример 2 — Сервис, приваренный к своей базе данных

OrderService сам создаёт своё хранилище, поэтому модульный тест не может выполниться без базы данных, а сервис нельзя нацелить на другое хранилище. Скажите, какой принцип нарушен, затем инвертируйте зависимость и покажите тест, подставляющий подделку.

class OrderService {
  private repo = new PostgresOrderRepo(process.env.DATABASE_URL);

  async place(order: Order) {
    const saved = await this.repo.insert(order);
    return saved.id;
  }
}

Решение

interface OrderRepo {
  insert(order: Order): Promise<{ id: number }>;
}

class OrderService {
  constructor(private repo: OrderRepo) {}

  async place(order: Order) {
    const saved = await this.repo.insert(order);
    return saved.id;
  }
}

// тест
const fake: OrderRepo = { insert: async () => ({ id: 7 }) };
expect(await new OrderService(fake).place(order)).toBe(7);

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