Основы архитектуры кода: принципы, паттерны и примеры
В этой статье мы разберём ключевые идеи, которые помогают делать код понятным, гибким и надёжным. Вместо абстрактных теорий я приведу конкретные фрагменты кода и объясню, какую проблему решает каждый подход. Материал ориентирован на начинающих разработчиков, но будет полезен и тем, кто хочет систематизировать свои знания.
1. Базовые принципы: KISS и DRY
Эти два правила — самый простой способ улучшить качество кода без глубокого погружения в архитектуру.
KISS — не усложняйте
Решение должно быть настолько простым, насколько это возможно в текущих условиях. Излишняя сложность возникает, когда мы забегаем вперёд и добавляем абстракции «на всякий случай».
Плохой пример (избыточное усложнение):
from abc import ABC, abstractmethod
# ABC — абстрактный базовый класс. Заставляет наследников реализовать метод apply()
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, amount):
pass
# Стратегия "без скидки" — просто возвращает сумму. Зачем для этого отдельный класс?
class NoDiscount(DiscountStrategy):
def apply(self, amount):
return amount
# Стратегия "процентная скидка" — вычисляет итог с учётом процента
class PercentageDiscount(DiscountStrategy):
def __init__(self, percent):
self.percent = percent
def apply(self, amount):
return amount * (1 - self.percent / 100)
# Заказ принимает стратегию скидки. Теперь чтобы посчитать итог, нужно создать объект стратегии
class Order:
def __init__(self, amount, strategy: DiscountStrategy):
self.amount = amount
self.strategy = strategy
def total(self):
return self.strategy.apply(self.amount)
# Использование: создаём заказ, передаём объект стратегии, вызываем метод
order = Order(1200, PercentageDiscount(10))
print(order.total())
# Проблема: для задачи "если сумма > 1000, дать скидку 10%" мы написали 4 класса.
# Это паттерн "Стратегия", и он полезен в больших системах. Но здесь — оверинжиниринг.
Хороший пример (простое решение):
# Та же задача, но решена в 4 строки. Если логика простая — не нужно городить иерархию классов
def calculate_discount(amount):
if amount > 1000:
return amount * 0.9 # Скидка 10%
return amount # Без скидки
print(calculate_discount(1200)) # 1080
# KISS в действии: код читается как текст, не требует понимания ООП-паттернов
Пояснение. В первом случае мы создали иерархию классов для стратегии скидки, хотя в реальности скидка пока что одна и условие простое. Это добавляет сложность без выгоды. Простая функция читается легче, её проще тестировать и менять. Когда появятся новые виды скидок, можно будет вернуться к более сложной структуре, но не раньше.
DRY — не повторяйтесь
Любая часть логики должна существовать в одном месте. Дублирование ведёт к ошибкам при изменениях.
Плохой пример (повторяющийся расчёт):
# Считаем площадь круга в трёх разных местах программы
# В одном месте
circle_area1 = 3.14159 * r1 * r1
# В другом месте, через 200 строк
circle_area2 = 3.14159 * r2 * r2
# И ещё в третьем
circle_area3 = 3.14159 * r3 * r3
# Проблема: формула скопирована три раза. Если завтра решим использовать math.pi
# или изменить точность — придётся искать и править все три места.
# Забыли одно — получили баг.
Хороший пример (вынесение в функцию):
# Вынесли формулу в функцию. Теперь она живёт в одном месте
def circle_area(radius):
return 3.14159 * radius * radius
# Везде используем эту функцию — код короче и понятнее
area1 = circle_area(r1)
area2 = circle_area(r2)
area3 = circle_area(r3)
# Преимущество: если нужно изменить формулу (например, использовать math.pi),
# правим только одну строку внутри функции. Все вызовы автоматически получат обновление.
Пояснение. Если значение числа Пи уточнится (например, возьмём больше знаков), нам потребуется изменить только одну строку в функции. Кроме того, код становится самодокументируемым — имя функции сразу говорит о том, что она делает.
2. Пять принципов SOLID
Рассмотрим эти принципы, покажем примеры на Python и кратко поясним код.
S — Единственная ответственность
Суть: в классе должна решаться одна задача и изменение этой задачи — единственная причина изменить код класса.
Нарушение: класс Report одновременно формирует отчёт и сохраняет его в файл.
class Report:
def __init__(self, data):
self.data = data
def generate(self):
# Отвечает за бизнес-логику: как должен выглядеть текст отчёта
return f"Report: {self.data}"
def save_to_file(self, filename):
# Отвечает за инфраструктуру: работу с файловой системой
with open(filename, 'w') as f:
f.write(self.generate())
# В чём проблема? У этого класса ДВЕ причины для изменений.
# Если маркетологи попросят изменить формат текста — мы полезем в этот класс.
# Если системные администраторы попросят сохранять отчёты не в файл, а в облако — мы снова полезем в этот же класс.
# Смешивать логику и сохранение — плохая идея.
Если понадобится изменить формат сохранения, например, перейти на JSON, придётся править класс Report. Также если изменится логика генерации, это может случайно задеть сохранение.
Исправление: разделим на два класса.
class Report:
def __init__(self, data):
self.data = data
def generate(self):
# Этот класс знает ТОЛЬКО как сформировать данные.
# Ему всё равно, куда они потом пойдут.
return f"Report: {self.data}"
class ReportSaver:
@staticmethod
def save_to_file(report: Report, filename):
# Этот класс знает ТОЛЬКО как сохранить данные в файл.
# Он не занимается их форматированием.
with open(filename, 'w') as f:
f.write(report.generate())
# Почему это хорошо?
# Если завтра нам понадобится сохранять отчёт в базу данных или отправлять по почте,
# мы просто создадим новый класс (например, ReportDatabaseSaver).
# Сам класс Report трогать не придётся — он стабилен и надёжен.
После разделения класса каждый из классов отвечает за свою функцию. Если понадобится сохранять отчёт в базу данных или в облачное хранилище, мы создадим новый класс с методами сохранения в нужном формате, не трогая логику формирования отчёта.
O — Открыт для расширения, закрыт для изменения
Суть: можно добавлять новое поведение без модификации существующего кода.
Нарушение: класс AreaCalculator содержит условные операторы для каждой фигуры и его придется постоянно переписывать.
class AreaCalculator:
def calculate(self, shape):
# Жёсткая проверка типа. Если добавим треугольник, придётся лезть в этот метод
if shape['type'] == 'circle':
return 3.14159 * shape['radius'] ** 2
elif shape['type'] == 'square':
return shape['side'] ** 2
# Проблема: этот класс НЕ закрыт от изменений.
# Каждый новый тип фигуры заставляет нас модифицировать уже работающий код,
# что повышает риск случайно сломать расчёт для круга или квадрата.
Исправление: используем полиморфизм. Создадим общий интерфейс (абстрактный класс) и отдельные классы для каждой фигуры.
from abc import ABC, abstractmethod
# Создаём общий "контракт". Любой наследник ОБЯЗАН реализовать метод area()
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
class AreaCalculator:
def calculate(self, shape: Shape):
# Калькулятор больше не знает, с какой фигурой работает.
# Он просто вызывает area().
# Чтобы добавить треугольник, мы создадим новый класс Triangle,
# но сам AreaCalculator трогать не будем. Принцип OCP соблюден!
return shape.area()
Теперь, чтобы добавить треугольник, мы просто создадим класс Triangle, реализующий метод area(). Код AreaCalculator останется без изменений — его нельзя править, но можно расширить.
L — Принцип подстановки Барбары Лисков
Суть: объекты наследников должны вести себя так же, как объекты родителя, чтобы их можно было заменять без нарушения ожиданий.
Нарушение: класс Penguin наследуется от Bird, но метод fly() выбрасывает исключение. Наследник ломает ожидания родителя
class Bird:
def fly(self):
return "I'm flying!"
class Penguin(Bird):
def fly(self):
# Пингвин технически наследуется от птицы, но ломает её базовое поведение
raise NotImplementedError("Penguins can't fly!")
def make_bird_fly(bird: Bird):
# Функция ожидает, что любая Bird умеет летать.
# Но если передать сюда пингвина, программа упадёт с ошибкой.
# Это и есть нарушение LSP: подкласс не может безопасно заменить родительский класс.
return bird.fly()
Исправление: не следует наследовать пингвина от птицы, если мы ожидаем умение летать. Лучше выделить интерфейс для летающих существ.
class Bird:
pass # Базовый класс просто обозначает принадлежность к птицам
class FlyingBird(Bird):
def fly(self):
return "I'm flying!"
class Penguin(Bird):
# Пингвин — это птица, но он НЕ летающая птица.
# Мы просто не даём ему метод fly(), и это абсолютно нормально.
pass
# Теперь функция make_bird_fly() в типизации будет принимать только FlyingBird.
# Подстановка любого объекта FlyingBird всегда будет безопасной.
Пояснение. Принцип Лисков напоминает, что наследование должно сохранять семантику. Если подкласс делает что-то, что противоречит поведению базового класса, это приводит к хрупкому коду.
I — Разделение интерфейсов
Суть: Интерфейсы не должны быть перегружены методами, которые не нужны всем реализующим классам. Лучше сделать несколько маленьких и целенаправленных интерфейсов.
Нарушение: интерфейс Worker содержит методы для разных профессий.
from abc import ABC, abstractmethod
class Worker(ABC):
@abstractmethod
def code(self): pass
@abstractmethod
def design(self): pass
@abstractmethod
def test(self): pass
class Developer(Worker):
def code(self):
return "writing code"
def design(self):
# Разработчик не дизайнер, но интерфейс заставляет его реализовать этот метод.
# Приходится писать "заглушку", которая ничего не делает или падает с ошибкой.
raise NotImplementedError
def test(self):
return "running tests"
Если мы создадим класс Developer, ему придётся реализовывать design, хотя он дизайнером не является. Конечно, можно оставить заглушку, но это загрязняет код и создаёт ложные ожидания.
Исправление: разбиваем Employee на три отдельных интерфейса:
class Coder(ABC):
@abstractmethod
def code(self): pass
class Designer(ABC):
@abstractmethod
def design(self): pass
class Tester(ABC):
@abstractmethod
def test(self): pass
# Разработчик умеет писать код и тестировать. Он просто наследует нужные интерфейсы.
class Developer(Coder, Tester):
def code(self):
return "writing code"
def test(self):
return "running tests"
# Никаких заглушек и NotImplementedError. Класс реализует только то, что реально умеет.
Теперь Developer может реализовать только Coder и Tester, а UIDesigner — только Designer. Каждый класс берёт ровно то, что ему нужно, и не зависит от посторонних методов. Это делает систему более гибкой и понятной.
D — Инверсия зависимостей
Суть: вместо того чтобы жёстко привязывать высокоуровневый модуль к низкоуровневым деталям, следует опираться на абстракции. Тогда конкретные реализации можно менять безболезненно.
Нарушение: сервис работы с пользователями напрямую создаёт экземпляр конкретной базы данных — жёсткая привязка к конкретной реализации:
class MySQLDatabase:
def save(self, data):
print(f"Saving {data} to MySQL")
class UserService:
def __init__(self):
# Жёсткая сцепка! Сервис сам создаёт базу данных.
self.db = MySQLDatabase()
def save_user(self, user):
self.db.save(user)
# Проблема: если завтра мы захотим перейти на PostgreSQL,
# или напишем unit-тест и захотим подменить БД на "фейковую" (Mock),
# мы не сможем этого сделать без переписывания кода UserService.
Если заказчик решит перейти на PostgreSQL, нам придётся править код UserService, что неудобно и рискованно.
Исправление: вводим интерфейс (абстракцию) для хранилища и передаём конкретную реализацию извне:
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def save(self, data):
pass
class MySQLDatabase(Database):
def save(self, data):
print(f"Saving {data} to MySQL")
class PostgreSQLDatabase(Database):
def save(self, data):
print(f"Saving {data} to PostgreSQL")
class UserService:
def __init__(self, db: Database):
self.db = db
def save_user(self, user):
self.db.save(user)
Теперь UserService зависит только от абстракции Storage. Мы можем подставить любой класс, реализующий этот интерфейс — хоть для тестов, хоть для продакшена. При этом сам сервис не меняется. Такой подход также упрощает модульное тестирование, потому что можно передать заглушку (mock) без реальной базы.
Эти три принципа тесно связаны между собой и вместе с первыми двумя (S и O) формируют фундамент для создания устойчивых к изменениям систем. На практике стоит начинать с осознания «запахов» кода — когда вы чувствуете, что изменение одной детали тянет за собой множество правок, вероятно, нарушен один из этих принципов.
3. Паттерны проектирования
Паттерны — это проверенные решения для типовых проблем. Приведу три наиболее часто встречающихся.
Singleton (Одиночка)
Задача: гарантировать, что у класса есть только один экземпляр, и предоставить глобальный доступ к нему. Например, для настроек приложения или логгера.
Пример реализации (Python)
class AppConfig:
_instance = None # Сюда сохраним ссылку на наш единственный объект
def __new__(cls):
# Переопределяем метод создания объекта.
# Если использовать __init__, он будет вызываться при каждом вызове AppConfig(),
# а нам нужно контролировать само создание экземпляра.
if cls._instance is None:
# Если объекта еще нет в памяти — создаем его
cls._instance = super().__new__(cls)
# Инициализируем настройки ровно один раз
cls._instance.settings = {"theme": "dark", "language": "en"}
# Если объект уже есть — просто возвращаем существующую ссылку
return cls._instance
# Использование
config1 = AppConfig()
config2 = AppConfig()
print(config1 is config2) # True. Мы убедились, что config1 и config2 — это один и тот же объект в памяти.
Когда использовать: когда объект должен быть общим для всей системы и его создание дорого (например, подключение к базе данных или кэш).
Factory (Фабрика)
Задача: инкапсулировать создание объектов, чтобы клиентский код не зависел от конкретных классов.
Пример: создание разных типов уведомлений.
class Notification:
def send(self, message):
pass # Базовый контракт. Все уведомления обязаны иметь этот метод.
class EmailNotification(Notification):
def send(self, message):
print(f"Email: {message}")
class SMSNotification(Notification):
def send(self, message):
print(f"SMS: {message}")
class NotificationFactory:
@staticmethod
def create_notification(notification_type):
# Фабрика берет всю грязную работу по созданию объектов на себя.
if notification_type == "email":
return EmailNotification()
elif notification_type == "sms":
return SMSNotification()
else:
raise ValueError("Unknown type")
# Использование
factory = NotificationFactory()
# Клиент просто говорит "хочу email", не импортируя класс EmailNotification напрямую.
notif = factory.create_notification("email")
notif.send("Hello!")
# Польза: если завтра мы добавим TelegramNotification, нам нужно будет поправить
# только код внутри фабрики. Весь остальной код приложения трогать не придется.
Теперь, если появится новый тип (например, Push), мы добавим новый класс и изменим фабрику, но код, использующий фабрику, останется прежним.
Observer (Наблюдатель)
Задача: объекты (наблюдатели) могут подписаться на изменения состояния другого объекта (субъекта) и автоматически получать уведомления.
Пример: система уведомлений о завершении задачи.
class Task:
def __init__(self):
# Список тех, кто "подписался" на изменения этой задачи
self._observers = []
def attach(self, observer):
# Метод подписки
self._observers.append(observer)
def detach(self, observer):
# Метод отписки (если наблюдатель больше не хочет получать уведомления)
self._observers.remove(observer)
def complete(self):
print("Task completed")
# Когда состояние меняется, мы просто оповещаем всех подписчиков.
# Обратите внимание: Task не знает ни про EmailNotifier, ни про Logger!
for observer in self._observers:
observer.update("Task done")
class EmailNotifier:
def update(self, message):
print(f"Sending email: {message}")
class Logger:
def update(self, message):
print(f"Logging: {message}")
# Использование
task = Task()
task.attach(EmailNotifier()) # Подписываем почтовик
task.attach(Logger()) # Подписываем логгер
task.complete()
# Выведет:
# Task completed
# Sending email: Task done
# Logging: Task done
# Главная фишка: мы можем добавить хоть 10 новых наблюдателей (например, отправку в Slack),
# и нам вообще не придется менять код класса Task.
Этот паттерн широко используется в GUI-фреймворках и реактивных системах.
4. Архитектурные подходы
Когда речь заходит о структуре всего проекта, разработчики прибегают к проверенным временем шаблонам разделения ответственности между компонентами.
MVC (Model‑View‑Controller)
- Model — содержит данные приложения и всю бизнес-логику (в частности, это сущности предметной области и бизнес-сервисы).
- View — отображение (шаблоны, UI-компоненты).
- Controller — обработка входящих запросов, координация между Model и View.
Типичный сценарий работы: когда поступает HTTP-запрос, фреймворк вроде Django или Spring направляет его соответствующему контроллеру. Тот, в свою очередь, обращается к модели за необходимыми данными, а затем формирует ответ через представление (шаблон).
MVVM (Model‑View‑ViewModel)
Популярен в мобильной разработке и в XAML-фреймворках (WPF, Xamarin, Android с Data Binding).
- Model — данные.
- View (Представление) — отвечает исключительно за визуальную часть. Вместо прямого вызова методов, этот слой «приклеивается» к ViewModel с помощью механизма привязок (binding).
- ViewModel — предоставляет данные и команды для View, но не знает о том, как именно она отображается.
ViewModel (Модель представления) — выступает связующим звеном: она подготавливает свойства и обрабатывает действия пользователя, оставаясь при этом полностью «слепой» к тому, как именно эти данные будут нарисованы на экране.
Преимущество: интерфейс сам перерисовывается, как только меняются данные. Кроме того, ViewModel можно полноценно покрывать юнит-тестами без необходимости запускать само приложение или эмулятор.
Clean Architecture
Эту архитектуру предложил Роберт Мартин. Она основана на независимости от внешних факторов (БД, UI, фреймворки).
Структура (от центра к краям):
- Сущности (Entities) — бизнес-объекты (например, User, Order).
- Сценарии применения (Use Cases) отвечают за реализацию ключевых бизнес-алгоритмов системы.
- Интерфейсы адаптеров — контроллеры, презентеры, преобразователи данных.
- Фреймворки и драйверы — базы данных, веб-серверы, UI.
Основополагающее правило диктует направление связей строго к бизнес-логике. Внешние инструменты зависят от внутренних, но не наоборот. На практике это означает полную независимость ядра: вы можете в любой момент поменять базу данных или фреймворк, просто переписав адаптеры, и при этом не тронете ни строчки в самих бизнес-правилах.
В коде это часто выражается в структуре папок:
src/
domain/ # сущности и интерфейсы репозиториев
application/ # use cases
infrastructure/ # реализации репозиториев, внешние сервисы
interfaces/ # контроллеры, представления
Заключение
Мы разобрали основные принципы и паттерны, которые помогают создавать поддерживаемые и гибкие системы. Начинающим стоит в первую очередь обратить внимание на KISS и DRY — они дают мгновенный эффект и просты в освоении. Постепенно, по мере роста проектов, вы будете интуитивно применять SOLID и паттерны там, где они действительно нужны.
Помните, что архитектура — это инструмент, а не цель. Избыточное усложнение так же вредно, как и полное его отсутствие. Главное, чтобы код был понятен вам и вашим коллегам, а изменения в нём не превращались в квест.





0 комментариев
Добавить комментарий