Паттерны #
1. Какие группы паттернов (паттерны) вы можете назвать (порождающие, структурные, поведенческие) и приведите примеры из каждой? #
Группы паттернов проектирования #
Классическая классификация GoF делит паттерны на 3 основные группы:
- Порождающие
- Структурные
- Поведенческие
1. Порождающие паттерны #
Отвечают за создание объектов. Помогают скрыть сложную логику создания и сделать код менее зависимым от конкретных классов. Примеры:
| Паттерн | Назначение |
|---|---|
| Singleton | Гарантирует, что у класса есть только один экземпляр |
| Factory Method | Создаёт объекты через метод, позволяя подклассам выбирать конкретный класс |
| Abstract Factory | Создаёт семейства связанных объектов |
| Builder | Пошагово собирает сложный объект |
| Prototype | Создаёт новые объекты копированием существующих |
2. Структурные паттерны #
Отвечают за организацию классов и объектов. Помогают удобно связывать объекты между собой, не усложняя архитектуру. Примеры:
| Паттерн | Назначение |
|---|---|
| Adapter | Позволяет объектам с несовместимыми интерфейсами работать вместе |
| Decorator | Динамически добавляет объекту новое поведение |
| Facade | Даёт простой интерфейс к сложной системе |
| Proxy | Контролирует доступ к другому объекту |
| Composite | Позволяет работать с группой объектов как с одним объектом |
| Bridge | Разделяет абстракцию и реализацию |
| Flyweight | Экономит память за счёт переиспользования общих данных |
3. Поведенческие паттерны #
Отвечают за взаимодействие объектов и распределение ответственности между ними. Примеры:
| Паттерн | Назначение |
|---|---|
| Strategy | Позволяет менять алгоритм во время выполнения программы |
| Observer | Уведомляет зависимые объекты об изменениях |
| Command | Инкапсулирует действие в отдельный объект |
| Chain of Responsibility | Передаёт запрос по цепочке обработчиков |
| State | Меняет поведение объекта в зависимости от его состояния |
| Template Method | Задаёт общий алгоритм, позволяя переопределять отдельные шаги |
| Iterator | Даёт способ последовательно обходить коллекцию |
| Mediator | Централизует взаимодействие между объектами |
| Memento | Сохраняет и восстанавливает состояние объекта |
| Visitor | Добавляет операции к объектам без изменения их классов |
2. Как реализовать паттерн Singleton и какие есть основные способы реализации? #
Singleton — это паттерн, который гарантирует, что у класса будет только один экземпляр, и даёт глобальную точку доступа к нему.
В Python Singleton обычно реализуют не так часто, как в Java/C++, потому что во многих случаях достаточно обычного модуля: Python кэширует импортированные модули в sys.modules, и повторный импорт обычно возвращает уже загруженный объект модуля.
1. Реализация через __new__
#
Это один из самых прямых способов. Метод __new__ отвечает за создание объекта до вызова __init__. Python-документация прямо указывает, что __new__() вызывается для создания нового экземпляра класса.
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
a = Singleton()
b = Singleton()
print(a is b) # True
Проблема этого варианта: __init__ будет вызываться каждый раз при Singleton().
Например:
class Singleton:
_instance = None
def __new__(cls, value):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
self.value = value
a = Singleton(10)
b = Singleton(20)
print(a.value) # 20
print(b.value) # 20
Объект один, но состояние перезаписалось.
Более безопасный вариант:
class Singleton:
_instance = None
_initialized = False
def __new__(cls, value):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
if self._initialized:
return
self.value = value
self._initialized = True
a = Singleton(10)
b = Singleton(20)
print(a is b) # True
print(a.value) # 10
print(b.value) # 10
2. Потокобезопасный Singleton через Lock
#
Если объект может создаваться из нескольких потоков, простой вариант через __new__ может быть недостаточным. Для защиты критической секции используют блокировку. В документации Python threading.Lock описан как механизм, который блокирует поток до освобождения lock.
from threading import Lock
class Singleton:
_instance = None
_lock = Lock()
def __new__(cls, value):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
if hasattr(self, "_initialized"):
return
self.value = value
self._initialized = True
a = Singleton(10)
b = Singleton(20)
print(a is b) # True
print(a.value) # 10
Она нужна, чтобы не заходить в lock после того, как объект уже создан.
3. Реализация через метакласс #
Метакласс управляет созданием экземпляров классов. Это более универсальный способ, если Singleton нужно применить к нескольким классам.
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self, url):
self.url = url
db1 = Database("postgres://localhost")
db2 = Database("sqlite://local")
print(db1 is db2) # True
print(db1.url) # postgres://localhost
print(db2.url) # postgres://localhost
Плюс: можно переиспользовать для разных классов. Минус: метаклассы усложняют код.
4. Реализация через декоратор #
Можно написать декоратор, который будет возвращать уже созданный объект.
def singleton(cls):
instances = {}
def wrapper(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return wrapper
@singleton
class Config:
def __init__(self, debug):
self.debug = debug
c1 = Config(True)
c2 = Config(False)
print(c1 is c2) # True
print(c1.debug) # True
print(c2.debug) # True
Минус: после декорирования Config уже не совсем обычный класс, а функция-обёртка. Это может мешать наследованию, isinstance, introspection и типизации.
5. Singleton через модуль #
В Python это часто самый простой и практичный способ.
Файл settings.py:
class Config:
def __init__(self):
self.debug = True
self.db_url = "postgres://localhost"
config = Config()
Использование:
from settings import config
print(config.debug)
Плюс: просто, понятно, без метаклассов и переопределения __new__.
Минус: это не классический Singleton-класс, а объект уровня модуля.
Что лучше использовать на практике #
Для Python чаще всего:
| Способ | Когда использовать |
|---|---|
| Модуль | Лучший простой вариант для конфигов, логгеров, клиентов |
__new__ | Когда нужно сохранить обычный синтаксис Class() |
__new__ + Lock | Когда объект может создаваться из разных потоков |
| Метакласс | Когда Singleton нужен для нескольких классов |
| Декоратор | Для простых учебных примеров, но в проде спорный вариант |
3. Чем отличается паттерн Декоратор от Адаптера? #
| Критерий | Adapter | Decorator |
|---|---|---|
| Цель | Преобразовать интерфейс одного класса в интерфейс, который ожидает клиент. | Добавить новую функциональность объекту без изменения его структуры. |
| Пример использования | Используется, когда у нас есть несовместимые интерфейсы, которые нужно связать. | Используется, когда нужно динамически добавлять новые возможности объектам. |
| Взаимодействие | Адаптирует существующий класс к новому интерфейсу. | Оборачивает объект, добавляя или изменяя его поведение. |
| Структура | Реализует интерфейс, который ожидает клиент, и делегирует вызовы адаптируемому классу. | Оборачивает объект одного и того же интерфейса, добавляя новую логику. |
| Пример в реальной жизни | Электрический адаптер, который преобразует тип розетки. | Подарочная упаковка на коробке, которая не меняет содержимое, но добавляет украшение. |
Паттерн Decorator
Декоратор — это структурный паттерн проектирования, который позволяет добавлять объектам новые обязанности на лету, помещая их в специальные обёртки.
Ключевые особенности:
- Динамическое добавление функциональности: Не изменяя код исходного объекта.
- Композиция вместо наследования: Декоратор оборачивает объект, вместо того чтобы расширять его через наследование.
- Прозрачность для клиента: Декоратор использует тот же интерфейс, что и объект, который он оборачивает.
4. Паттерн Адаптер #
Adapter Pattern #
Адаптер — это структурный паттерн, который позволяет подружить несовместимые объекты. Адаптер выступает прослойкой между двумя объектами, превращая вызовы одного в вызовы понятные другому.
Суть паттерна: Есть класс или объект с определённым интерфейсом, который неудобен или несовместим с остальной системой. Создаётся адаптер — новый класс, реализующий нужный клиенту интерфейс. Адаптер содержит ссылку на оригинальный объект и внутри методов преобразует вызовы клиента в вызовы методов оригинального объекта.
Пример реализации:
class OldSystem:
def old_method(self):
print("Старый метод")
class Adapter:
def __init__(self, old_system):
self.old_system = old_system
def new_method(self):
print("Адаптер вызывает:")
self.old_system.old_method()
# Использование
old = OldSystem()
adapter = Adapter(old)
adapter.new_method
Вывод:
Адаптер вызывает:
Старый метод
Использование:
- Когда нужно использовать сторонний или legacy класс с несовместимым интерфейсом.
- Для объединения нескольких интерфейсов в один.
- Для адаптации классов, которые нельзя модифицировать.
Паттерн помогает создавать гибкие и расширяемые системы, позволяя переиспользовать существующий код без изменения.
Что меняет паттерн адаптер?
Паттерн Адаптер (Adapter) меняет интерфейс одного класса или объекта так, чтобы он мог работать с другим, несовместимым интерфейсом. Он «подгоняет» один класс под API, который ожидают другие компоненты.
Меняет ли адаптер поведение?
Нет, адаптер не меняет поведение объекта. Он только изменяет интерфейс, через который к этому поведению обращаются. Адаптер просто перенаправляет или преобразует вызовы Пример, как переходник для розетки — сам по себе он ничего не делает, только позволяет подключить устройство.
5. Паттерн Декоратор #
Decorator — это структурный паттерн проектирования. Его задача — динамически добавлять объекту новое поведение, не изменяя его исходный класс. Обычно это делается через объект-обёртку, который имеет тот же интерфейс, что и исходный объект, и внутри хранит ссылку на него.
Когда нужен Decorator Паттерн полезен, когда:
| Ситуация | Почему Decorator подходит |
|---|---|
| Нужно добавить поведение объекту без изменения его класса | Исходный класс остаётся чистым |
| Нужно комбинировать разные расширения | Декораторы можно накладывать друг на друга |
| Наследование создаёт слишком много подклассов | Decorator заменяет разрастание иерархии |
| Поведение нужно добавлять только отдельным объектам | Меняется конкретный экземпляр, а не весь класс |
Например, есть обычный сервис отправки уведомлений. Потом нужно добавить логирование, валидацию, кеширование, метрики. Делать отдельные классы вроде LoggingCachedValidatedNotificationService — плохая идея. Лучше оборачивать объект отдельными декораторами. |
Классическая структура #
У паттерна обычно есть 4 роли:
| Роль | Назначение |
|---|---|
| Component | Общий интерфейс |
| ConcreteComponent | Основной объект |
| BaseDecorator | Базовый декоратор, хранит ссылку на Component |
| ConcreteDecorator | Конкретное расширение поведения |
Пример на Python
Допустим, есть сервис оплаты.
from abc import ABC, abstractmethod
class PaymentService(ABC):
@abstractmethod
def pay(self, amount: int) -> None:
pass
class BasicPaymentService(PaymentService):
def pay(self, amount: int) -> None:
print(f"Оплата на сумму {amount}")
Теперь сделаем базовый декоратор:
class PaymentDecorator(PaymentService):
def __init__(self, payment_service: PaymentService):
self._payment_service = payment_service
def pay(self, amount: int) -> None:
self._payment_service.pay(amount)
Добавим логирование:
class LoggingPaymentDecorator(PaymentDecorator):
def pay(self, amount: int) -> None:
print(f"Лог: попытка оплаты {amount}")
self._payment_service.pay(amount)
print("Лог: оплата завершена")
Добавим проверку суммы:
class ValidationPaymentDecorator(PaymentDecorator):
def pay(self, amount: int) -> None:
if amount <= 0:
raise ValueError("Сумма оплаты должна быть больше 0")
self._payment_service.pay(amount)
Использование:
payment_service = BasicPaymentService()
payment_service = ValidationPaymentDecorator(payment_service)
payment_service = LoggingPaymentDecorator(payment_service)
payment_service.pay(100)
Результат:
Лог: попытка оплаты 100
Оплата на сумму 100
Лог: оплата завершена
Что здесь происходит #
Исходный объект:
BasicPaymentService()
оборачивается в декораторы:
LoggingPaymentDecorator(
ValidationPaymentDecorator(
BasicPaymentService()
)
)
Снаружи это всё ещё PaymentService, потому что и основной класс, и декораторы реализуют один интерфейс.
Главное отличие от наследования #
Через наследование можно было бы сделать так:
class LoggingPaymentService(BasicPaymentService):
...
Но проблема в том, что при большом количестве комбинаций появится много классов:
LoggingPaymentService
ValidationPaymentService
CachedPaymentService
LoggingValidationPaymentService
CachedLoggingPaymentService
CachedValidationPaymentService
CachedLoggingValidationPaymentService
Decorator решает это через композицию: объект не наследуется, а оборачивается.
Decorator и Python-декораторы #
Важно не путать:
@some_decorator
def func():
...
и GoF-паттерн Decorator.
Python-декораторы — это языковой механизм для оборачивания функций, методов или классов. Например, functools.wraps используется внутри декораторов, чтобы wrapper сохранял метаданные исходной функции: имя, документацию, аннотации и ссылку __wrapped__.
Пример Python-декоратора:
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Вызов функции {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def create_user(username: str) -> None:
print(f"Создан пользователь {username}")
create_user("alex")
6. Dependency Injection (DI) #
Dependency Injection, или DI, — это подход, при котором объект не создаёт свои зависимости сам, а получает их извне.
Зависимость — это любой объект, который нужен классу для работы: репозиторий, база данных, кеш, HTTP-клиент, логгер, сервис оплаты и так далее.
Вариант без DI:
class UserService:
def __init__(self):
self.repository = PostgresUserRepository()
def get_user(self, user_id: int):
return self.repository.get_user(user_id)
Проблема: UserService жёстко связан с PostgresUserRepository.
Вариант с DI:
class UserService:
def __init__(self, repository):
self.repository = repository
def get_user(self, user_id: int):
return self.repository.get_user(user_id)
Теперь UserService не знает, откуда берутся данные: из PostgreSQL, Redis, памяти, mock-объекта в тестах. Он просто использует переданный ему объект.
Martin Fowler описывает Dependency Injection как способ, при котором внешний объект/контейнер собирает зависимости и передаёт их компонентам, вместо того чтобы компоненты сами искали или создавали нужные объекты.
Зачем нужен DI #
DI нужен, чтобы уменьшить связанность кода.
| Без DI | С DI |
|---|---|
| Класс сам создаёт зависимости | Класс получает зависимости извне |
| Сложнее тестировать | Легче подставлять mock/fake |
| Жёсткая связь с конкретными классами | Можно зависеть от интерфейса/абстракции |
| Сложнее менять реализацию | Реализацию можно заменить без изменения бизнес-логики |
Пример без DI:
class EmailSender:
def send(self, email: str, text: str) -> None:
print(f"Send email to {email}: {text}")
class NotificationService:
def __init__(self):
self.sender = EmailSender()
def notify(self, email: str, text: str) -> None:
self.sender.send(email, text)
Проблема:
self.sender = EmailSender()
NotificationService сам создаёт EmailSender. Значит, он жёстко зависит от конкретного способа отправки.
Пример с DI:
class EmailSender:
def send(self, email: str, text: str) -> None:
print(f"Send email to {email}: {text}")
class NotificationService:
def __init__(self, sender: EmailSender):
self.sender = sender
def notify(self, email: str, text: str) -> None:
self.sender.send(email, text)
sender = EmailSender()
service = NotificationService(sender)
service.notify("user@example.com", "Hello")
Теперь NotificationService получает sender извне.
Лучше через абстракцию #
В Python можно описать контракт через ABC. Модуль abc в стандартной библиотеке используется для объявления абстрактных базовых классов.
from abc import ABC, abstractmethod
class Sender(ABC):
@abstractmethod
def send(self, address: str, text: str) -> None:
pass
class EmailSender(Sender):
def send(self, address: str, text: str) -> None:
print(f"Email to {address}: {text}")
class SmsSender(Sender):
def send(self, address: str, text: str) -> None:
print(f"SMS to {address}: {text}")
class NotificationService:
def __init__(self, sender: Sender):
self.sender = sender
def notify(self, address: str, text: str) -> None:
self.sender.send(address, text)
Использование:
email_service = NotificationService(EmailSender())
email_service.notify("user@example.com", "Hello")
sms_service = NotificationService(SmsSender())
sms_service.notify("+994000000000", "Hello")
NotificationService не зависит от EmailSender или SmsSender. Он зависит от абстракции Sender.
Основные способы Dependency Injection #
1. Constructor Injection
Зависимость передаётся через конструктор.
class UserService:
def __init__(self, repository):
self.repository = repository
Это самый частый и обычно самый удобный вариант.
Плюсы:
- Явные зависимости
- Удобно тестировать
2. Method Injection
Зависимость передаётся прямо в метод.
class ReportService:
def generate_report(self, repository):
users = repository.get_users()
return users
Подходит, когда зависимость нужна только одному конкретному методу.
3. Property / Setter Injection
Зависимость устанавливается через атрибут или setter.
class UserService:
def set_repository(self, repository):
self.repository = repository
Минус:
объект можно создать в неполном состоянии. Например, забыли вызвать set_repository(), а потом метод упал с ошибкой.
4. DI-контейнер
DI-контейнер — это объект или фреймворк, который сам знает, какие зависимости нужно создать и куда их передать.
Условно:
container.register(UserRepository, PostgresUserRepository)
container.register(UserService)
Потом контейнер сам создаёт UserService и передаёт ему PostgresUserRepository.
В Python DI-контейнеры не всегда нужны. Часто достаточно обычной ручной сборки зависимостей в одном месте приложения.
DI в FastAPI
FastAPI имеет встроенную систему зависимостей. В документации FastAPI указано, что можно объявить зависимость, которую нужно выполнить до path operation, а FastAPI выполнит её и внедрит результат.
from fastapi import Depends, FastAPI
app = FastAPI()
class UserRepository:
def get_user(self, user_id: int):
return {"id": user_id, "username": "alex"}
def get_user_repository():
return UserRepository()
@app.get("/users/{user_id}")
def get_user(
user_id: int,
repository: UserRepository = Depends(get_user_repository),
):
return repository.get_user(user_id)
Здесь:
repository: UserRepository = Depends(get_user_repository)
означает: FastAPI должен вызвать get_user_repository() и передать результат в параметр repository. В справке FastAPI также указано, что зависимости в FastAPI в основном обрабатываются через Depends(), принимающий callable.
DI и тестирование
Главное практическое преимущество DI — тесты.
Например, есть настоящий репозиторий:
class PostgresUserRepository:
def get_user(self, user_id: int):
# запрос в PostgreSQL
return {"id": user_id, "username": "alex"}
А для теста можно сделать fake:
class FakeUserRepository:
def get_user(self, user_id: int):
return {"id": user_id, "username": "test_user"}
Тестируем сервис без настоящей базы данных:
def test_get_user():
repository = FakeUserRepository()
service = UserService(repository)
user = service.get_user(1)
assert user["username"] == "test_user"
Без DI пришлось бы лезть внутрь UserService, патчить PostgresUserRepository, мокать импорты или поднимать реальную БД.
Кратко:
Dependency Injection — это подход, при котором класс не создаёт свои зависимости сам, а получает их извне: через конструктор, метод, setter или DI-контейнер.
Главная цель DI — уменьшить связанность кода, упростить замену реализаций и сделать код удобнее для тестирования.
class UserService:
def __init__(self, repository):
self.repository = repository
7. Архитектурный паттерн CQRS #
CQRS #
CQRS — Command Query Responsibility Segregation — это архитектурный паттерн, который разделяет операции изменения данных и операции чтения данных на разные модели. Команды изменяют состояние системы, а запросы только читают данные и не должны менять состояние.
Главная идея:
Command side -> create/update/delete -> write model
Query side -> read/search/list -> read model
Без CQRS #
Обычно один и тот же слой/модель отвечает и за запись, и за чтение:
Controller -> Service -> Repository -> Database
Например:
class OrderService:
def create_order(self, data):
...
def get_order(self, order_id):
...
def list_orders(self, filters):
...
Это нормально для простого CRUD. Но при росте системы чтение и запись начинают требовать разной логики:
Запись:
- валидация бизнес-правил
- транзакции
- проверка прав
- изменение состояния
Чтение:
- фильтры
- сортировки
- агрегации
- joins
- кэширование
- оптимизация под UI
С CQRS #
Логику разделяют:
CreateOrderCommand -> CreateOrderHandler -> write model
GetOrderQuery -> GetOrderHandler -> read model
Пример структуры:
orders/
commands/
create_order.py
cancel_order.py
queries/
get_order.py
list_orders.py
models/
order.py
Пример:
from dataclasses import dataclass
@dataclass
class CreateOrderCommand:
user_id: int
product_id: int
quantity: int
class CreateOrderHandler:
def __init__(self, order_repository):
self.order_repository = order_repository
async def handle(self, command: CreateOrderCommand):
# валидация бизнес-правил
# создание заказа
# сохранение в БД
return await self.order_repository.create(
user_id=command.user_id,
product_id=command.product_id,
quantity=command.quantity,
)
@dataclass
class GetOrderQuery:
order_id: int
user_id: int
class GetOrderHandler:
def __init__(self, order_read_repository):
self.order_read_repository = order_read_repository
async def handle(self, query: GetOrderQuery):
# только чтение
return await self.order_read_repository.get_order_detail(
order_id=query.order_id,
user_id=query.user_id,
)
Важный момент #
CQRS не обязательно означает две разные базы данных. Разделение может быть только на уровне кода: отдельные handlers, DTO, репозитории и модели для чтения/записи. Microsoft прямо отмечает, что у command/query слоев могут быть разные модели, но не обязательно разные хранилища.
То есть можно сделать так:
Одна БД PostgreSQL
├── command handlers используют ORM-модели
└── query handlers используют SQL-запросы / read DTO / views
Или сложнее:
Write DB: PostgreSQL
Read DB: Elasticsearch / Redis / ClickHouse / отдельные read tables
Где CQRS полезен #
CQRS особенно полезен, когда чтение и запись сильно отличаются по нагрузке или сложности. Например, запись требует строгих бизнес-правил, а чтение требует тяжелых фильтров, агрегаций и выдачи данных под интерфейс. Azure Architecture Center указывает, что CQRS позволяет независимо оптимизировать read/write модели и может улучшать производительность, масштабируемость и безопасность.
Хорошие случаи:
- Сложная бизнес-логика на запись.
- Очень много чтения и мало записи.
- Тяжелые дашборды, фильтры, отчеты.
- Нужно отдельно масштабировать чтение и запись.
- Нужно иметь разные модели данных для UI и бизнес-логики.
- Микросервисы, где чтение собирается из нескольких источников.
Где CQRS избыточен #
Для простого CRUD CQRS часто усложняет проект без реальной пользы. Martin Fowler отдельно предупреждает, что CQRS может быть полезен в отдельных ситуациях, но для большинства систем добавляет рискованную сложность.
Плохие случаи:
- Маленький CRUD-сервис.
- Простая админка.
- Нет сложной бизнес-логики.
- Нет проблем с производительностью чтения.
- Команда не готова поддерживать дополнительную архитектурную сложность.
CQRS и Event Sourcing #
CQRS часто путают с Event Sourcing, но это разные паттерны.
CQRS:
- Разделяет чтение и запись. Event Sourcing:
- Хранит не текущее состояние, а цепочку событий.
Они могут использоваться вместе, но CQRS не требует Event Sourcing. Microsoft также отдельно указывает, что при совмещении CQRS и Event Sourcing надо учитывать eventual consistency: read model может обновляться с задержкой.
Пример:
Command:
PlaceOrder
Events:
OrderPlaced
PaymentReserved
OrderConfirmed
Read model:
order_details_view
Плюсы:
- Чище разделение ответственности.
- Проще оптимизировать чтение.
- Проще оптимизировать запись.
- Можно масштабировать read и write части отдельно.
- Можно делать read-модели конкретно под UI.
- Команды легче тестировать отдельно от запросов.
Минусы:
- Больше классов и файлов.
- Сложнее архитектура.
- Может появиться eventual consistency.
- Нужно синхронизировать read model и write model.
- Для простых проектов может быть overengineering.
Пример из реального backend #
Допустим, есть сервис заказов.
Команды:
CreateOrderCommand
CancelOrderCommand
PayOrderCommand
ChangeOrderAddressCommand
Запросы:
GetOrderQuery
ListUserOrdersQuery
GetOrdersStatisticsQuery
SearchOrdersQuery
Команда CreateOrderCommand должна проверять бизнес-правила:
- существует ли пользователь
- есть ли товар
- хватает ли остатков
- можно ли создать заказ
- нужно ли открыть транзакцию
А
ListUserOrdersQueryможет просто читать готовую таблицу или view:
SELECT id, status, total_price, created_at
FROM user_orders_view
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;
Здесь нет смысла использовать одну и ту же модель для создания заказа и для отображения списка заказов.
Главное отличие от обычного Service Layer #
Обычный Service Layer:
OrderService.create_order()
OrderService.cancel_order()
OrderService.get_order()
OrderService.list_orders()
CQRS:
CreateOrderHandler
CancelOrderHandler
GetOrderHandler
ListOrdersHandler
В CQRS каждый handler обычно отвечает за один конкретный use case. Это уменьшает разрастание больших сервисов вида OrderService на сотни методов.
8. Паттерн Стратегия #
Стратегия — это поведенческий паттерн проектирования, который позволяет вынести разные варианты одного алгоритма в отдельные классы и подставлять нужный алгоритм во время выполнения программы. Идея паттерна: объект не реализует все варианты поведения сам, а делегирует выполнение выбранной стратегии отдельному объекту. Refactoring Guru описывает Strategy как способ изолировать код разных алгоритмов и переключать их во время выполнения; SourceMaking также определяет стратегию как набор взаимозаменяемых алгоритмов.
Простая идея #
Допустим, у нас есть разные способы оплаты:
Оплата картой
Оплата через PayPal
Оплата бонусами
Без паттерна часто пишут так:
class PaymentService:
def pay(self, payment_type: str, amount: int):
if payment_type == "card":
print(f"Pay {amount} by card")
elif payment_type == "paypal":
print(f"Pay {amount} by PayPal")
elif payment_type == "bonus":
print(f"Pay {amount} by bonuses")
else:
raise ValueError("Unknown payment type")
Проблема: при добавлении нового способа оплаты придется менять PaymentService.
Это нарушает идею Open/Closed Principle: код должен быть открыт для расширения, но закрыт для изменения.
Решение через Strategy #
Создаем общий интерфейс стратегии:
from abc import ABC, abstractmethod
class PaymentStrategy(ABC):
@abstractmethod
def pay(self, amount: int) -> None:
pass
Создаем конкретные стратегии:
class CardPayment(PaymentStrategy):
def pay(self, amount: int) -> None:
print(f"Pay {amount} by card")
class PayPalPayment(PaymentStrategy):
def pay(self, amount: int) -> None:
print(f"Pay {amount} by PayPal")
class BonusPayment(PaymentStrategy):
def pay(self, amount: int) -> None:
print(f"Pay {amount} by bonuses")
Контекст использует стратегию, но не знает деталей реализации:
class PaymentService:
def __init__(self, strategy: PaymentStrategy):
self.strategy = strategy
def pay(self, amount: int) -> None:
self.strategy.pay(amount)
Использование:
payment_service = PaymentService(CardPayment())
payment_service.pay(100)
payment_service = PaymentService(PayPalPayment())
payment_service.pay(250)
Теперь PaymentService не содержит if/elif по типам оплаты. Он просто вызывает выбранную стратегию.
Структура паттерна #
Client
|
v
Context ---> Strategy interface
^
|
-------------------------
| | |
CardPayment PayPalPayment BonusPayment
Где:
Context — объект, который использует стратегию.
Strategy — общий интерфейс алгоритма.
ConcreteStrategy — конкретная реализация алгоритма.
Client — код, который выбирает нужную стратегию.
Когда использовать #
Strategy полезна, когда есть несколько вариантов одного поведения и они должны быть взаимозаменяемыми. Например, разные способы оплаты, разные алгоритмы сортировки, разные способы доставки, разные правила расчета скидки, разные способы авторизации. Microsoft в описании паттерна указывает, что Strategy позволяет выбирать поведение алгоритма во время выполнения программы.
Хорошие случаи:
- Есть много if/elif по типу поведения.
- Нужно легко добавлять новые варианты алгоритма.
- Нужно менять поведение объекта во время выполнения.
- Разные алгоритмы имеют одинаковую цель, но разную реализацию.
- Нужно вынести сложную логику из большого service-класса.
Когда не стоит использовать #
Strategy может быть избыточной, если вариантов поведения мало и они почти не меняются.
Плохие случаи:
- Всего 2 простых if, и логика вряд ли будет расти.
- Алгоритмы слишком маленькие и не несут отдельной ответственности.
- Паттерн добавляет больше классов, чем пользы.
- Команда не понимает, зачем здесь дополнительная абстракция.
Пример ближе к backend #
Допустим, есть расчет стоимости доставки.
Без Strategy:
class DeliveryService:
def calculate_price(self, delivery_type: str, weight: float) -> float:
if delivery_type == "standard":
return weight * 5
if delivery_type == "express":
return weight * 10 + 20
if delivery_type == "pickup":
return 0
raise ValueError("Unknown delivery type")
Через Strategy:
from abc import ABC, abstractmethod
class DeliveryStrategy(ABC):
@abstractmethod
def calculate(self, weight: float) -> float:
pass
class StandardDelivery(DeliveryStrategy):
def calculate(self, weight: float) -> float:
return weight * 5
class ExpressDelivery(DeliveryStrategy):
def calculate(self, weight: float) -> float:
return weight * 10 + 20
class PickupDelivery(DeliveryStrategy):
def calculate(self, weight: float) -> float:
return 0
Контекст:
class DeliveryService:
def __init__(self, strategy: DeliveryStrategy):
self.strategy = strategy
def calculate_price(self, weight: float) -> float:
return self.strategy.calculate(weight)
Использование:
service = DeliveryService(StandardDelivery())
print(service.calculate_price(3.5))
service = DeliveryService(ExpressDelivery())
print(service.calculate_price(3.5))
Более практичный вариант с выбором стратегии #
В реальном коде часто используют словарь стратегий:
class DeliveryStrategyFactory:
strategies = {
"standard": StandardDelivery(),
"express": ExpressDelivery(),
"pickup": PickupDelivery(),
}
@classmethod
def get_strategy(cls, delivery_type: str) -> DeliveryStrategy:
try:
return cls.strategies[delivery_type]
except KeyError:
raise ValueError("Unknown delivery type")
Использование:
delivery_type = "express"
strategy = DeliveryStrategyFactory.get_strategy(delivery_type)
service = DeliveryService(strategy)
price = service.calculate_price(weight=5)
print(price)
Такой подход лучше, чем большой if/elif, потому что добавление новой стратегии не требует переписывать основной сервис.
Отличие от обычного if/else #
if/else:
- Один класс знает обо всех вариантах поведения. Strategy:
- Каждый вариант поведения живет в отдельном классе.
- Контекст работает через общий интерфейс.
Главное отличие не в том, что if/else полностью исчезает. Иногда выбор стратегии все равно где-то происходит. Главное — бизнес-алгоритмы разделены и не смешаны в одном большом методе.
Плюсы
- Убирает большие if/elif/switch.
- Упрощает добавление новых алгоритмов.
- Делает код расширяемым.
- Упрощает тестирование каждой стратегии отдельно.
- Разделяет разные варианты поведения по отдельным классам.
Минусы
- Увеличивает количество классов.
- Может быть избыточен для простой логики.
- Клиентский код должен понимать, какую стратегию выбрать.
- Иногда нужен дополнительный factory/registry для выбора стратегии.
Strategy и Dependency Injection #
Strategy часто используется вместе с DI.
Например:
class ReportService:
def __init__(self, export_strategy):
self.export_strategy = export_strategy
def export(self, data):
return self.export_strategy.export(data)
Можно подставить:
PdfExportStrategy()
ExcelExportStrategy()
CsvExportStrategy()
То есть ReportService не зависит от конкретного формата. Он зависит только от интерфейса стратегии.
9. Паттерн Наблюдатель #
Наблюдатель — это поведенческий паттерн проектирования, который задает связь «один ко многим»: когда один объект меняет состояние, все подписанные на него объекты получают уведомление. Этот паттерн также часто называют Observer, Listener, Event-Subscriber
Простая идея #
Есть объект, за которым следят:
Subject / Publisher / Observable
И есть объекты, которые хотят получать уведомления:
Observers / Subscribers / Listeners
Когда Subject меняется, он вызывает метод у всех наблюдателей.
Subject
├── Observer A
├── Observer B
└── Observer C
Пример:
YouTube-канал = Subject
Подписчики = Observers
Канал выпускает видео -> подписчики получают уведомление
Проблема без паттерна #
Допустим, после создания заказа нужно:
- Отправить email.
- Отправить push-уведомление.
- Записать событие в лог.
- Обновить аналитику.
Плохой вариант:
class OrderService:
def create_order(self, user_id: int, product_id: int):
order = {
"user_id": user_id,
"product_id": product_id,
"status": "created",
}
self.send_email(order)
self.send_push(order)
self.write_log(order)
self.update_analytics(order)
return order
def send_email(self, order):
print("Send email")
def send_push(self, order):
print("Send push")
def write_log(self, order):
print("Write log")
def update_analytics(self, order):
print("Update analytics")
Проблема:OrderService знает слишком много. Он отвечает не только за создание заказа, но и за email, push, логи, аналитику. При добавлении нового действия придется менять сам OrderService.
Решение через Observer #
Выносим реакции на событие в отдельные классы.
Сначала создаем общий интерфейс наблюдателя:
from abc import ABC, abstractmethod
class Observer(ABC):
@abstractmethod
def update(self, event: dict) -> None:
pass
Конкретные наблюдатели:
class EmailObserver(Observer):
def update(self, event: dict) -> None:
print(f"Send email for event: {event}")
class PushObserver(Observer):
def update(self, event: dict) -> None:
print(f"Send push for event: {event}")
class LogObserver(Observer):
def update(self, event: dict) -> None:
print(f"Write log for event: {event}")
Теперь объект, за которым наблюдают:
class Subject:
def __init__(self):
self._observers: list[Observer] = []
def attach(self, observer: Observer) -> None:
self._observers.append(observer)
def detach(self, observer: Observer) -> None:
self._observers.remove(observer)
def notify(self, event: dict) -> None:
for observer in self._observers:
observer.update(event)
Сервис заказов:
class OrderService(Subject):
def create_order(self, user_id: int, product_id: int):
order = {
"user_id": user_id,
"product_id": product_id,
"status": "created",
}
self.notify({
"type": "order_created",
"payload": order,
})
return order
Использование:
order_service = OrderService()
order_service.attach(EmailObserver())
order_service.attach(PushObserver())
order_service.attach(LogObserver())
order_service.create_order(user_id=1, product_id=100)
Результат:
Send email for event: {'type': 'order_created', ...}
Send push for event: {'type': 'order_created', ...}
Write log for event: {'type': 'order_created', ...}
Структура паттерна #
Subject
- хранит список observers
- attach(observer)
- detach(observer)
- notify(event)
Observer
- update(event)
ConcreteObserver
- EmailObserver
- PushObserver
- LogObserver
Microsoft описывает Observer как схему, где подписчик регистрируется у поставщика и получает от него push-уведомления; это подходит для сценариев, где объект должен уведомлять другие компоненты об изменениях.
Где применяется #
Observer хорошо подходит, когда после одного события должны автоматически запускаться разные независимые реакции.
Примеры:
Пользователь зарегистрировался:
- отправить email
- создать профиль
- записать аудит
Заказ создан:
- отправить уведомление
- списать товар
- обновить статистику
Файл загружен:
- проверить антивирусом
- создать превью
- отправить событие в аналитику
Изменилось состояние объекта:
- обновить UI
- пересчитать данные
- уведомить подписчиков
Backend-пример через события #
В backend чаще используют не названия Subject и Observer, а Event и Handler.
from dataclasses import dataclass
from typing import Protocol
@dataclass
class OrderCreatedEvent:
order_id: int
user_id: int
class EventHandler(Protocol):
def handle(self, event: OrderCreatedEvent) -> None:
...
class SendEmailHandler:
def handle(self, event: OrderCreatedEvent) -> None:
print(f"Send email for order {event.order_id}")
class WriteAuditLogHandler:
def handle(self, event: OrderCreatedEvent) -> None:
print(f"Write audit log for order {event.order_id}")
class EventDispatcher:
def __init__(self):
self._handlers: dict[type, list[EventHandler]] = {}
def register(self, event_type: type, handler: EventHandler) -> None:
self._handlers.setdefault(event_type, []).append(handler)
def dispatch(self, event) -> None:
for handler in self._handlers.get(type(event), []):
handler.handle(event)
Использование:
dispatcher = EventDispatcher()
dispatcher.register(OrderCreatedEvent, SendEmailHandler())
dispatcher.register(OrderCreatedEvent, WriteAuditLogHandler())
event = OrderCreatedEvent(order_id=10, user_id=5)
dispatcher.dispatch(event)
Такой вариант часто ближе к реальному backend-коду, чем классический учебный пример с Subject и Observer.
Отличие от Strategy #
Strategy выбирает один алгоритм из нескольких:
Оплатить картой
или оплатить PayPal
или оплатить бонусами
Observer уведомляет несколько объектов о событии:
Заказ создан
и нужно выполнить email
и push
и логирование
и аналитику
Отличие от Pub/Sub #
Observer и Pub/Sub похожи, но не одно и то же.
В классическом Observer объект-источник обычно знает своих наблюдателей напрямую:
Subject -> Observer A
Subject -> Observer B
В Pub/Sub часто есть посредник: брокер, event bus, message broker.
Publisher -> Event Bus / Broker -> Subscribers
Пример Pub/Sub:
Service A -> Kafka / RabbitMQ / Redis Pub/Sub -> Service B, Service C
Observer чаще используется внутри одного приложения или процесса, а Pub/Sub чаще применяют для более слабой связности между частями системы или между сервисами. Microsoft отдельно рассматривает Observer и Publish-Subscribe как близкие, но разные подходы к уведомлению зависимых компонентов.
Плюсы
- Уменьшает связанность между объектами.
- Позволяет добавлять новые реакции без изменения основного класса.
- Удобен для событийной логики.
- Хорошо подходит для уведомлений, UI, аудита, аналитики.
- Помогает соблюдать Open/Closed Principle.
Минусы
- Сложнее отслеживать порядок выполнения observers.
- Может быть трудно дебажить цепочку событий.
- Один observer может замедлить всю операцию.
- Ошибка в одном observer может сломать уведомление остальных.
- При неаккуратной реализации можно забыть отписать observer и получить утечки памяти.
Когда использовать #
Паттерн стоит использовать, когда один объект должен уведомлять несколько других объектов, но не должен жестко зависеть от их конкретных классов.
Хорошие случаи:
- После одного действия нужно выполнить несколько независимых реакций.
- Нужно подписывать и отписывать обработчики во время выполнения.
- Нужно убрать лишние зависимости из основного сервиса.
- Нужно сделать событийную модель внутри приложения.
Когда не стоит использовать:
- Есть только одно простое действие после события.
- Логика строго последовательная и зависит от результата каждого шага.
- Важно явно видеть весь сценарий в одном use case.
- Проект маленький, и Observer только усложнит код.
10. Паттерн Репозиторий #
Репозиторий — это паттерн, который отделяет бизнес-логику от деталей работы с базой данных. По определению Мартина Фаулера, Repository выступает посредником между доменной логикой и слоем маппинга данных, создавая ощущение, что работа идет с коллекцией объектов в памяти.
Идейно:
Service / Use Case
|
v
Repository
|
v
Database / ORM / SQL / API / File storage
Какую проблему решает #
Без репозитория бизнес-логика часто начинает напрямую зависеть от ORM или SQL:
class UserService:
async def get_user_profile(self, user_id: int):
user = await User.filter(id=user_id).first()
if not user:
raise ValueError("User not found")
return {
"id": user.id,
"username": user.username,
}
Проблема в том, что UserService теперь знает, как именно данные достаются из базы:
- какая ORM используется;
- как пишутся запросы;
- какие методы есть у модели;
- как устроена таблица;
- как обрабатываются ошибки БД. Репозиторий выносит эту логику отдельно.
Вариант с репозиторием #
class UserRepository:
async def get_by_id(self, user_id: int):
return await User.filter(id=user_id).first()
Сервис:
class UserService:
def __init__(self, user_repository: UserRepository):
self.user_repository = user_repository
async def get_user_profile(self, user_id: int):
user = await self.user_repository.get_by_id(user_id)
if not user:
raise ValueError("User not found")
return {
"id": user.id,
"username": user.username,
}
Теперь UserService не знает, как именно достается пользователь. Он просто просит репозиторий:
Дай мне пользователя по id.
А репозиторий уже решает, как это сделать.
Основная идея #
Репозиторий инкапсулирует доступ к данным. Microsoft описывает Repository и Unit of Work как способ создать абстрактный слой между бизнес-логикой и доступом к данным, чтобы изолировать приложение от изменений в хранилище и упростить тестирование.
Структура паттерна #
Service / Use Case
|
v
Repository Interface
|
v
Concrete Repository
|
v
ORM / SQL / Database
Пример:
from typing import Protocol
class UserRepositoryProtocol(Protocol):
async def get_by_id(self, user_id: int):
...
async def get_by_username(self, username: str):
...
async def create(self, username: str, password_hash: str):
...
Реализация через ORM:
class TortoiseUserRepository:
async def get_by_id(self, user_id: int):
return await User.filter(id=user_id).first()
async def get_by_username(self, username: str):
return await User.filter(username=username).first()
async def create(self, username: str, password_hash: str):
return await User.create(
username=username,
password_hash=password_hash,
)
Сервис зависит от интерфейса, а не от конкретной ORM:
class AuthService:
def __init__(self, user_repository: UserRepositoryProtocol):
self.user_repository = user_repository
async def register(self, username: str, password_hash: str):
existing_user = await self.user_repository.get_by_username(username)
if existing_user:
raise ValueError("Username already exists")
return await self.user_repository.create(
username=username,
password_hash=password_hash,
)
Что должно быть в Repository #
В репозитории обычно лежит логика получения и сохранения данных:
get_by_id()
get_by_username()
create()
update()
delete()
list_by_user()
exists_by_email()
Например:
class NotificationRepository:
async def create_notification(self, user_id: int, type_: str, text: str):
return await Notification.create(
user_id=user_id,
type=type_,
text=text,
)
async def list_user_notifications(
self,
user_id: int,
limit: int,
offset: int,
):
return await Notification.filter(user_id=user_id).offset(offset).limit(limit)
async def delete_user_notification(self, user_id: int, notification_id: int):
deleted_count = await Notification.filter(
id=notification_id,
user_id=user_id,
).delete()
return deleted_count > 0
Что не должно быть в Repository #
Репозиторий не должен превращаться в сервис с бизнес-логикой.
Плохо:
class UserRepository:
async def register_user(self, username: str, password: str):
if len(password) < 8:
raise ValueError("Password too short")
password_hash = hash_password(password)
user = await User.create(
username=username,
password_hash=password_hash,
)
await send_email(user)
return user
Здесь смешались:
- валидация;
- хеширование пароля;
- создание пользователя;
- отправка email.
Лучше:
Service:
- проверяет бизнес-правила;
- вызывает нужные репозитории;
- управляет сценарием.
Repository:
- читает данные;
- сохраняет данные;
- скрывает ORM/SQL.
Repository и ORM — это не одно и то же #
ORM уже дает доступ к БД:
User.objects.get(id=1)
User.filter(id=1).first()
Но ORM — это технический инструмент. Repository — это архитектурная прослойка над доступом к данным.
ORM отвечает: как работать с таблицами и объектами.
Repository отвечает: какие операции с данными нужны приложению.
Например, ORM говорит:
filter, select_related, prefetch_related, annotate
Репозиторий говорит:
get_active_users()
get_user_with_profile()
list_user_notifications()
exists_by_email()
Repository и Service Layer #
Разница такая:
Service Layer — бизнес-сценарии.
Repository — доступ к данным.
Пример:
class OrderService:
def __init__(
self,
order_repository,
product_repository,
):
self.order_repository = order_repository
self.product_repository = product_repository
async def create_order(self, user_id: int, product_id: int, quantity: int):
product = await self.product_repository.get_by_id(product_id)
if not product:
raise ValueError("Product not found")
if product.stock < quantity:
raise ValueError("Not enough stock")
return await self.order_repository.create(
user_id=user_id,
product_id=product_id,
quantity=quantity,
)
Здесь:
OrderServiceрешает бизнес-задачу: создать заказ.ProductRepositoryдостает товар.OrderRepositoryсохраняет заказ.
Repository и Unit of Work #
Repository часто используют вместе с Unit of Work. Repository отвечает за операции с конкретными сущностями, а Unit of Work — за транзакцию и согласованное сохранение изменений. Microsoft описывает Repository и Unit of Work как паттерны, которые помогают инкапсулировать infrastructure persistence layer и отделить его от application/domain слоев.
Пример идеи:
UnitOfWork
├── user_repository
├── order_repository
└── commit / rollback
Условно:
class UnitOfWork:
def __init__(self, session):
self.session = session
self.users = UserRepository(session)
self.orders = OrderRepository(session)
async def commit(self):
await self.session.commit()
async def rollback(self):
await self.session.rollback()
Сервис:
class OrderService:
def __init__(self, uow: UnitOfWork):
self.uow = uow
async def create_order(self, user_id: int, product_id: int):
try:
user = await self.uow.users.get_by_id(user_id)
if not user:
raise ValueError("User not found")
order = await self.uow.orders.create(user_id, product_id)
await self.uow.commit()
return order
except Exception:
await self.uow.rollback()
raise
Плюсы
- Бизнес-логика не зависит напрямую от ORM.
- Код проще тестировать через mock/fake repository.
- Легче менять способ хранения данных.
- Запросы к данным собраны в одном месте.
- Сервисы становятся чище.
- Удобно использовать вместе с Clean Architecture, DDD, CQRS.
Минусы
- Больше классов и файлов.
- Может дублировать возможности ORM.
- В простом CRUD может быть лишней абстракцией.
- При плохом дизайне превращается в набор методов на все случаи жизни.
- Слишком общий GenericRepository часто становится бесполезным.
Плохой Generic Repository #
Частая ошибка — сделать универсальный репозиторий для всего:
class GenericRepository:
async def get(self, model, id):
...
async def create(self, model, data):
...
async def update(self, model, id, data):
...
async def delete(self, model, id):
...
Формально это репозиторий, но пользы мало. Он просто повторяет ORM.
Лучше делать репозитории под конкретную сущность или агрегат:
class UserRepository:
async def get_by_email(self, email: str):
...
async def exists_by_username(self, username: str):
...
class OrderRepository:
async def get_user_orders(self, user_id: int):
...
async def get_unpaid_orders(self):
...
Когда использовать #
Repository уместен, когда:
- Есть слой сервисов/use cases.
- Есть бизнес-логика, которую не хочется смешивать с ORM.
- Нужно тестировать сервисы без настоящей БД.
- Есть сложные запросы, которые нужно централизовать.
- Проект растет и прямые ORM-вызовы размазываются по коду.
- Используется DDD, Clean Architecture или CQRS.
Когда не использовать #
Repository может быть лишним, когда:
- Маленький CRUD-проект.
- Вся логика — простые create/read/update/delete.
- ORM уже достаточно хорошо закрывает задачу.
- Репозиторий ничего не добавляет, а только проксирует ORM.
Плохой репозиторий:
class UserRepository:
async def get_by_id(self, id: int):
return await User.get(id=id)
Если в проекте только такие методы, пользы почти нет.
Более полезный репозиторий:
class UserRepository:
async def get_active_user_with_profile(self, user_id: int):
return await User.filter(
id=user_id,
is_active=True,
).prefetch_related("profile").first()
Здесь репозиторий уже скрывает конкретные детали запроса.
11. Архитектура Stateful и Stateless — в чём различия и почему стремимся к Stateless #
Stateful и Stateless #
Stateful и Stateless — это два подхода к тому, хранит ли приложение состояние между запросами.
HTTP сам по себе является stateless-протоколом: каждый запрос независим, а связь между запросами обычно добавляют через cookies, токены или серверные сессии.
Stateless #
Stateless-сервис не хранит пользовательское состояние внутри конкретного экземпляра приложения.
То есть сервер не должен помнить:
пользователь X авторизован именно на этом сервере;
корзина пользователя лежит в памяти этого процесса;
следующий запрос этого пользователя должен попасть на тот же инстанс.
Пример stateless API:
Client -> Request with JWT -> App instance A -> Database
Client -> Request with JWT -> App instance B -> Database
Client -> Request with JWT -> App instance C -> Database
Каждый запрос содержит всё, что нужно для обработки: токен, параметры, body, headers. Данные, которые нужно хранить долго, лежат не в памяти приложения, а во внешнем хранилище: PostgreSQL, Redis, S3, Elasticsearch и т.д.
Пример:
@app.get("/profile")
async def get_profile(user=Depends(get_current_user)):
return await user_repository.get_by_id(user.id)
Здесь приложение не хранит сессию пользователя в памяти. Оно получает пользователя из токена и достает данные из БД.
Stateful #
Stateful-сервис хранит состояние между запросами внутри конкретного экземпляра приложения.
Например:
App instance A хранит session_id -> user_id в своей памяти
App instance B этого состояния не знает
App instance C этого состояния не знает
Проблема:
1-й запрос пользователя попал на instance A.
2-й запрос попал на instance B.
B не знает состояние пользователя.
Пользователь получает ошибку или теряет сессию.
Чтобы это работало, часто приходится использовать sticky sessions/session affinity, то есть балансировщик должен направлять одного пользователя на один и тот же сервер.
Главное различие #
| Критерий | Stateless | Stateful |
|---|---|---|
| Хранит состояние в приложении | Нет | Да |
| Запросы независимы | Да | Нет |
| Можно отправить запрос на любой инстанс | Да | Обычно нет |
| Масштабирование | Проще | Сложнее |
| Отказоустойчивость | Выше | Ниже, если состояние только в памяти |
| Пример | REST API с JWT | Серверные сессии в памяти процесса |
| Где хранится состояние | В БД, Redis, S3, токене | В конкретном процессе/сервере |
Почему стремимся к Stateless #
Главная причина — проще масштабировать и поддерживать систему.
AWS Well-Architected прямо рекомендует делать приложения stateless, где это возможно, потому что stateless-приложения лучше горизонтально масштабируются и устойчивее к отказу отдельного узла.
1. Проще горизонтальное масштабирование #
Stateless:
Было 2 инстанса.
Добавили еще 5.
Любой запрос может попасть на любой инстанс.
Stateful:
Нужно помнить, на каком сервере лежит состояние пользователя.
Нужны sticky sessions.
Нужно думать, что делать при падении конкретного сервера.
Azure Well-Architected также рекомендует по возможности проектировать приложения stateless, а состояние выносить во внешнее хранилище.
2. Проще балансировка нагрузки #
При Stateless балансировщик может распределять запросы свободно:
Request 1 -> App A
Request 2 -> App B
Request 3 -> App C
При Stateful балансировщик часто вынужден привязывать пользователя к конкретному серверу:
User 1 -> всегда App A
User 2 -> всегда App B
Это хуже, потому что один сервер может быть перегружен, а другой простаивать.
3. Проще переживать падение сервера #
Stateless:
App A упал.
Следующий запрос ушел на App B.
Данные не потеряны, потому что они в БД/Redis/S3.
Stateful:
App A упал.
Состояние, которое было только в памяти App A, потеряно.
Это особенно важно в Docker/Kubernetes, где контейнеры могут перезапускаться, переноситься между нодами и масштабироваться автоматически.
4. Проще деплой и обновления #
Stateless-инстанс можно спокойно удалить и заменить новым:
Старый контейнер остановили.
Новый контейнер подняли.
Пользовательское состояние не потерялось.
С Stateful сложнее: нужно мигрировать состояние, аккуратно завершать сессии или обеспечивать репликацию.
5. Лучше подходит для cloud-native архитектуры #
В облачной архитектуре инстансы приложения часто считаются временными. Поэтому состояние стараются хранить вне приложения: в базе данных, кэше, message broker, object storage. Red Hat также описывает основное различие так: stateful-приложения сохраняют прошлую и текущую информацию, а stateless — нет.
Важное уточнение #
Stateless не означает, что состояния нет вообще.
Состояние есть почти всегда:
пользователи;
заказы;
корзины;
файлы;
платежи;
уведомления;
сессии;
настройки.
Но в stateless-архитектуре это состояние не хранится внутри конкретного app-инстанса.
Когда Stateful нужен #
Stateful — не всегда плохо. Он нужен там, где само состояние является частью работы системы.
Примеры:
1. WebSocket-соединения.
2. Онлайн-игры.
3. Чаты в реальном времени.
4. Стриминг.
5. Stateful workers.
6. Базы данных.
7. Очереди сообщений.
8. Сервисы с долгими транзакциями или in-memory processing.
Например, WebSocket-сервер держит открытое соединение с клиентом. Это уже stateful-поведение.
Главная мысль #
Мы стремимся не к тому, чтобы «у системы не было состояния», а к тому, чтобы app-сервисы не хранили критичное состояние локально.
Хорошая схема:
Client
|
v
Load Balancer
|
v
App instance A / B / C <- stateless
|
v
External state:
PostgreSQL / Redis / S3 / Kafka
Плохая схема для масштабирования:
Client
|
v
Load Balancer
|
v
App instance A хранит session/user/cart в памяти
App instance B ничего об этом не знает
App instance C ничего об этом не знает
12. Что такое луковичная архитектура? (Onion architecture) #
Луковичная архитектура, или Onion Architecture, — это архитектурный подход, в котором бизнес-логика находится в центре приложения, а технические детали располагаются во внешних слоях.
Главное правило:
Зависимости направлены только внутрь.
Внутренние слои не знают о внешних.
То есть доменная логика не должна зависеть от:
- FastAPI / Django / Flask;
- PostgreSQL;
- SQLAlchemy / Django ORM / Tortoise ORM;
- Redis;
- Kafka / RabbitMQ;
- внешних API;
- файловой системы;
- UI.
Оригинально Onion Architecture была описана Jeffrey Palermo. Основное правило у него формулируется так: код может зависеть от более центральных слоев, но не от внешних; вся связанность направлена к центру.
Схема слоев #
Упрощенно:
[ Infrastructure ]
[ Presentation / API ]
[ Application Services / Use Cases ]
[ Domain Model ]
Или в виде направления зависимостей:
Infrastructure ---> Application ---> Domain
API ---> Application ---> Domain
Database ---> Application ---> Domain
Но не наоборот:
Domain -X-> Database
Domain -X-> FastAPI
Domain -X-> SQLAlchemy
Application -X-> конкретная PostgreSQL-реализация
Центр — Domain #
В центре находится домен: бизнес-сущности, value objects, доменные правила.
Например:
class Order:
def __init__(self, user_id: int, items: list):
if not items:
raise ValueError("Order must contain at least one item")
self.user_id = user_id
self.items = items
self.status = "created"
def cancel(self):
if self.status == "paid":
raise ValueError("Paid order cannot be cancelled")
self.status = "cancelled"
Этот класс не должен знать, где он хранится: в PostgreSQL, MongoDB, Redis или файле. Это обычная бизнес-логика.
Следующий слой — Application #
Application layer содержит use cases, то есть сценарии приложения:
- создать заказ;
- отменить заказ;
- зарегистрировать пользователя;
- отправить уведомление;
- загрузить файл;
- получить список заказов.
Пример:
class CreateOrderUseCase:
def __init__(self, order_repository, product_repository):
self.order_repository = order_repository
self.product_repository = product_repository
async def execute(self, user_id: int, product_ids: list[int]):
products = await self.product_repository.get_by_ids(product_ids)
if len(products) != len(product_ids):
raise ValueError("Some products not found")
order = Order(user_id=user_id, items=products)
await self.order_repository.save(order)
return order
Здесь use case управляет сценарием, но не знает, как именно order_repository работает с базой.
Важный момент: интерфейсы внутрь, реализации наружу #
Внутренний слой может объявить интерфейс:
from typing import Protocol
class OrderRepository(Protocol):
async def save(self, order: Order) -> None:
...
async def get_by_id(self, order_id: int) -> Order | None:
...
А внешний слой дает реализацию:
class SqlAlchemyOrderRepository:
def __init__(self, session):
self.session = session
async def save(self, order: Order) -> None:
# SQLAlchemy-specific code
...
async def get_by_id(self, order_id: int) -> Order | None:
# SQLAlchemy-specific code
...
То есть Application зависит от абстракции, а Infrastructure реализует эту абстракцию.
Infrastructure layer #
Infrastructure — это внешний слой. Там находятся технические реализации:
- SQLAlchemyOrderRepository;
- DjangoUserRepository;
- RedisTokenStorage;
- S3FileStorage;
- KafkaEventProducer;
- EmailSender;
- HTTP-клиенты к внешним сервисам.
Пример:
class RedisTokenStorage:
def __init__(self, redis):
self.redis = redis
async def save_refresh_token(self, user_id: int, token: str):
await self.redis.set(f"refresh:{user_id}", token)
Application layer может знать только интерфейс:
class TokenStorage(Protocol):
async def save_refresh_token(self, user_id: int, token: str):
...
А Redis — это уже деталь реализации.
Presentation layer #
Presentation — это слой входа в приложение:
- FastAPI routers;
- Django views;
- DRF ViewSet;
- CLI-команды;
- Celery tasks;
- GraphQL resolvers.
Пример с FastAPI:
@router.post("/orders")
async def create_order(
data: CreateOrderRequest,
use_case: CreateOrderUseCase = Depends(get_create_order_use_case),
):
order = await use_case.execute(
user_id=data.user_id,
product_ids=data.product_ids,
)
return {"id": order.id, "status": order.status}
Роутер не должен содержать бизнес-логику. Его задача:
- принять HTTP-запрос;
- провалидировать input DTO;
- вызвать use case;
- вернуть response DTO.
Почему называется «луковичная» #
Потому что приложение представляется как слои вокруг центра:
Infrastructure
---------------------
Presentation / API
-------------------------
Application / Use Cases
----------------------------
Domain
Центр — самый важный и стабильный слой.
Внешние слои можно менять:
FastAPI заменить на Django;
PostgreSQL заменить на MongoDB;
Redis заменить на другой cache;
REST заменить на GraphQL.
Но доменная логика должна остаться максимально неизменной.
Главное преимущество #
Главное преимущество Onion Architecture — независимость бизнес-логики от технических деталей.
Например, плохо:
class OrderService:
async def create_order(self, user_id: int):
order = await OrderModel.create(user_id=user_id)
return order
Здесь сервис напрямую зависит от ORM-модели.
Лучше:
class OrderService:
def __init__(self, order_repository: OrderRepository):
self.order_repository = order_repository
async def create_order(self, user_id: int):
order = Order(user_id=user_id)
await self.order_repository.save(order)
return order
Теперь бизнес-сценарий не привязан к ORM.
Отличие от обычной слоистой архитектуры #
Классическая layered architecture часто выглядит так:
Controller -> Service -> Repository -> Database
Проблема: зависимости часто идут сверху вниз, и бизнес-логика зависит от инфраструктуры.
Service зависит от Repository
Repository зависит от ORM
ORM зависит от Database
В Onion Architecture зависимость переворачивается:
Application зависит от интерфейса Repository
Infrastructure реализует этот интерфейс
То есть не бизнес-логика подстраивается под базу, а база становится внешней деталью.
Microsoft также описывает Repository как способ вынести persistence concerns за пределы доменной модели; интерфейсы репозиториев определяются рядом с доменной моделью, а конкретные реализации находятся в инфраструктуре.
Плюсы
- Бизнес-логика не зависит от фреймворка.
- Проще тестировать use cases без реальной БД.
- Можно заменить ORM или базу с меньшими изменениями.
- Код лучше разделен по ответственности.
- Хорошо сочетается с DDD, Repository, Unit of Work, CQRS.
- Проще держать сложную бизнес-логику отдельно от инфраструктуры.
Минусы
- Больше файлов и абстракций.
- Для простого CRUD может быть избыточной.
- Нужно дисциплинированно следить за направлением импортов.
- Разработчики могут начать делать лишние интерфейсы без пользы.
- Порог входа выше, чем у обычного Controller-Service-Repository.
Когда использовать
- проект не просто CRUD;
- есть сложная бизнес-логика;
- приложение будет расти;
- важны тесты;
- нужно отделить домен от фреймворка и БД;
- есть несколько способов входа: API, Celery, CLI;
- используется DDD или Clean Architecture.
Когда не использовать
- маленький CRUD-проект;
- логика почти вся состоит из create/read/update/delete;
- проект временный;
- команда не готова поддерживать архитектурные границы;
- абстракции только усложняют код.
Onion Architecture, Clean Architecture и Hexagonal Architecture #
Эти подходы очень близки. Все они строятся вокруг одной идеи: бизнес-логика должна быть в центре, а инфраструктура — снаружи.
Microsoft в документации по веб-архитектурам прямо отмечает, что архитектуры, следующие Dependency Inversion и DDD, со временем назывались Hexagonal Architecture, Ports and Adapters, Onion Architecture и Clean Architecture.
Разница больше в терминологии и акцентах:
| Подход | Акцент |
|---|---|
| Onion Architecture | Слои вокруг домена, зависимости внутрь |
| Clean Architecture | Use cases, entities, interface adapters, frameworks |
| Hexagonal Architecture | Ports and adapters, входные и выходные адаптеры |
13. Какие архитектурные решения вы знаете? #
Архитектурные решения #
Архитектурные решения — это не только «монолит или микросервисы». Это выборы, которые определяют структуру системы: как разделять код, как хранить данные, как сервисы общаются, как масштабироваться, как обрабатывать ошибки и как обеспечивать надежность.
1. Монолитная архитектура #
Монолит — приложение, где основная логика собрана в одном deployable unit.
Пример:
Django / FastAPI app
├── users
├── orders
├── payments
└── notifications
Deploy: один сервис
Database: чаще одна БД
Плюсы:
- проще разработка на старте;
- проще деплой;
- проще транзакции;
- меньше сетевой сложности;
- легче отлаживать.
Минусы:
- при росте может стать сложно поддерживать;
- один большой кодbase;
- сложнее независимо масштабировать части системы;
- изменения в одной области могут ломать другие.
Подходит для большинства стартапов, MVP, CRUD-систем и проектов без огромной нагрузки.
2. Модульный монолит #
Это монолит, но внутри он разделен на четкие модули.
app/
users/
orders/
payments/
notifications/
Главная идея: деплой один, но границы внутри кода строгие.
Например:
orders не лезет напрямую в таблицы payments
orders вызывает application/service интерфейс payments
Это часто лучший вариант до микросервисов: меньше сложности, чем у микросервисов, но архитектура уже не превращается в хаос.
3. Микросервисная архитектура #
Микросервисы — система из нескольких независимых сервисов, каждый отвечает за свою бизнес-область.
User Service
Order Service
Payment Service
Notification Service
Каждый сервис может иметь свою БД, свой deploy, свою команду и свой цикл релизов.
Плюсы:
- независимое масштабирование;
- независимый деплой;
- изоляция ответственности;
- можно использовать разные технологии;
- проще разделять команды по доменным областям.
Минусы:
- сложнее инфраструктура;
- сетевые ошибки;
- distributed transactions;
- сложнее логирование и трассировка;
- нужна зрелая DevOps-культура.
CNCF связывает cloud-native подход с масштабируемыми приложениями в динамических средах вроде public/private/hybrid cloud, а микросервисы обычно рассматриваются как один из ключевых подходов в такой среде.
Микросервисы не стоит брать только потому, что это «модно». Для маленькой команды и простой системы они часто создают больше проблем, чем пользы.
4. Layered Architecture / N-tier #
Классическая слоистая архитектура:
Controller / API
Service
Repository
Database
Пример
FastAPI Router -> UserService -> UserRepository -> PostgreSQL
Плюсы:
- понятная структура;
- легко объяснить команде;
- хорошо подходит для CRUD;
- удобно тестировать по слоям.
Минусы:
- бизнес-логика может размазаться между слоями;
- сервисы могут стать огромными;
- при плохом дизайне всё превращается в Controller-Service-Repository без настоящих границ.
Martin Fowler в Patterns of Enterprise Application Architecture отдельно выделяет темы слоев, доменной логики, связи с реляционной БД и web presentation как ключевые области enterprise-архитектуры.
5. Clean Architecture / Onion Architecture / Hexagonal Architecture #
Это архитектуры, где бизнес-логика находится в центре, а внешние детали находятся снаружи.
Infrastructure -> Application -> Domain
Presentation -> Application -> Domain
Главное правило:
зависимости направлены внутрь
То есть домен не должен зависеть от:
- FastAPI;
- Django;
- SQLAlchemy;
- PostgreSQL;
- Redis;
- Kafka;
- внешних API.
Пример:
UseCase зависит от интерфейса UserRepository
SQLAlchemyUserRepository реализует этот интерфейс во внешнем слое
Подходит, когда:
- есть сложная бизнес-логика;
- важны тесты;
- проект будет расти;
- нужно отделить бизнес-правила от фреймворка и БД.
Не подходит, когда:
- маленький CRUD;
- нет сложной логики;
- абстракции только увеличивают количество файлов.
6. Event-driven architecture #
Event-driven architecture — архитектура, где части системы общаются через события.
OrderCreated
PaymentCompleted
UserRegistered
FileUploaded
Пример:
Order Service -> OrderCreated event -> Notification Service
-> Analytics Service
-> Warehouse Service
AWS описывает event-driven architecture как подход, состоящий из event sources, event routers и event destinations.
Плюсы:
- слабая связанность сервисов;
- удобно добавлять новые реакции на событие;
- хорошо подходит для асинхронной обработки;
- повышает масштабируемость.
Минусы:
- сложнее отладка;
- eventual consistency;
- нужно думать об идемпотентности;
- возможны дубли событий;
- сложнее понимать полный flow.
Используется с:
Kafka
RabbitMQ
Redis Streams
AWS EventBridge
NATS
7. CQRS #
CQRS — разделение операций чтения и записи.
Command -> меняет состояние
Query -> только читает данные
Microsoft описывает CQRS как паттерн, который разделяет read и write операции на разные модели, что позволяет независимо оптимизировать производительность, масштабируемость и безопасность.
Пример:
CreateOrderCommand
CancelOrderCommand
GetOrderQuery
ListOrdersQuery
SearchOrdersQuery
Подходит, когда:
- чтение и запись сильно отличаются;
- много сложных фильтров;
- нужны read models под UI;
- тяжелые отчеты и дашборды;
- высокая нагрузка на чтение.
Но Martin Fowler предупреждает, что CQRS полезен не всегда и для большинства систем может добавить рискованную сложность.
8. SOA #
SOA — Service-Oriented Architecture. Это более старший родственник микросервисов: система строится вокруг сервисов, которые предоставляют бизнес-возможности.
Billing Service
Customer Service
Inventory Service
Отличие от микросервисов обычно в том, что SOA часто крупнее по гранулярности и исторически чаще использовала ESB — Enterprise Service Bus.
Сейчас в backend чаще говорят именно про микросервисы, но SOA как подход всё еще встречается в крупных enterprise-системах.
9. Serverless architecture #
Serverless — подход, где разработчик пишет функции или сервисы, а управление серверами берет на себя платформа.
Пример:
HTTP request -> API Gateway -> Lambda Function -> Database
Плюсы:
- не нужно управлять серверами;
- автоматическое масштабирование;
- оплата часто за использование;
- удобно для событийных задач.
Минусы:
- cold start;
- vendor lock-in;
- ограничения runtime;
- сложнее локальная отладка;
- не всегда подходит для долгих процессов.
10. Microkernel / Plugin architecture #
Архитектура ядра и плагинов.
Core system
├── plugin A
├── plugin B
└── plugin C
Примеры:
IDE
CMS
игровые движки
браузеры
системы с расширениями
Плюсы:
- легко добавлять расширения;
- ядро остается стабильным;
- плагины можно разрабатывать отдельно.
Минусы:
- нужно продумать API плагинов;
- сложнее безопасность;
- сложнее совместимость версий.
11. Pipes and Filters #
Архитектура конвейера обработки данных.
Input -> Filter 1 -> Filter 2 -> Filter 3 -> Output
Пример:
загрузить файл
-> проверить формат
-> распарсить
-> провалидировать
-> сохранить
-> отправить уведомление
Хорошо подходит для:
- ETL;
- обработки файлов;
- data pipelines;
- медиа-обработки;
- компиляции;
- цепочек валидации.
12. MVC / MVT #
MVC:
Model
View
Controller
MVT в Django:
Model
View
Template
Это архитектурный подход для разделения UI, данных и логики обработки запроса.
В backend часто выглядит так:
URL -> View/Controller -> Model/Service -> Response
Подходит для web-приложений, админок, сайтов, CRUD-систем.
13. BFF — Backend for Frontend #
BFF — отдельный backend под конкретный frontend.
Mobile App -> Mobile BFF -> Services
Web App -> Web BFF -> Services
Admin -> Admin BFF -> Services
Зачем нужен:
- разные клиенты требуют разные DTO;
- мобильному приложению нужны одни данные;
- web-интерфейсу другие;
- не хочется усложнять общий API.
Минус — больше сервисов и больше поддержки.
14. API Gateway #
API Gateway — единая точка входа перед набором сервисов.
Client -> API Gateway -> User Service
-> Order Service
-> Payment Service
Обычно отвечает за:
- routing;
- authentication;
- rate limiting;
- aggregation;
- logging;
- request/response transformation.
В микросервисной архитектуре часто используется вместе с BFF.
15. Service Mesh #
Service Mesh — инфраструктурный слой для управления взаимодействием сервисов.
CNCF описывает service mesh как слой, который добавляет reliability, observability и security для сервисов в кластере без необходимости реализовывать это в каждом сервисе вручную.
Примеры задач:
- retries;
- timeouts;
- mTLS;
- traffic splitting;
- circuit breaking;
- observability.
Примеры технологий:
Istio
Linkerd
Consul
Envoy
Нужен не всем. Для маленькой системы service mesh часто избыточен.
16. Stateless architecture #
Stateless-подход означает, что app-инстанс не хранит критичное состояние пользователя локально между запросами.
Client -> App A
Client -> App B
Client -> App C
Состояние выносится во внешние хранилища:
PostgreSQL
Redis
S3/MinIO
Kafka
JWT
Зачем:
- проще масштабировать;
- проще балансировать нагрузку;
- проще перезапускать контейнеры;
- проще деплоить;
- выше отказоустойчивость.
17. Repository + Unit of Work #
Это не полноценный архитектурный стиль, но важные архитектурные паттерны внутри приложения.
Repository:
скрывает доступ к данным
Unit of Work:
управляет транзакцией
Пример:
OrderService
-> UnitOfWork
-> OrderRepository
-> ProductRepository
-> commit / rollback
Полезно, когда нужно отделить бизнес-логику от ORM и централизовать транзакции.
18. Saga #
Saga — решение для бизнес-процессов, которые затрагивают несколько сервисов без одной общей транзакции.
Пример:
Create order
-> Reserve product
-> Charge payment
-> Send notification
Если платеж не прошел:
Cancel order
Release reserved product
Используется в микросервисах, где обычная ACID-транзакция через несколько БД невозможна или нежелательна.
19. Outbox Pattern #
Outbox нужен, чтобы надежно сохранить изменение в БД и событие для брокера.
Проблема:
1. Сохранили заказ в БД.
2. Хотели отправить OrderCreated в Kafka.
3. Сервис упал.
4. Заказ есть, события нет.
Решение:
в той же транзакции:
- сохранить заказ;
- сохранить событие в outbox table.
отдельный worker потом отправит событие в брокер.
Это частое решение в event-driven и микросервисной архитектуре.
Главное #
Архитектурное решение выбирают не по модности, а по проблеме:
| Проблема | Возможное решение |
|---|---|
| Код разрастается | Layered, Modular Monolith, Clean Architecture |
| Сложная бизнес-логика | DDD, Onion, Hexagonal |
| Много независимых частей | Microservices |
| Много асинхронных процессов | Event-driven architecture |
| Чтение и запись сильно отличаются | CQRS |
| Нужно надежно публиковать события | Outbox |
| Транзакция между сервисами невозможна | Saga |
| Разные клиенты требуют разные API | BFF |
| Много сервисов и сетевой сложности | API Gateway, Service Mesh |
| Нужно легко масштабировать API | Stateless architecture |