Site logo

Developer Blog

Pavel Koltyshev

Чистая архитектура с принципами SOLID

Содержание

Составителем принципов SOLID является Роберт Мартин (также известный как Дядя Боб), американский программист, консультант и автор книг по разработки ПО.

Роберт Мартин

Роберт Мартин начал собирать принципы создания поддерживаемого кода в конце 1980-х годов. В окончательном виде принципы были сформулированы в начале 2000-х годов.

В 2004 году появился термин SOLID, в котором каждая буква обозначает отдельный принцип:

  • SRP: Single Responsibility Principle - принцип единственной ответственности.
    Фактическое следствие закона Конвея: оптимальная структура программной системы формируется преимущественно под влиянием социальной структуры организации, которая её использует. Поэтому каждый программный модуль имеет одну и только одну причину для изменения.
  • OCP: Open-Closed Principle - принцип открытости/закрытости.
    Сформулированный Бертраном Мейером в 1980-х годах, этот принцип утверждает, что удобная для модификации система должна позволять изменять своё поведение путем добавления нового кода, а не изменением уже существующего.
  • LSP: Liskov Substitution Principle - принцип подстановки Барбары Лисков.
    Сформулированное в 1988 году определение подтипов Барбары Лисков гласит, что для построения программных систем из взаимозаменяемых компонентов эти компоненты должны соблюдать контракт, обеспечивающий их корректную замену друг другом.
  • ISP: Interface Segregation Principle - принцип разделения интерфейсов.
    Этот принцип рекомендует разработчикам избегать зависимостей от неиспользуемого функционала.
  • DIP: Dependency Inversion Principle - принцип инверсии зависимостей.
    Код, определяющий высокоуровневую логику, не должен зависеть от кода, реализующего низкоуровневые детали. Напротив, детали должны зависеть от этой логики.

Разберем каждый принцип более детально.

SRP: Single Responsibility Principle (принцип единственной ответственности)

Не путайте этот принцип с другим часто используемым в программировании, который не относится к SOLID - "каждая функция должна делать что-то одно". Мы используем его, когда разбиваем большие функции выполняющие множество действий на более мелкие. Но принцип единственной ответственности про другое.

Первоначально принцип единственной ответственности звучал так:

Модуль должен иметь одну и только одну причину для изменения.

Более современная версия звучит так:

Модуль должен отвечать за одного и только за одного актора.

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

Модуль это файл с исходным кодом или набор логически связанных функций/классов.

Например мы создали класс, в котором связали логику определяемую разными акторами, тем самым нарушив принцип единственной ответственности.

class Employee {
  calculatePay() {} // логику определяет бухгалтерия
  reportHours() {} // логику определяет отдел персонала
  save() {} // логику определяет администратор БД
}

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

class EmployeeFacade {
  constructor(data: EmployeeData) {
    this.data = data;
    this.calculator = new PayCalculator();
    this.reporter = new HourReporter();
    this.saver = new EmployeeSaver();
  }

  calculatePay() {
    return this.calculator.calculatePay(this.data);
  }

  reportHours() {
    return this.reporter.reportHours(this.data);
  }

  saveEmployee() {
    return this.saver.saveEmployee(this.data);
  }
}

// логику определяет бухгалтерия
class PayCalculator {
  calculatePay(data: EmployeeData) {} // расчет зарплаты
}

// логику определяет отдел персонала
class HourReporter {
  reportHours(data: EmployeeData) {} // создание отчета
}

// логику определяет администратор БД
class EmployeeSaver {
  saveEmployee(data: EmployeeData) {} // сохранение данных
}

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

Недостатком является добавление новых сущностей в кодовую базу (классов PayCalculator, HourReporter, EmployeeSaver), но это единственное решение если вам необходимо разделить логику по акторам.

Преимуществом является отсутствие связанности между кодом относящимся к разным акторам. Изменение необходимые для одного актора теперь будут производиться в строго отведенном для этого месте.

Принцип единственной ответственности на уровне компонентов превращается в принцип согласованного изменения (Common Closure Principle; CCP), а на архитектурном уровне в принцип оси изменения (Axis of Change), отвечающий за создание архитектурных границ.

OCP: Open-Closed Principle (принцип открытости/закрытости)

Бертран Мейер

Бертран Мейер, создатель языка программирования Eiffel, сформулировал принцип открытости/закрытости в 1988 году:

Программные сущности должны быть открыты для расширения и закрыты для изменения.

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

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

Многие популярные библиотеки имеют возможность для добавления нового функционала без переписывания их исходного кода, например express.js позволяет добавлять функционал используя middlewares, а fastify расширяется через систему плагинов.

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

Барбара Лисков

Барбара Лисков, американский ученый в области информатики в 1987 году впервые сформулировала этот принцип в своем докладе "Абстракция данных и иерархия" ("Data Abstraction and Hierarchy").

Позже, в 1994 году, Лисков и Джин Винг формализовали этот принцип в статье "Поведенческое понятие подтипизации" ("A Behavioral Notion of Subtyping"):

Если S является подтипом T, то объекты типа T в программе могут быть заменены объектами типа S без изменения работы программы.

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

Классическим примером нарушения принципа подстановки Барбары Лисков может служить известная проблема квадрат/прямоугольник:

/** Прямоугольник */
class Rectangle {
  width = 0;
  height = 0;

  setWidth(value: number) {
    this.width = value;
  }

  setHeight(value: number) {
    this.height = value;
  }

  /** Возвращает площадь */
  getArea() {
    return this.width * this.height;
  }
}

/** Квадрат */
class Square extends Rectangle {
  size = 0;

  /** Возвращает площадь */
  getArea() {
    return this.size ** 2;
  }
}

function printArea(rect: Rectangle) {
  rect.setWidth(2);
  rect.setHeight(5);
  console.log(rect.getArea());
}

printArea(new Rectangle()); // 10 (площадь рассчитана ПРАВИЛЬНО)
printArea(new Square()); // 0 (площадь рассчитана НЕПРАВИЛЬНО)

Почему этот кода нарушает принцип подстановки Барбары Лисков?

  1. Подкласс Square изменяет контракт поведения родителя Rectangle.
  2. Код, который ожидает работать с Rectangle, может вести себя непредсказуемо при подстановке Square.
  3. Square не может быть подтипом Rectangle, потому что его поведение отличается.

Для исправления нарушения принципа подстановки Барбары Лисков нам нужно вынести наши подклассы Rectangle, Square под общий суперкласс Shape.

class Shape {
  getArea(): number {
    throw new Error('Not implemented');
  }
}

/** Прямоугольник */
class Rectangle extends Shape {
  constructor(
    public width: number,
    public height: number,
  ) {
    super();
  }

  /** Возвращает площадь */
  getArea() {
    return this.width * this.height;
  }
}

/** Квадрат */
class Square extends Shape {
  constructor(public size: number) {
    super();
  }

  /** Возвращает площадь */
  getArea() {
    return this.size ** 2;
  }
}

function printArea(rect: Shape) {
  console.log(rect.getArea());
}

printArea(new Rectangle(2, 5)); // 10 (площадь рассчитана ПРАВИЛЬНО)
printArea(new Square(2)); // 4 (площадь рассчитана ПРАВИЛЬНО)

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

Роберт Мартин

Принцип разделения интерфейсов звучит так:

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

Клиенты это потребители интерфейсов, те места в программном коде, где мы используем интерфейсы.

Рассмотрим пример нарушения принципа разделения интерфейсов:

interface Animal {
  walk(): void;
  fly(): void;
}

class Dog implements Animal {
  walk() {
    console.log('Ходить');
  }

  fly() {
    throw new Error('Собаки не могут летать');
  }
}

class Duck implements Animal {
  walk() {
    console.log('Ходить');
  }

  fly() {
    console.log('Летать');
  }
}

Интерфейс Animal, который мы реализуем в классе Dog содержит метод fly(), который не имеет смысла в Dog.

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

Чем меньше методов реализует наш класс, тем меньше связанность с конкретным интерфейсом.

Для устранения нарушения принципа разделения интерфейсов, нам необходимо вынести методы, которые мы используем в отдельные интерфейсы:

interface Walkable {
  walk(): void;
}

interface Flyable {
  fly(): void;
}

class Dog implements Walkable {
  walk() {
    console.log('Ходить');
  }
}

class Duck implements Walkable, Flyable {
  walk() {
    console.log('Ходить');
  }

  fly() {
    console.log('Летать');
  }
}

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

DIP: Dependency Inversion Principle (принцип инверсии зависимостей)

Роберт Мартин

Принцип инверсии зависимостей звучит так:

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

Рассмотрим пример нарушения принципа инверсии зависимостей:

class SberBankCard {
  pay(amount: number) {
    console.log('Оплата');
  }
}

class PaymentService {
  bankCard = new SberBankCard(); // Жесткая зависимость

  userPay(amount: number) {
    this.bankCard.pay(amount);
  }
}

Класс PaymentService напрямую зависит от класса SberBankCard, его нельзя легко заменить, например на MockBankCard.

Для устранения нарушения принципа инверсии зависимостей нам необходимо ввести интерфейс IBankCard от которого будут зависеть реализации PaymentService и SberBankCard.

interface IBankCard {
  pay(amount: number): void;
}

class SberBankCard implements IBankCard {
  pay(amount: number) {
    console.log('Оплата');
  }
}

class MockBankCard implements IBankCard {
  pay(amount: number) {
    console.log('Имитация оплаты для тестов');
  }
}

class PaymentService {
  constructor(public bankCard: IBankCard) {}

  userPay(amount: number) {
    this.bankCard.pay(amount);
  }
}

// Легко подставляем нужную карту для оплаты
const sberService = new PaymentService(new SberBankCard());
sberService.userPay(1000); // Реальная оплата

const mockService = new PaymentService(new MockBankCard());
mockService.userPay(1000); // Имитация оплаты для тестирования

Теперь наш код, соответствует принципу инверсии зависимостей, так как PaymentService зависит от абстракции IBankCard, а не от конкретной реализации.

Если нам нужно будет заменить банковскую карту, мы можем просто создать новый класс, реализующий IBankCard, без изменений в PaymentService.