Продвинутый

Чистая архитектура и слои приложения

Урок 3 из 6 в курсе Архитектурные паттерны и подходы к проектированию

Содержание курса (3/6)

Чистая архитектура и слои приложения

Чистая архитектура Роберта Мартина, гексагональная архитектура Алистера Кокбёрна и луковая архитектура Джеффри Палермо — три названия одной идеи: бизнес-логика в центре, фреймворк и база данных — на периферии.

Почему это важно: В типичном Rails/Django-проекте бизнес-логика размазана по контроллерам, моделям и вьюхам. При смене БД, фреймворка или добавлении нового UI (CLI, API, WebSocket) приходится переписывать всё. Чистая архитектура решает эту проблему разделением на слои.

Главная идея

Правило зависимостей: зависимости направлены внутрь. Внутренний слой (бизнес-логика) ничего не знает о внешнем (фреймворк, БД, HTTP). Внешний слой адаптирует запросы внешнего мира к интерфейсам внутреннего.

Как это выглядит на практике

  1. Задача: перевести часть API с REST на GraphQL, сохранив бизнес-логику
  2. Без слоёв: бизнес-логика в контроллерах REST — нужно дублировать для GraphQL
  3. С чистой архитектурой: бизнес-логика в Use Cases (сервисных объектах)
  4. REST-контроллер вызывает CreateOrder use case с параметрами
  5. GraphQL-резолвер вызывает тот же CreateOrder use case
  6. Бизнес-логика написана один раз, два способа доставки

Примеры кода

Слои чистой архитектуры на примере заказа

# === DOMAIN LAYER (центр) — чистая бизнес-логика ===
# Не зависит от Rails, ActiveRecord, HTTP

class Order
  attr_reader :items, :customer_id, :status, :total

  def initialize(customer_id:, items:)
    @customer_id = customer_id
    @items = items
    @status = :pending
    @total = items.sum { |i| i[:price] * i[:quantity] }
  end

  def confirm!
    raise 'Пустой заказ' if @items.empty?
    raise 'Сумма должна быть > 0' if @total <= 0
    @status = :confirmed
  end
end

# === APPLICATION LAYER (use cases) ===
# Оркестрация бизнес-логики, зависит от интерфейсов (портов)

class PlaceOrder
  def initialize(order_repo:, payment_gateway:, notifier:)
    @order_repo = order_repo
    @payment = payment_gateway
    @notifier = notifier
  end

  def call(customer_id:, items:)
    order = Order.new(customer_id: customer_id, items: items)
    order.confirm!

    @payment.charge(order.total, customer_id: customer_id)
    @order_repo.save(order)
    @notifier.order_placed(order)

    order
  end
end

# === INFRASTRUCTURE LAYER (адаптеры) ===
# Реализация интерфейсов: БД, API, email

class ActiveRecordOrderRepo
  def save(order)
    OrderRecord.create!(
      customer_id: order.customer_id,
      items: order.items.to_json,
      total: order.total,
      status: order.status
    )
  end
end

# === DELIVERY LAYER (контроллеры, GraphQL) ===

# REST
class Api::OrdersController < ApplicationController
  def create
    result = PlaceOrder.new(
      order_repo: ActiveRecordOrderRepo.new,
      payment_gateway: StripePayment.new,
      notifier: EmailNotifier.new
    ).call(
      customer_id: current_user.id,
      items: order_params[:items]
    )
    render json: result, status: :created
  end
end

# GraphQL — тот же use case, другая точка входа
class Mutations::PlaceOrder < BaseMutation
  def resolve(items:)
    PlaceOrder.new(
      order_repo: ActiveRecordOrderRepo.new,
      payment_gateway: StripePayment.new,
      notifier: EmailNotifier.new
    ).call(customer_id: context[:current_user].id, items: items)
  end
end

Тот же принцип на Python (FastAPI)

# domain/order.py — чистый Python, без фреймворков
from dataclasses import dataclass, field

@dataclass
class OrderItem:
    product_id: int
    price: float
    quantity: int

@dataclass
class Order:
    customer_id: int
    items: list[OrderItem]
    status: str = 'pending'

    @property
    def total(self) -> float:
        return sum(i.price * i.quantity for i in self.items)

    def confirm(self):
        if not self.items:
            raise ValueError('Order must have items')
        self.status = 'confirmed'

# application/place_order.py — use case
from domain.order import Order, OrderItem
from ports.order_repo import OrderRepository
from ports.payment import PaymentGateway

class PlaceOrder:
    def __init__(self, repo: OrderRepository, payment: PaymentGateway):
        self.repo = repo
        self.payment = payment

    def execute(self, customer_id: int, items: list[dict]) -> Order:
        order_items = [OrderItem(**i) for i in items]
        order = Order(customer_id=customer_id, items=order_items)
        order.confirm()
        self.payment.charge(order.total, customer_id)
        self.repo.save(order)
        return order

# adapters/api.py — FastAPI controller
@router.post('/orders')
def create_order(body: OrderRequest, user=Depends(get_user)):
    use_case = PlaceOrder(
        repo=SqlAlchemyOrderRepo(db),
        payment=StripeGateway()
    )
    order = use_case.execute(user.id, body.items)
    return {'id': order.id, 'total': order.total}

Что происходит под капотом

  • Правило зависимостей: Domain ← Application ← Infrastructure ← Delivery. Стрелка = «знает о»
  • Domain layer: чистые классы без импортов фреймворка, тестируются без БД за миллисекунды
  • Порты (interfaces) определяются во внутреннем слое, адаптеры (реализации) — во внешнем
  • Use Case = один бизнес-сценарий, один публичный метод (call/execute), явные зависимости через конструктор
  • Чистая архитектура ≠ много папок. Суть — в направлении зависимостей, не в структуре файлов

Типичные ошибки и заблуждения

  • «Чистая архитектура = 20 папок и 50 файлов для одного CRUD» — для простого CRUD это overkill; применяйте, когда бизнес-логика нетривиальна
  • «Нужно полностью абстрагировать ActiveRecord» — в Rails можно применять принципы выборочно: use cases без полной абстракции БД
  • «Чистая, гексагональная и луковая — разные архитектуры» — одна идея с разными диаграммами: бизнес-логика в центре, фреймворк снаружи

Ключевые выводы

  • Зависимости направлены внутрь: фреймворк знает о бизнес-логике, но не наоборот
  • Use Case (сервисный объект) = один бизнес-сценарий с явными зависимостями
  • Domain layer тестируется без БД, без HTTP, без фреймворка — за миллисекунды

Термины урока

{:term=>"Use Case", :definition=>"Класс, реализующий один бизнес-сценарий (PlaceOrder, TransferMoney). Orchestrates domain objects."}
{:term=>"Port", :definition=>"Интерфейс, определённый бизнес-логикой (OrderRepository, PaymentGateway), реализуемый адаптером"}
{:term=>"Adapter", :definition=>"Реализация порта для конкретной технологии (ActiveRecordOrderRepo, StripeGateway)"}

Связь с работой backend-разработчика

Чистая архитектура — не про папки, а про направление зависимостей. В реальности: вынесите бизнес-логику в Service Objects/Use Cases, внедряйте зависимости через конструктор, и ваш код станет тестируемым и переносимым между фреймворками.

Мини-разбор реальной ситуации

SaaS-платформа для бухгалтерии: изначально монолит на Rails с бизнес-логикой в моделях и контроллерах. При добавлении мобильного API пришлось дублировать логику. После вынесения 40 ключевых операций в Use Cases: REST и GraphQL используют одну кодовую базу, тесты бизнес-логики запускаются за 3 секунды (без БД), а добавление нового endpoint — вопрос написания тонкого контроллера.

Что запомнить

  • Зависимости внутрь: Domain ← Application ← Infrastructure ← Delivery
  • Use Case = один сценарий, явные зависимости через конструктор, один публичный метод
  • Не абстрагируйте всё сразу — начните с выноса самой сложной бизнес-логики

Итог

Чистая архитектура — это принцип, а не шаблон проекта. Бизнес-логика в центре, фреймворк и БД на периферии. Применяйте постепенно: сначала use cases для самых сложных операций, затем расширяйте по мере роста системы.

Комментарии к уроку

Войдите, чтобы оставить комментарий.

Пока нет комментариев — будьте первым.