Паттерны

Паттерны #

1. Какие группы паттернов (паттерны) вы можете назвать (порождающие, структурные, поведенческие) и приведите примеры из каждой? #

Группы паттернов проектирования #

Классическая классификация GoF делит паттерны на 3 основные группы:

  1. Порождающие
  2. Структурные
  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. Чем отличается паттерн Декоратор от Адаптера? #

КритерийAdapterDecorator
ЦельПреобразовать интерфейс одного класса в интерфейс, который ожидает клиент.Добавить новую функциональность объекту без изменения его структуры.
Пример использованияИспользуется, когда у нас есть несовместимые интерфейсы, которые нужно связать.Используется, когда нужно динамически добавлять новые возможности объектам.
ВзаимодействиеАдаптирует существующий класс к новому интерфейсу.Оборачивает объект, добавляя или изменяя его поведение.
СтруктураРеализует интерфейс, который ожидает клиент, и делегирует вызовы адаптируемому классу.Оборачивает объект одного и того же интерфейса, добавляя новую логику.
Пример в реальной жизниЭлектрический адаптер, который преобразует тип розетки.Подарочная упаковка на коробке, которая не меняет содержимое, но добавляет украшение.

Паттерн 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 модели и может улучшать производительность, масштабируемость и безопасность.

Хорошие случаи:

  1. Сложная бизнес-логика на запись.
  2. Очень много чтения и мало записи.
  3. Тяжелые дашборды, фильтры, отчеты.
  4. Нужно отдельно масштабировать чтение и запись.
  5. Нужно иметь разные модели данных для UI и бизнес-логики.
  6. Микросервисы, где чтение собирается из нескольких источников.

Где CQRS избыточен #

Для простого CRUD CQRS часто усложняет проект без реальной пользы. Martin Fowler отдельно предупреждает, что CQRS может быть полезен в отдельных ситуациях, но для большинства систем добавляет рискованную сложность.

Плохие случаи:

  1. Маленький CRUD-сервис.
  2. Простая админка.
  3. Нет сложной бизнес-логики.
  4. Нет проблем с производительностью чтения.
  5. Команда не готова поддерживать дополнительную архитектурную сложность.

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

Плюсы:

  1. Чище разделение ответственности.
  2. Проще оптимизировать чтение.
  3. Проще оптимизировать запись.
  4. Можно масштабировать read и write части отдельно.
  5. Можно делать read-модели конкретно под UI.
  6. Команды легче тестировать отдельно от запросов.

Минусы:

  1. Больше классов и файлов.
  2. Сложнее архитектура.
  3. Может появиться eventual consistency.
  4. Нужно синхронизировать read model и write model.
  5. Для простых проектов может быть 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 позволяет выбирать поведение алгоритма во время выполнения программы.

Хорошие случаи:

  1. Есть много if/elif по типу поведения.
  2. Нужно легко добавлять новые варианты алгоритма.
  3. Нужно менять поведение объекта во время выполнения.
  4. Разные алгоритмы имеют одинаковую цель, но разную реализацию.
  5. Нужно вынести сложную логику из большого service-класса.

Когда не стоит использовать #

Strategy может быть избыточной, если вариантов поведения мало и они почти не меняются.

Плохие случаи:

  1. Всего 2 простых if, и логика вряд ли будет расти.
  2. Алгоритмы слишком маленькие и не несут отдельной ответственности.
  3. Паттерн добавляет больше классов, чем пользы.
  4. Команда не понимает, зачем здесь дополнительная абстракция.

Пример ближе к 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 полностью исчезает. Иногда выбор стратегии все равно где-то происходит. Главное — бизнес-алгоритмы разделены и не смешаны в одном большом методе.

Плюсы

  1. Убирает большие if/elif/switch.
  2. Упрощает добавление новых алгоритмов.
  3. Делает код расширяемым.
  4. Упрощает тестирование каждой стратегии отдельно.
  5. Разделяет разные варианты поведения по отдельным классам.

Минусы

  1. Увеличивает количество классов.
  2. Может быть избыточен для простой логики.
  3. Клиентский код должен понимать, какую стратегию выбрать.
  4. Иногда нужен дополнительный 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

Канал выпускает видео -> подписчики получают уведомление

Проблема без паттерна #

Допустим, после создания заказа нужно:

  1. Отправить email.
  2. Отправить push-уведомление.
  3. Записать событие в лог.
  4. Обновить аналитику.

Плохой вариант:

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 хорошо подходит, когда после одного события должны автоматически запускаться разные независимые реакции.

Примеры:

  1. Пользователь зарегистрировался:

    • отправить email
    • создать профиль
    • записать аудит
  2. Заказ создан:

    • отправить уведомление
    • списать товар
    • обновить статистику
  3. Файл загружен:

    • проверить антивирусом
    • создать превью
    • отправить событие в аналитику
  4. Изменилось состояние объекта:

    • обновить 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 как близкие, но разные подходы к уведомлению зависимых компонентов.

Плюсы

  1. Уменьшает связанность между объектами.
  2. Позволяет добавлять новые реакции без изменения основного класса.
  3. Удобен для событийной логики.
  4. Хорошо подходит для уведомлений, UI, аудита, аналитики.
  5. Помогает соблюдать Open/Closed Principle.

Минусы

  1. Сложнее отслеживать порядок выполнения observers.
  2. Может быть трудно дебажить цепочку событий.
  3. Один observer может замедлить всю операцию.
  4. Ошибка в одном observer может сломать уведомление остальных.
  5. При неаккуратной реализации можно забыть отписать observer и получить утечки памяти.

Когда использовать #

Паттерн стоит использовать, когда один объект должен уведомлять несколько других объектов, но не должен жестко зависеть от их конкретных классов.

Хорошие случаи:

  1. После одного действия нужно выполнить несколько независимых реакций.
  2. Нужно подписывать и отписывать обработчики во время выполнения.
  3. Нужно убрать лишние зависимости из основного сервиса.
  4. Нужно сделать событийную модель внутри приложения.

Когда не стоит использовать:

  1. Есть только одно простое действие после события.
  2. Логика строго последовательная и зависит от результата каждого шага.
  3. Важно явно видеть весь сценарий в одном use case.
  4. Проект маленький, и 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

Плюсы

  1. Бизнес-логика не зависит напрямую от ORM.
  2. Код проще тестировать через mock/fake repository.
  3. Легче менять способ хранения данных.
  4. Запросы к данным собраны в одном месте.
  5. Сервисы становятся чище.
  6. Удобно использовать вместе с Clean Architecture, DDD, CQRS.

Минусы

  1. Больше классов и файлов.
  2. Может дублировать возможности ORM.
  3. В простом CRUD может быть лишней абстракцией.
  4. При плохом дизайне превращается в набор методов на все случаи жизни.
  5. Слишком общий 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 уместен, когда:

  1. Есть слой сервисов/use cases.
  2. Есть бизнес-логика, которую не хочется смешивать с ORM.
  3. Нужно тестировать сервисы без настоящей БД.
  4. Есть сложные запросы, которые нужно централизовать.
  5. Проект растет и прямые ORM-вызовы размазываются по коду.
  6. Используется DDD, Clean Architecture или CQRS.

Когда не использовать #

Repository может быть лишним, когда:

  1. Маленький CRUD-проект.
  2. Вся логика — простые create/read/update/delete.
  3. ORM уже достаточно хорошо закрывает задачу.
  4. Репозиторий ничего не добавляет, а только проксирует 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, то есть балансировщик должен направлять одного пользователя на один и тот же сервер.

Главное различие #

КритерийStatelessStateful
Хранит состояние в приложенииНетДа
Запросы независимыДаНет
Можно отправить запрос на любой инстансДаОбычно нет
МасштабированиеПрощеСложнее
ОтказоустойчивостьВышеНиже, если состояние только в памяти
Пример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 за пределы доменной модели; интерфейсы репозиториев определяются рядом с доменной моделью, а конкретные реализации находятся в инфраструктуре.

Плюсы

  1. Бизнес-логика не зависит от фреймворка.
  2. Проще тестировать use cases без реальной БД.
  3. Можно заменить ORM или базу с меньшими изменениями.
  4. Код лучше разделен по ответственности.
  5. Хорошо сочетается с DDD, Repository, Unit of Work, CQRS.
  6. Проще держать сложную бизнес-логику отдельно от инфраструктуры.

Минусы

  1. Больше файлов и абстракций.
  2. Для простого CRUD может быть избыточной.
  3. Нужно дисциплинированно следить за направлением импортов.
  4. Разработчики могут начать делать лишние интерфейсы без пользы.
  5. Порог входа выше, чем у обычного 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 ArchitectureUse cases, entities, interface adapters, frameworks
Hexagonal ArchitecturePorts 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
Разные клиенты требуют разные APIBFF
Много сервисов и сетевой сложностиAPI Gateway, Service Mesh
Нужно легко масштабировать APIStateless architecture