VK Cloud

Принципы SOLID: что это такое и как применять в реальном коде — примеры на Python

11 октября 2026 г.
268267_007n7ek.jpg
Евгений Левашов
Автор статьи
_blog_head_2.png

Сервисный класс редко вырастает до шестисот строк за один раз. Сначала

UserService
работает с базой, затем в нём появляется отправка писем, позже — сборка PDF-отчётов. Постепенно эти задачи переплетаются: правка шаблона письма роняет тесты репозитория, а создать экземпляр класса в юнит-тесте без живой БД и SMTP-сервера уже не получается.

Принципы SOLID помогают разобраться, как разделить обязанности такого класса и выстроить зависимости между компонентами. Их пять: Single Responsibility Principle (SRP, принцип единственной ответственности), Open/Closed Principle (OCP, принцип открытости-закрытости), Liskov Substitution Principle (LSP, принцип подстановки Лисков), Interface Segregation Principle (ISP, принцип разделения интерфейсов) и Dependency Inversion Principle (DIP, принцип инверсии зависимостей). Роберт Мартин собрал их в одну систему в работе «Design Principles and Design Patterns» (2000), а мнемонический акроним SOLID позже предложил Майкл Фэзерс.

Разберём эти принципы на коде из сервисного слоя типового Python-бэкенда: репозиториях, уведомлениях, хранилищах и отчётах. Без теории ради теории и учебных иерархий фигур и животных — с примерами на Python 3.10+ и аннотациями типов.

В Python ошибки в контрактах между классами могут обнаружиться только во время выполнения: язык динамически типизирован, и компилятор не проверяет согласованность этих контрактов. В Java такая ошибка всплывёт при сборке, в Python — у клиента в три часа ночи. Аннотации типов и mypy помогают поймать часть проблем, но проверяют сигнатуры, а не распределение ответственности между классами.

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

Принципы SOLID стоит держать в голове прежде всего ради двух практических задач:

  • Тестируемость. SRP и DIP помогают создавать классы, зависимости которых легко подменить в тесте: вместо почтового клиента использовать заглушку, вместо репозитория — словарь в памяти. Без такого разделения юнит-тест превращается в интеграционный и требует запуска БД и почтового сервера.
  • Рефакторинг. Небольшой класс с одной причиной для изменения можно править изолированно. В классе на несколько сотен строк с четырьмя зонами ответственности изменения затрагивают весь класс, а ошибка в одной части может нарушить работу остальных.

Каждый принцип разберём по одной схеме: проблемный код, разбор ошибок, исправленный вариант и вывод.

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

Плохой код:

class UserService: def __init__(self, db_url: str): self.db_url = db_url def get_user(self, user_id: int) -> dict: # логика работы с БД ... def send_welcome_email(self, user: dict) -> None: # SMTP-логика прямо здесь ... def generate_pdf_report(self, user: dict) -> bytes: # генерация PDF прямо здесь ...

Что не так:

  • Класс меняется по трём независимым причинам: изменилась схема хранения, сменился SMTP-провайдер или маркетинг попросил другой формат отчёта.
  • Протестировать
    get_user
    без запуска БД и почтового сервера невозможно: импорт модуля тянет за собой все зависимости.
  • Любая правка требует ревью трёх человек, каждый из которых отвечает за свою подсистему.
  • Три команды редактируют один файл, поэтому конфликты в мердж-реквестах неизбежны.

Хороший код:

class UserRepository: def __init__(self, db_url: str): self.db_url = db_url def get_user(self, user_id: int) -> dict: ... class EmailService: def send_welcome_email(self, user: dict) -> None: ... class ReportService: def generate_pdf_report(self, user: dict) -> bytes: ...

Теперь каждый класс можно тестировать изолированно:

UserRepository
— на тестовой БД или фикстуре,
EmailService
— с фейковым транспортом,
ReportService
— проверяя байты на выходе. Смена SMTP-провайдера не затрагивает работу с пользователями, а переход на другую СУБД — формирование отчётов.

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

Классы должны быть открыты для расширения, но закрыты для изменения: новую функциональность следует добавлять без правок существующего кода. Бертран Мейер сформулировал этот принцип в книге «Object-Oriented Software Construction» в 1988 году, задолго до появления акронима SOLID.

Плохой код:

class NotificationService: def send(self, user: dict, channel: str, message: str) -> None: if channel == "email": # отправка email ... elif channel == "telegram": # отправка telegram ... elif channel == "sms": # отправка sms ...

Что не так:

  • Для каждого нового канала приходится менять класс, который уже работает в продакшене.
  • Правки могут сломать существующие каналы: опечатка в ветке
    sms
    способна нарушить работу всего модуля, включая email.
  • У каналов нет единого контракта: каждая ветка использует свой набор параметров и по-своему обрабатывает ошибки.
  • Тесты класса разрастаются, а каждый прогон требует зависимостей всех каналов сразу.

Хороший код:

from abc import ABC, abstractmethod class NotificationChannel(ABC): @abstractmethod def send(self, user: dict, message: str) -> None: ... class EmailChannel(NotificationChannel): def send(self, user: dict, message: str) -> None: ... class TelegramChannel(NotificationChannel): def send(self, user: dict, message: str) -> None: ... class NotificationService: def __init__(self, channel: NotificationChannel): self.channel = channel def notify(self, user: dict, message: str) -> None: self.channel.send(user, message)

Чтобы добавить

PushChannel
, достаточно написать новый класс и зарегистрировать его при сборке приложения. Код
NotificationService
менять не нужно, его тесты не приходится перезапускать из-за правок в самом сервисе, а ревью новой интеграции ограничивается одним файлом. В Python контракт можно задать и без абстрактного базового класса:
typing.Protocol
даёт тот же эффект за счёт структурной типизации, когда наследование от общей базы неудобно.

Если

S
— подтип
T
, объекты типа
T
можно заменить объектами типа
S
без потери корректности программы. Барбара Лисков сформулировала идею подстановки типов в программном докладе «Data Abstraction and Hierarchy» на конференции OOPSLA в 1987 году. На практике подкласс должен вести себя так, как ожидает код, рассчитанный на родительский класс.

Плохой код:

class Storage: def read(self, key: str) -> bytes: ... def write(self, key: str, data: bytes) -> None: ... class ReadOnlyStorage(Storage): def write(self, key: str, data: bytes) -> None: raise NotImplementedError("Запись запрещена")

Что не так:

  • Код, который принимает
    Storage
    , рассчитывает на возможность записи: она входит в контракт родительского класса.
  • ReadOnlyStorage
    нарушает этот контракт, но ошибка обнаруживается только во время выполнения.
  • Вызывающему коду приходится проверять тип через
    isinstance
    или перехватывать
    NotImplementedError
    . В обоих случаях он вынужден учитывать особенности конкретной реализации, что нарушает полиморфизм.
  • Статический анализатор не обнаружит проблему: сигнатуры совпадают, различается только поведение.

Хороший код:

from abc import ABC, abstractmethod class ReadableStorage(ABC): @abstractmethod def read(self, key: str) -> bytes: ... class WritableStorage(ReadableStorage): @abstractmethod def write(self, key: str, data: bytes) -> None: ... class S3Storage(WritableStorage): def read(self, key: str) -> bytes: ... def write(self, key: str, data: bytes) -> None: ... class ArchiveStorage(ReadableStorage): def read(self, key: str) -> bytes: ... # write не объявлен, контракт не нарушен

Теперь иерархия отражает возможности хранилищ:

ArchiveStorage
обещает только чтение и не нарушает контракт. Функция с аннотацией
def restore(storage: ReadableStorage)
работает с обоими классами, а попытка записать данные в архив обнаруживается при проверке типов, до запуска в продакшене. Тот же принцип действует для ограничений: подкласс не должен сужать набор допустимых входных значений или расширять набор исключений, с которыми сталкивается вызывающий код.

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

Плохой код:

from abc import ABC, abstractmethod class DataProcessor(ABC): @abstractmethod def read(self, source: str) -> list: ... @abstractmethod def write(self, destination: str, data: list) -> None: ... @abstractmethod def validate(self, data: list) -> bool: ... @abstractmethod def compress(self, data: list) -> bytes: ...

Что не так:

  • Классу, которому нужна только валидация, приходится реализовывать
    read
    ,
    write
    и
    compress
    . Обычно ненужные методы заменяют заглушками, которые выбрасывают исключения, и тем самым нарушают LSP.
  • Добавление метода в интерфейс ломает все реализации, в том числе те, которым этот метод не нужен.
  • Тестовые подмены разрастаются: чтобы подменить один метод, фейк должен реализовать все четыре.

Хороший код:

class Readable(ABC): @abstractmethod def read(self, source: str) -> list: ... class Writable(ABC): @abstractmethod def write(self, destination: str, data: list) -> None: ... class Validatable(ABC): @abstractmethod def validate(self, data: list) -> bool: ... class CSVProcessor(Readable, Writable, Validatable): # реализует только то, что нужно ... class StreamReader(Readable): # только чтение ...

StreamReader
не обязан реализовывать ненужные методы, а сигнатура
def load(src: Readable)
сразу показывает, что функции достаточно чтения. В Python узкие интерфейсы легко объединить через множественное наследование. С
typing.Protocol
такое объединение можно сделать неявным: реализациям не потребуется наследоваться от интерфейсов.

Модули верхнего уровня не должны зависеть от модулей нижнего уровня: оба должны зависеть от абстракций. Абстракции задают контракт, которому следуют конкретные реализации, и не зависят от их деталей. Роберт Мартин описал этот принцип в статье для C++ Report в 1996 году.

Плохой код:

import smtplib class ReportService: def __init__(self): self.mailer = smtplib.SMTP("smtp.example.com", 587) # жёсткая зависимость def send_report(self, report: bytes, recipient: str) -> None: # отправка через SMTP ...

Что не так:

  • ReportService
    привязан к
    smtplib
    — конкретной реализации почтового транспорта.
  • Чтобы заменить SMTP другим почтовым сервисом или тестовой заглушкой, приходится менять класс.
  • Конструктор открывает сетевое соединение. Для создания объекта в тесте нужен почтовый сервер или подмена через
    unittest.mock.patch
    . Такая подмена хрупка и ломается при переносе импорта.
  • Адрес и порт зашиты в код, поэтому для разных окружений приходится поддерживать разные ветки вместо конфигурации.

Хороший код:

from abc import ABC, abstractmethod class Mailer(ABC): @abstractmethod def send(self, recipient: str, subject: str, body: bytes) -> None: ... class SMTPMailer(Mailer): def send(self, recipient: str, subject: str, body: bytes) -> None: # SMTP-реализация ... class ApiMailer(Mailer): def send(self, recipient: str, subject: str, body: bytes) -> None: # реализация поверх HTTP API почтового провайдера ... class ReportService: def __init__(self, mailer: Mailer): # зависимость через абстракцию self.mailer = mailer def send_report(self, report: bytes, recipient: str) -> None: self.mailer.send(recipient, "Отчёт", report) # В тестах: class FakeMailer(Mailer): def __init__(self): self.sent: list = [] def send(self, recipient: str, subject: str, body: bytes) -> None: self.sent.append({"recipient": recipient, "subject": subject}) # Использование: service = ReportService(mailer=SMTPMailer()) # В тесте: fake = FakeMailer() service = ReportService(mailer=fake)

ReportService
не знает, как отправляется письмо: он отвечает за отчёт и передаёт его объекту с контрактом
Mailer
. Для смены провайдера достаточно заменить одну строку при инициализации приложения. В тесте факт отправки проверяется по списку
sent
, без подмены импортов и сетевых соединений.

Так выглядит внедрение зависимостей (dependency injection) через конструктор: класс получает готовую зависимость вместо того, чтобы создавать её внутри. В крупных проектах сборку объектов по той же схеме удобно поручить DI-контейнеру. Контейнер лишь упрощает сборку; сам принцип определяет направление зависимостей.


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

При рефакторинге легаси удобно двигаться в таком порядке:

  1. Сначала SRP. Поочерёдно выделите из разросшегося сервиса репозиторий, отправку почты и формирование отчётов. Пока не вводите абстракции, просто разнесите обязанности по классам.
  2. Потом DIP. Замените создание зависимостей внутри классов передачей извне. На этом шаге появляются первые изолированные юнит-тесты, которые делают дальнейший рефакторинг безопаснее.
  3. Потом OCP. Там, где растёт цепочка
    if/elif
    по типам, введите общий контракт и отдельные реализации.

ISP дополняет этот подход: контракты, выделенные под конкретного клиента, получаются узкими. В

NotificationService
из раздела про OCP сочетаются сразу три принципа. DIP задаёт зависимость от абстракции канала, которую сервис получает через конструктор. ISP ограничивает интерфейс
NotificationChannel
одним методом
send
, поэтому реализация push-канала не обязана поддерживать лишние операции. OCP позволяет добавить новый канал без правок сервиса.

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

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

Скрипты и одноразовые утилиты

Файл на сотню строк, который раз в квартал разбирает выгрузку, не станет лучше от трёх абстрактных базовых классов.

Малые проекты в несколько сотен строк

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

MVP и прототипы

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

Data Science и ноутбуки

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

Важно знать

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

ПринципВопрос для проверкиСигнал нарушения
SRPСколько причин изменить этот класс?Больше одной
OCPНужно ли менять класс, чтобы добавить новое?Да
LSPПодкласс ведёт себя неожиданно для пользователей родителя?Да
ISPКласс реализует методы, которые не использует?Да
DIPКласс создаёт зависимости внутри себя через *import* и прямой вызов конструктора?Да

Частые вопросы о принципах SOLID

SOLID — это обязательно ООП?

Принципы SOLID изначально сформулировали для объектно-ориентированного проектирования, но идеи SRP и DIP применимы и к функциональному коду. Если функция отвечает за одну задачу и получает зависимости через явные параметры, её легко тестировать, подменяя транспорт или хранилище.

Чем SOLID отличается от паттернов GoF?

SOLID задаёт принципы проектирования классов и модулей. Паттерны GoF (Factory, Strategy, Observer) предлагают конкретные решения типовых задач и описывают, как устроены классы и связи между ними. С помощью паттернов можно применять принципы на практике: Strategy реализует OCP, а Factory помогает соблюдать DIP, вынося создание объектов из кода, который их использует.

Как проверить, нарушает ли мой код SOLID?

Есть три практических признака нарушения принципов SOLID. Если класс трудно протестировать без запуска инфраструктуры, нарушен DIP. Если для добавления новой функциональности приходится менять работающий класс, нарушен OCP. Если метод принимает булев флаг, который переключает его поведение, в нём совмещены две ответственности — нарушены SRP и ISP.

Dependency Injection — это то же самое, что DIP?

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

Стоит ли применять SOLID в Django и FastAPI?

Да, но способ применения зависит от фреймворка. В Django выделение сервисного слоя из представлений помогает соблюдать SRP, а абстрактный репозиторий поверх ORM-моделей — DIP. В FastAPI

Depends()
служит встроенным механизмом внедрения зависимостей. В документации FastAPI описано, как переопределять зависимости в тестах через
dependency_overrides
: это позволяет подменять реализации без патчей импортов.

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

При работе с легаси начните с разделения ответственности по SRP, затем примените DIP, чтобы изолировать зависимости и написать тесты. Абстракции для OCP вводите, когда появляется вторая реализация. В скрипте на вечер, прототипе или ноутбуке затраты на такую структуру могут не окупиться: для этих задач достаточно более простого кода.

Предсказуемость работы сервиса зависит и от инфраструктуры. Виртуальные машины Cloud Servers в VK Cloud работают в геораспределённых зонах доступности с SLA (соглашением об уровне обслуживания) 99,95% и финансовыми гарантиями. Управлять ими можно через Terraform-провайдер, CLI и API, в том числе загружать собственные образы и менять тип диска без простоя.

Оставьте заявку, чтобы получить консультацию

Наши специалисты свяжутся с вами в ближайшее время и ответят на все вопросы.

            Узнавайте о выходе новых статей в блоге первыми!

            Будем держать в курсе новостей и облачных трендов

            section-subscribe_2x.png
              section-subscribe_2x.png
              Теги: SOLID, Python, архитектура кода, ООП, VK Cloud Servers
              Ссылка скопирована
              Поделиться

              Почитать по теме

              _blog_head_16.png
              28 сентября

              Агентный ИИ в облаке: от function calling до MCP и GPU-инференса

              _blog_head_17.png
              10 сентября

              Open source против закрытого ПО: что на самом деле разделяет две модели разработки

              _blog_head_4.png
              8 сентября

              Что такое ЦОД: устройство, резервирование и метрика PUE

              40+ готовых сервисов