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

Сервисный класс редко вырастает до шестисот строк за один раз. Сначала
UserServiceПринципы 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: ...
Теперь каждый класс можно тестировать изолированно:
UserRepositoryEmailServiceReportServiceИмена классов помогают понять устройство сервисного слоя. Новому разработчику не нужно читать сотни строк, чтобы найти логику рассылок.
Классы должны быть открыты для расширения, но закрыты для изменения: новую функциональность следует добавлять без правок существующего кода. Бертран Мейер сформулировал этот принцип в книге «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 ...
Что не так:
- Для каждого нового канала приходится менять класс, который уже работает в продакшене.
- Правки могут сломать существующие каналы: опечатка в ветке способна нарушить работу всего модуля, включая email.
sms - У каналов нет единого контракта: каждая ветка использует свой набор параметров и по-своему обрабатывает ошибки.
- Тесты класса разрастаются, а каждый прогон требует зависимостей всех каналов сразу.
Хороший код:
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)
Чтобы добавить
PushChannelNotificationServicetyping.ProtocolЕсли
STTSПлохой код:
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 не объявлен, контракт не нарушен
Теперь иерархия отражает возможности хранилищ:
ArchiveStoragedef 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. Обычно ненужные методы заменяют заглушками, которые выбрасывают исключения, и тем самым нарушают LSP.compress - Добавление метода в интерфейс ломает все реализации, в том числе те, которым этот метод не нужен.
- Тестовые подмены разрастаются: чтобы подменить один метод, фейк должен реализовать все четыре.
Хороший код:
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): # только чтение ...
StreamReaderdef load(src: Readable)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)
ReportServiceMailersentТак выглядит внедрение зависимостей (dependency injection) через конструктор: класс получает готовую зависимость вместо того, чтобы создавать её внутри. В крупных проектах сборку объектов по той же схеме удобно поручить DI-контейнеру. Контейнер лишь упрощает сборку; сам принцип определяет направление зависимостей.
Принципы SOLID дополняют друг друга. SRP помогает разделить большой класс на компоненты с одной причиной для изменения. DIP выстраивает зависимости между ними через абстракции, позволяя писать тесты с фейковыми репозиторием, почтовым клиентом и хранилищем. С тестами проще применять OCP: добавлять функциональность в новых классах и проверять, не нарушили ли изменения существующее поведение.
При рефакторинге легаси удобно двигаться в таком порядке:
- Сначала SRP. Поочерёдно выделите из разросшегося сервиса репозиторий, отправку почты и формирование отчётов. Пока не вводите абстракции, просто разнесите обязанности по классам.
- Потом DIP. Замените создание зависимостей внутри классов передачей извне. На этом шаге появляются первые изолированные юнит-тесты, которые делают дальнейший рефакторинг безопаснее.
- Потом OCP. Там, где растёт цепочка по типам, введите общий контракт и отдельные реализации.
if/elif
ISP дополняет этот подход: контракты, выделенные под конкретного клиента, получаются узкими. В
NotificationServiceNotificationChannelsendКаждый из этих принципов решает свою часть задачи. Если передавать зависимость извне, но сохранить большой интерфейс, реализациям всё равно придётся поддерживать ненужные методы. Если сузить контракт, но оставить создание конкретного транспорта внутри сервиса, жёсткая привязка сохранится. Нарушения тоже связаны: класс с тремя обязанностями зависит сразу от трёх инфраструктур, что затрудняет соблюдение DIP и независимую подмену зависимостей.
Применять SOLID стоит там, где код уже трудно менять или тестировать. Если ради простой задачи приходится создавать несколько интерфейсов и классов, нужно оценить, какую проблему они решают и оправдывают ли дополнительную работу. Ниже приведены ориентиры, которые помогают принять это решение. Они основаны на практике, но не задают точных порогов: числа нужно соотносить с устройством вашего проекта.
Скрипты и одноразовые утилиты
Файл на сотню строк, который раз в квартал разбирает выгрузку, не станет лучше от трёх абстрактных базовых классов.
Малые проекты в несколько сотен строк
Накладные расходы на интерфейсы и сборку зависимостей превысят выигрыш в поддержке.
MVP и прототипы
Здесь нужна скорость проверки гипотезы. Расширяемость кода, который с высокой вероятностью выбросят через месяц, стоит дешевле времени разработчика.
Data Science и ноутбуки
Последовательный процедурный код, где каждая ячейка — шаг обработки, обычно читается легче, чем иерархия классов. Абстракции имеет смысл вводить при переносе эксперимента в сервис.
| Принцип | Вопрос для проверки | Сигнал нарушения |
| 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()dependency_overridesПять принципов SOLID помогают оценить, насколько сложно будет внести следующее изменение в код. Их стоит применять там, где они упрощают разработку и сопровождение.
При работе с легаси начните с разделения ответственности по SRP, затем примените DIP, чтобы изолировать зависимости и написать тесты. Абстракции для OCP вводите, когда появляется вторая реализация. В скрипте на вечер, прототипе или ноутбуке затраты на такую структуру могут не окупиться: для этих задач достаточно более простого кода.
Предсказуемость работы сервиса зависит и от инфраструктуры. Виртуальные машины Cloud Servers в VK Cloud работают в геораспределённых зонах доступности с SLA (соглашением об уровне обслуживания) 99,95% и финансовыми гарантиями. Управлять ими можно через Terraform-провайдер, CLI и API, в том числе загружать собственные образы и менять тип диска без простоя.
Оставьте заявку, чтобы получить консультацию
Наши специалисты свяжутся с вами в ближайшее время и ответят на все вопросы.
Узнавайте о выходе новых статей в блоге первыми!
Будем держать в курсе новостей и облачных трендов


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

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

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


