Скидки до 50% и курс в подарок : : Выбрать курс
Код
#статьи

Принципы SOLID: что это и почему их используют все сеньоры

SRP, OCP, LSP, ISP, DIP — разбираем основы современной архитектуры с примерами на Java.

Иллюстрация: Оля Ежак для Skillbox Media

SOLID — это пять ключевых принципов проектирования классов в объектно-ориентированном программировании. Они помогают создавать понятный, гибкий и легко поддерживаемый код. Благодаря этим принципам архитектура приложения становится надёжнее и удобнее для развития. В статье мы познакомимся с каждым из них и разберём примеры на Java. Так что берите чашку кофе или чая — и начнём!

Содержание


Что такое SOLID и зачем это придумали

Принципы SOLID сформулировал американский инженер-программист Роберт С. Мартин. В начале 2000-х он систематизировал подходы к объектно-ориентированному проектированию в статье Design Principles and Design Patterns. Позже, в 2004 году, консультант по разработке Майкл Физерс предложил объединить эти идеи под аббревиатурой SOLID:

  • S — Single Responsibility Principle, принцип единственной ответственности.
  • O — Open-Closed Principle, принцип открытости / закрытости.
  • — Liskov Substitution Principle, принцип подстановки Барбары Лисков.
  • I — Interface Segregation Principle, принцип разделения интерфейсов.
  • D — Dependency Inversion Principle, принцип инверсии зависимостей.

Эти принципы помогают решать типичные проблемы объектно-ориентированных программ:

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

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

В следующих разделах мы разберём все принципы по очереди и потренируемся применять их на практике. Чтобы понять материал, вам понадобятся базовые знания Java и основ ООП. Если вы только начинаете изучать программирование, советуем сначала прочитать эти статьи:

Если вам так удобнее, вместо IntelliJ IDEA можно использовать VS Code с пакетом расширений Extension Pack for Java. Этого будет достаточно для запуска примеров из статьи — мы специально сделали их довольно простыми. Например, поля объявлены без private, геттеры и сеттеры не используются, а вместо реальной логики — просто System.out.println().

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

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

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

Допустим, у нас есть класс Book, который хранит информацию о книге. Добавим к нему класс Invoice, отвечающий за оформление счёта в книжном магазине. Давайте посмотрим, как это может выглядеть в коде:

public class MainSRPViolation {
    public static void main(String[] args) {
        // Создаём книгу
        Book book = new Book("Clean Code", "Robert C. Martin", 40);

        // Создаём счёт на 3 экземпляра книги
        Invoice invoice = new Invoice(book, 3);

        // Печатаем счёт
        invoice.printInvoice();

        // Сохраняем счёт в файл
        invoice.saveToFile("invoice.txt");
    }
}

// Книга — просто данные
class Book {
    String name;
    String authorName;
    int price;

    public Book(String name, String authorName, int price) {
        this.name = name;
        this.authorName = authorName;
        this.price = price;
    }
}

// Invoice нарушает SRP: отвечает за расчёт, за вывод и сохранение
class Invoice {
    Book book;
    int quantity;
    double total;

    public Invoice(Book book, int quantity) {
        this.book = book;
        this.quantity = quantity;
        this.total = calculateTotal();
    }

    // Считаем сумму
    public double calculateTotal() {
        return book.price * quantity;
    }

    // Печатаем счёт
    public void printInvoice() {
        System.out.println(quantity + "x " + book.name + " " + book.price + "$");
        System.out.println("Total: " + total + "$");
    }

    // Сохраняем счёт (имитация процесса)
    public void saveToFile(String filename) {
        System.out.println("Сохраняем счёт в файл: " + filename);
    }
}

Результат вывода:

3x Clean Code 40$
Total: 120.0$
Сохраняем счёт в файл: invoice.txt

На первый взгляд, всё кажется логичным, но на деле этот класс нарушает первый принцип SOLID сразу в нескольких местах:

  • Метод printInvoice() отвечает за вывод счёта. Если нужно изменить формат отображения — например, добавить поддержку PDF или HTML, — придётся редактировать сам класс Invoice, что нарушает принцип единственной ответственности. Логика отображения не должна смешиваться с бизнес-логикой.
  • Метод saveToFile() отвечает за сохранение счёта в файл. Но если в будущем потребуется сохранять данные, например, в базу данных или отправлять их по API, снова придётся править класс Invoice.

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

Нарушение SRP: Invoice совмещает логику расчёта, вывода и сохранения
Изображение: Mermaid Chart / Skillbox Media

Чтобы соблюдать принцип единственной ответственности, разделим задачи между классами: Invoice будет рассчитывать стоимость заказа, InvoicePrinter — выводить счёт, а InvoicePersistence — сохранять его:

public class MainSRPRefactored {
    public static void main(String[] args) {
        // Создаём книгу
        Book book = new Book("Clean Code", "Robert C. Martin", 40);

        // Создаём счёт на 3 книги
        Invoice invoice = new Invoice(book, 3);

        // Печатаем счёт
        InvoicePrinter printer = new InvoicePrinter(invoice);
        printer.print();

        // Сохраняем счёта
        InvoicePersistence persistence = new InvoicePersistence(invoice);
        persistence.saveToFile("invoice.txt");
    }
}

// Книга — просто данные
class Book {
    public String name;
    public String authorName;
    public int price;

    public Book(String name, String authorName, int price) {
        this.name = name;
        this.authorName = authorName;
        this.price = price;
    }
}

// Счёт — расчёт суммы и данные заказа
class Invoice {
    public Book book;
    public int quantity;
    public double total;

    public Invoice(Book book, int quantity) {
        this.book = book;
        this.quantity = quantity;
        this.total = book.price * quantity;
    }
}

// Вывод счёта — отдельная задача
class InvoicePrinter {
    private Invoice invoice;

    public InvoicePrinter(Invoice invoice) {
        this.invoice = invoice;
    }

    public void print() {
        System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + "$");
        System.out.println("Total: " + invoice.total + "$");
    }
}

// Сохранение счёта — отдельная задача
class InvoicePersistence {
    private Invoice invoice;

    public InvoicePersistence(Invoice invoice) {
        this.invoice = invoice;
    }

    public void saveToFile(String filename) {
        System.out.println("Сохраняем в файл: " + filename);
        System.out.println("Содержимое:");
        System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + "$");
        System.out.println("Total: " + invoice.total + "$");
    }
}

Результат вывода:

3x Clean Code 40$
Total: 120.0$
Сохраняем в файл: invoice.txt
Содержимое:
3x Clean Code 40$
Total: 120.0$

После такого разделения каждый компонент отвечает только за свою задачу. Теперь можно легко менять формат вывода в InvoicePrinter или способ хранения в InvoicePersistence, не затрагивая бизнес-логику в классе Invoice. Это делает код более гибким и простым в поддержке.

Каждый класс отвечает за своё: Invoice — за данные и расчёт, InvoicePrinter — за вывод, InvoicePersistence — за сохранение
Изображение: Mermaid Chart / Skillbox Media

Принцип открытости / закрытости: OCP — Open Closed Principle

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

Допустим, у нас уже есть приложение для выставления счетов, и начальник просит добавить сохранение счетов в базу данных. Что приходит в голову в первую очередь? Просто дописать метод saveToDatabase() в уже существующий класс InvoicePersistence:

public class MainOCPViolation {
    public static void main(String[] args) {
        // Каждый раз при добавлении нового способа сохранения
        // приходится менять класс InvoiceSaver, — это нарушение OCP
        Book book = new Book("Clean Code", "Robert C. Martin", 40);
        Invoice invoice = new Invoice(book, 3);

        InvoiceSaver saver = new InvoiceSaver(invoice);

        // Сохраняем счёт в файл
        saver.saveToFile("invoice.txt");

        // Сохраняем счёт в базу данных
        saver.saveToDatabase();
    }
}

// Книга — просто данные
class Book {
    String name;
    String authorName;
    int price;

    public Book(String name, String authorName, int price) {
        this.name = name;
        this.authorName = authorName;
        this.price = price;
    }
}

// Счёт — хранит данные и рассчитывает сумму
class Invoice {
    Book book;
    int quantity;
    double total;

    public Invoice(Book book, int quantity) {
        this.book = book;
        this.quantity = quantity;
        this.total = book.price * quantity;
    }
}

//  Saver нарушает OCP — он жёстко привязан к способам сохранения
class InvoiceSaver {
    Invoice invoice;

    public InvoiceSaver(Invoice invoice) {
        this.invoice = invoice;
    }

    // Сохранение в файл
    public void saveToFile(String filename) {
        System.out.println("Сохраняем счёт в файл: " + filename);
    }

    // Сохранение в базу данных
    public void saveToDatabase() {
        System.out.println("Сохраняем счёт в базу данных...");
    }

    // Если разработчику нужно будет добавить в проект MongoDB, API или облачную базу данных, придётся снова менять этот класс
}

Вывод в консоль при запуске кода:

Сохраняем счёт в файл: invoice.txt  
Сохраняем счёт в базу данных...

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

Нарушение OCP: при добавлении новых способов сохранения нужно менять InvoicePersistence
Изображение: Mermaid Chart / Skillbox Media

Для соблюдения принципа открытости / закрытости создадим интерфейс InvoicePersistence, а затем реализуем отдельный класс для каждого способа хранения: FilePersistence для файлов и DatabasePersistence для базы данных. Благодаря такому подходу мы сможем при необходимости добавлять новые типы хранилищ, не меняя существующий код:

package refactored.ocp;

public class MainOCPRefactored {
    public static void main(String[] args) {
        // Создаём книгу
        Book book = new Book("Clean Code", "Robert C. Martin", 40);

        // Создаём счёт на 3 книги
        Invoice invoice = new Invoice(book, 3);

        // Выбираем способ сохранения — в данном случае в файл
        // Используем интерфейс, не трогая код Invoice или Main
        InvoicePersistence persistence = new FilePersistence();
        persistence.save(invoice);

        // Хотим сохранить в базу? Просто создаём другую реализацию:
        // InvoicePersistence persistence = new DatabasePersistence();
        // persistence.save(invoice);
    }
}

// Книга — просто набор данных
class Book {
    public String name;
    public String authorName;
    public int price;

    public Book(String name, String authorName, int price) {
        this.name = name;
        this.authorName = authorName;
        this.price = price;
    }
}

// Счёт — хранит данные и считает итоговую сумму
class Invoice {
    public Book book;
    public int quantity;
    public double total;

    public Invoice(Book book, int quantity) {
        this.book = book;
        this.quantity = quantity;
        this.total = book.price * quantity;
    }
}

// Интерфейс для всех способов сохранения
interface InvoicePersistence {
    void save(Invoice invoice);
}

// Сохраняем счёт в файл
class FilePersistence implements InvoicePersistence {
    @Override
    public void save(Invoice invoice) {
        System.out.println("Сохраняем счёт в файл: invoice.txt");
    }
}

// Сохраняем счёт в базу данных
class DatabasePersistence implements InvoicePersistence {
    @Override
    public void save(Invoice invoice) {
        System.out.println("Сохраняем счёт в базу данных...");
    }
}

Если запустить код как есть, в консоли появится сообщение:

Сохраняем счёт в файл: invoice.txt

Однако, если вы раскомментируете строку с DatabasePersistence, а FilePersistence закомментируете, результат будет другим:

Сохраняем счёт в базу данных...

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

Принцип OCP: новые классы (FilePersistence, DatabasePersistence) добавляются без изменения существующего кода
Изображение: Mermaid Chart / Skillbox Media

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

Принцип подстановки Лисков (LSP) устанавливает важное правило для наследования: если в программе используется базовый класс, то любой его подкласс должен работать так же корректно, как и родительский класс. Подкласс не должен нарушать ожидаемое поведение программы.

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

public class MainLSPViolation {
    public static void main(String[] args) {
        // Прямоугольник — всё работает как ожидалось
        Rectangle rc = new Rectangle(2, 3);
        AreaFixedHeight.getArea(rc); // Ожидаемая площадь: 20

        // Квадрат — это наследник прямоугольника, но он меняет поведение setWidth и setHeight
        Rectangle sq = new Square();
        sq.setWidth(5); // Ожидаем, что изменится только ширина

        // Но у квадрата меняются сразу обе стороны — ширина и высота
        AreaFixedHeight.getArea(sq); // Ожидаемая площадь: 50, но получим другую
    }
}

// Прямоугольник — базовый класс с шириной и высотой
class Rectangle {
    protected int width, height;

    public Rectangle() {}

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getArea() {
        return width * height;
    }
}

// Квадрат меняет поведение родителя и ломает логику
class Square extends Rectangle {
    public Square() {}

    public Square(int size) {
        width = height = size;
    }

    @Override
    public void setWidth(int width) {
        // Меняем ширину и высоту одновременно
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        // Меняем высоту и ширину — снова ломаем контракт
        super.setHeight(height);
        super.setWidth(height);
    }
}

// Метод расчёта площади прямоугольника с фиксированной высотой
class AreaFixedHeight {
    static void getArea(Rectangle r) {
        int width = r.getWidth();    // Сохраняем начальную ширину
        r.setHeight(10);             // Меняем только высоту
        System.out.println("Ожидаемая площадь: " + (width * 10) + ", полученная: " + r.getArea());
    }
}

Казалось бы: если мы меняем ширину квадрата, автоматически меняется и высота, и наоборот. Но что может пойти не так? Например, код, рассчитанный на работу с прямоугольником с фиксированной высотой, может выдать неожиданный результат, если передать ему объект Square:

Ожидаемая площадь: 20, полученная: 20
Ожидаемая площадь: 50, полученная: 100

При вызове AreaFixedHeight.getArea(sq) мы наблюдаем неожиданное поведение: метод рассчитан на работу с объектами Rectangle и предполагает, что изменение высоты никак не влияет на ширину. Однако в Square метод setHeight() переопределён так, что меняет оба параметра одновременно. Это нарушает третий принцип SOLID: поведение подкласса отличается от поведения базового класса, и такая подстановка приводит к ошибкам.

Нарушение LSP: класс Square наследуется от Rectangle, но переопределяет методы так, что ломает ожидаемое поведение
Изображение: Mermaid Chart / Skillbox Media

В нашем случае Square не должен наследоваться от Rectangle, потому что их поведение различается. Вместо этого лучше создать общий интерфейс Shape и реализовать его отдельно в обоих классах, — так мы избегаем проблем с подстановкой и соблюдаем принцип LSP:

package refactored.lsp;

public class MainLSPRefactored {
    public static void main(String[] args) {
        // Прямоугольник и квадрат реализуют один интерфейс
        Shape rectangle = new Rectangle(2, 10); // прямоугольник 2 × 10
        Shape square = new Square(5);           // квадрат 5 × 5

        // Метод printArea() работает с любой фигурой
        printArea(rectangle); // Площадь: 20
        printArea(square);    // Площадь: 25
    }

    // Универсальный метод для обработки любой фигуры
    static void printArea(Shape shape) {
        System.out.println("Площадь: " + shape.getArea());
    }
}

// Интерфейс с методом для вычисления площади
interface Shape {
    int getArea();
}

// Прямоугольник: ширина × высота
class Rectangle implements Shape {
    private int width, height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

// Квадрат: стороны равны
class Square implements Shape {
    private int size;

    public Square(int size) {
        this.size = size;
    }

    @Override
    public int getArea() {
        return size * size;
    }
}

Лог в консоли:

Площадь: 20
Площадь: 25

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

Соблюдение LSP: Rectangle и Square реализуют общий интерфейс Shape — подстановка работает корректно, без нарушения поведения
Изображение: Mermaid Chart / Skillbox Media

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

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

Представим, что у нас есть интерфейс Employee, в котором собраны три обязанности сотрудника: работать, есть и отдыхать. Давайте напишем программу:

public class MainISPViolation {
    public static void main(String[] args) {
        // Два сотрудника с разным поведением
        Employee dev = new Developer();
        Employee manager = new Manager();

        // Все сотрудники вызывают одни и те же методы
        dev.work();
        dev.eat();
        dev.relax();

        manager.work();
        manager.eat();
        manager.relax();
    }
}

// Интерфейс объединяет все обязанности — без разделения по ролям
interface Employee {
    void work();
    void eat();
    void relax();
}

// Разработчику подходят все методы 
class Developer implements Employee {
    public void work() {
        System.out.println("Разработчик пишет код...");
    }

    public void eat() {
        System.out.println("Разработчик обедает...");
    }

    public void relax() {
        System.out.println("Разработчик отдыхает...");
    }
}

// Менеджер вынужден реализовывать лишние методы 
class Manager implements Employee {
    public void work() {
        System.out.println("Менеджер проводит встречи...");
    }

    public void eat() {
        System.out.println("Менеджеры не обедают...");
    }

    public void relax() {
        System.out.println("Менеджеры не отдыхают...");
    }
}

Результат выполнения программы:

Разработчик пишет код...
Разработчик обедает...
Разработчик отдыхает...
Менеджер проводит встречи...
Менеджеры не обедают...
Менеджеры не отдыхают...

Каждый класс, который использует интерфейс Employee, должен описывать все три метода. Представим двух сотрудников:

  • Разработчик (Developer) — работает, обедает и отдыхает.
  • Менеджер (Manager) — только работает, без обедов и перерывов.

В этом случае Manager всё равно вынужден добавлять лишние методы, которые не используются. Это нарушает принцип разделения интерфейсов.

Нарушение ISP: интерфейс Employee включает всё сразу, и приходится реализовывать даже ненужные методы
Изображение: Mermaid Chart / Skillbox Media

Давайте разделим интерфейс Employee на несколько узких интерфейсов — так каждый класс сможет реализовать только нужные ему методы:

package refactored.isp;

public class MainISPRefactored {
    public static void main(String[] args) {
        // Разработчик реализует все необходимые интерфейсы
        Workable dev = new Developer();
        dev.work();
        // Дополнительно можно вызвать обед и перерыв:
        // ((Lunchable) dev).eatLunch();
        // ((Breakable) dev).takeBreak();

        // Менеджер реализует только нужный интерфейс  
        Workable manager = new Manager();
        manager.work();
    }
}

// Интерфейс для работы
interface Workable {
    void work();
}

// Интерфейс для обеда
interface Lunchable {
    void eatLunch();
}

// Интерфейс для перерыва
interface Breakable {
    void takeBreak();
}

// Разработчик работает, ест и отдыхает
class Developer implements Workable, Lunchable, Breakable {
    @Override
    public void work() {
        System.out.println("Разработчик пишет код...");
    }

    @Override
    public void eatLunch() {
        System.out.println("Разработчик обедает...");
    }

    @Override
    public void takeBreak() {
        System.out.println("Разработчик отдыхает...");
    }
}

// Менеджер только работает
class Manager implements Workable {
    @Override
    public void work() {
        System.out.println("Менеджер проводит встречи...");
    }
}

Информация в консоли:

Разработчик пишет код...
Менеджер проводит встречи...

Теперь интерфейс Employee разделён на три отдельных интерфейса: Workable, Lunchable и Breakable. Получается следующее:

  • Developer реализует все три — он работает, обедает и отдыхает.
  • Manager реализует только Workable — ничего лишнего.

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

Соблюдение ISP: интерфейсы разделены по задачам — каждый класс реализует только нужные методы
Изображение: Mermaid Chart / Skillbox Media

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

Принцип инверсии зависимостей (DIP) означает, что модули высокого уровня должны зависеть от абстракций, а не от модулей низкого уровня. Под модулями высокого уровня обычно понимают бизнес-логику приложения — например, управление пользователями или обработку заказов. Модули низкого уровня — это конкретные технические реализации: работа с базой данных, API или файловой системой.

Если класс напрямую зависит от другой конкретной реализации, его сложно тестировать и модифицировать. Роберт Мартин пишет:

«Если OCP описывает цель объектно-ориентированной архитектуры, то DIP — это основной механизм её достижения».

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

Пусть у нас есть класс OrderService, который отвечает за обработку заказов и напрямую зависит от конкретной реализации — класса MySQLDatabase:

public class MainDIPViolation {
    public static void main(String[] args) {
        // OrderService напрямую зависит от конкретной базы данных (MySQL)
        OrderService orderService = new OrderService();

        // Сохраняем заказ
        orderService.saveOrder(new Order("Заказ №1"));
    }
}

// OrderService зависит от реализации MySQLDatabase
class OrderService {
    private MySQLDatabase database;

    public OrderService() {
        // Создание конкретной реализации внутри класса
        this.database = new MySQLDatabase();
    }

    public void saveOrder(Order order) {
        database.save(order);
    }
}

// Хранилище на базе MySQL — модуль низкого уровня
class MySQLDatabase {
    public void save(Order order) {
        System.out.println("Сохраняем заказ в MySQL: " + order.description);
    }
}

// Данные о заказе
class Order {
    public String description;

    public Order(String description) {
        this.description = description;
    }
}

Результат выполнения кода:

Сохраняем заказ в MySQL: Заказ №1

В примере выше OrderService привязан к MySQLDatabase: объект создаётся внутри класса и не может быть подменён. Поэтому, чтобы заменить базу данных или протестировать сервис без реального подключения, придётся менять сам OrderService. Это нарушает принцип DIP, поскольку бизнес-логика зависит от конкретной реализации, а не от абстракции.

Нарушение DIP: OrderService зависит от MySQLDatabase, что затрудняет тестирование и переиспользование кода
Изображение: Mermaid Chart / Skillbox Media

Создадим интерфейс, который будет абстракцией для хранения данных.

package refactored.dip;

public class MainDIPRefactored {
    public static void main(String[] args) {
        // Две разные реализации репозитория
        OrderRepository mysqlRepo = new MySQLDatabase();
        OrderRepository mongoRepo = new MongoDBDatabase();

        // OrderService работает через интерфейс — не зависит от конкретной базы 
        OrderService orderService1 = new OrderService(mysqlRepo);
        OrderService orderService2 = new OrderService(mongoRepo);

        // Сохраняем заказы с разными источниками данных
        orderService1.saveOrder(new Order("Заказ №1"));
        orderService2.saveOrder(new Order("Заказ №2"));
    }
}

// Абстракция для хранилища заказов
interface OrderRepository {
    void save(Order order);
}

// Реализация для MySQL
class MySQLDatabase implements OrderRepository {
    @Override
    public void save(Order order) {
        System.out.println("Сохраняем заказ в MySQL: " + order.description);
    }
}

// Реализация для MongoDB
class MongoDBDatabase implements OrderRepository {
    @Override
    public void save(Order order) {
        System.out.println("Сохраняем заказ в MongoDB: " + order.description);
    }
}

// OrderService — бизнес-логика, зависит только от интерфейса
class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void saveOrder(Order order) {
        repository.save(order);
    }
}

// Класс с данными о заказе
class Order {
    public String description;

    public Order(String description) {
        this.description = description;
    }
}

Сообщение в консоли:

Сохраняем заказ в MySQL: Заказ №1  
Сохраняем заказ в MongoDB: Заказ №2

Теперь OrderService зависит не от конкретной реализации репозитория, а от абстракции — интерфейса OrderRepository. Такой подход позволяет подключать разные реализации хранилища данных (например, MySQL, MongoDB и другие) без изменения кода самого сервиса. В этом и заключается принцип инверсии зависимостей: модули высокого уровня должны зависеть от абстракций, а не от конкретных реализаций.

Соблюдение DIP: OrderService зависит от интерфейса OrderRepository, а не от конкретной реализации базы данных
Изображение: Mermaid Chart / Skillbox Media

Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!



Изучайте IT на практике — бесплатно

Курсы за 2990 0 р.

Я не знаю, с чего начать
Научитесь: Профессия Java-разработчик Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована