Layered Architecture (N-Layer)

Суть

Код разделён на горизонтальные слои по ответственности. Каждый слой зависит только от слоя ниже. Самый старый и самый интуитивный способ структурировать backend.

text
┌─────────────────────────────────────┐ │ Presentation (HTTP handlers, UI) │ ← знает Business ├─────────────────────────────────────┤ │ Business Logic (services, rules) │ ← знает Data Access ├─────────────────────────────────────┤ │ Data Access (repos, ORM, SQL) │ ← знает Database ├─────────────────────────────────────┤ │ Database │ └─────────────────────────────────────┘

Поток вызова: HTTP-запрос → handler → service → repository → SQL → ответ обратно вверх.

Ключевые правила

  • Зависимости только сверху вниз — presentation вызывает business, business вызывает data access
  • Слой не знает о слоях выше себя
  • Внутри слоя — свободная организация
  • Изменения локализованы в одном слое (в идеале)

Closed vs Open layers

  • Closed layer — вызов разрешён только в соседний слой ниже. Presentation не имеет права лезть напрямую в Data Access, только через Business
  • Open layer — можно прыгать через слой. Чаще используют для cross-cutting concerns: логирование, кэш, метрики

Closed строже, но провоцирует "дырявые" сервисы, которые просто пробрасывают вызов в repo. Open даёт гибкость, но размывает границы.

Variations: 3, 4, N слоёв

3-Layer (классика):

text
Presentation → Business → Data Access

4-Layer (с DTO/Application слоем):

text
Presentation → Application → Domain → Data Access

Application здесь — оркестрация сценариев (use case), Domain — чистые правила.

N-Layer (enterprise):

text
UI → API Gateway → Application → Domain → Persistence → Database

Чем больше слоёв, тем больше boilerplate. В Go обычно хватает 3-4.

Layer vs Tier

  • Layer — логическое разделение кода в одном процессе
  • Tier — физическое разделение (отдельный сервер/процесс)

3 layers на одном сервере — нормально. 3 tiers = 3 разных сервера. Не путать.

Пример: Layered в Go

Структура:

text
internal/ handler/ ← Presentation order.go service/ ← Business order.go repository/ ← Data Access order.go
go
// repository/order.go package repository type OrderRepo struct { db *pgxpool.Pool } func (r *OrderRepo) Save(ctx context.Context, o Order) error { _, err := r.db.Exec(ctx, "INSERT INTO orders (id, user_id, total) VALUES ($1, $2, $3)", o.ID, o.UserID, o.Total) return err }
go
// service/order.go package service import "myapp/internal/repository" // зависим вниз type OrderService struct { repo *repository.OrderRepo // конкретный тип, не интерфейс } func (s *OrderService) Create(ctx context.Context, userID string, items []Item) error { total := calcTotal(items) if total <= 0 { return errors.New("empty order") } return s.repo.Save(ctx, Order{UserID: userID, Total: total}) }
go
// handler/order.go package handler import "myapp/internal/service" // зависим вниз type OrderHandler struct { svc *service.OrderService } func (h *OrderHandler) Create(c echo.Context) error { var req CreateOrderReq if err := c.Bind(&req); err != nil { return c.JSON(400, err) } if err := h.svc.Create(c.Request().Context(), req.UserID, req.Items); err != nil { return c.JSON(500, err) } return c.NoContent(201) }

Выглядит чисто. Но смотри на импорты: servicerepository. Бизнес-логика зависит от инфраструктуры.

Где Layered начинает болеть в Go

Layered сам по себе не зло. Для простого CRUD, админки или прототипа это нормальная стартовая точка: структура понятна, файлов мало, новичкам легко читать поток запроса.

Проблема начинается там, где слой business начинает импортировать pgx, database/sql, ORM-модели, Redis-клиент или конкретный пакет repository/postgres. В Go принято: бизнес-логика не должна знать про БД. Антипаттерн не в названии Layered, а в направлении зависимости: важные правила зависят от деталей хранения.

Проблемы

1. Бизнес-логика прибита к БД

go
// service импортирует repository → меняешь postgres на mongo → переписываешь service import "myapp/internal/repository/postgres"

2. Тесты требуют БД

go
// Не получится протестировать OrderService без поднятия Postgres svc := service.NewOrderService(repository.NewOrderRepo(realDB))

Можно делать integration-тесты с testcontainers, но это медленно и хрупко.

3. Сквозная связанность Поменяли тип колонки в БД → поменяли модель в repository → поменяли модель в service → поменяли DTO в handler. Каскад через все слои.

4. Размытие границ Без дисциплины handler начинает напрямую звать repository (срезать угол). Через год — спагетти.

5. Анемичная модель Бизнес-логика расползается по сервисам, entities превращаются в DTO с публичными полями. DDD умер.

Связь с проектом RateDesk

В проектном треке RateDesk layered-подход полезен как диагностика первой версии: handler -> service -> repository легко читать, пока сервис только конвертирует сумму из памяти. На архитектурном этапе проекта важно увидеть момент, где эта простота начинает течь:

  • use case начинает зависеть от конкретного Postgres/Redis/provider-клиента;
  • свежесть курса, fallback и idempotency размазываются между service и repository;
  • HTTP DTO, DB row и доменная модель становятся одним и тем же типом;
  • unit-тест конвертации внезапно требует БД, сети или testcontainers.

Детальное задание, MR-план и acceptance criteria для такого рефакторинга находятся в отдельном этапе RateDesk Architecture. В этом уроке важно понять критерий: layered допустим, пока бизнесовые правила не зависят от инфраструктурных деталей.

Рефакторинг: Layered → Hexagonal

Шаги:

1. Объявить интерфейс рядом с сервисом (где он используется):

go
// service/order.go package service type OrderRepo interface { Save(ctx context.Context, o Order) error } type OrderService struct { repo OrderRepo // теперь интерфейс, не конкретный тип }

2. Реализация уезжает в infrastructure-пакет:

go
// infrastructure/postgres/order_repo.go package postgres type OrderRepo struct { db *pgxpool.Pool } func (r *OrderRepo) Save(ctx context.Context, o service.Order) error { ... }

3. Сборка зависимостей в main.go:

go
func main() { db := postgres.Connect(...) repo := postgres.NewOrderRepo(db) svc := service.NewOrderService(repo) // инжектим адаптер в порт h := handler.NewOrderHandler(svc) ... }

Теперь зависимость инвертирована: postgres зависит от service (импортирует тип Order). Сервис ничего не знает про БД. Это уже Hexagonal.

Когда применять Layered

Подходит:

  • Простой CRUD-сервис без сложной бизнес-логики
  • Прототип, MVP — когда скорость важнее чистоты
  • Команда из 1-2 человек, проект на 1-3 месяца
  • Почти нет тестов и не планируется
  • Технологии не будут меняться (БД, транспорт)

Не подходит:

  • Сложная бизнес-логика, инварианты, правила
  • Несколько источников данных (postgres + mongo + cache)
  • Высокие требования к покрытию тестами
  • Команда 3+ человек, проект на годы
  • Микросервис, который должен жить долго

Pitfalls

Сервис как пробрасыватель. service.GetOrder() просто вызывает repo.GetOrder(). Зачем тогда слой? Либо положи логику в сервис, либо убери его.

God Service. Один OrderService на 5000 строк, который делает всё. Разбивай по use case: CreateOrder, CancelOrder.

Анемичный домен. Структура Order с публичными полями и без методов. Логика в сервисах. Это процедурный стиль с неймспейсами.

Repository возвращает ORM-модели. Service импортирует gorm.Model или sqlx-теги. Теперь бизнес-логика знает про ORM. Маппи модели на границе.

Циклические импорты. Service хочет дёрнуть handler (для callback) → import cycle. Признак, что слои перемешаны.

Pseudo-layered. Папки handler/service/repo есть, но handler напрямую дёргает sql-driver. Это не архитектура, это видимость.

Сравнение: что если без слоёв?

Все в одном файле:

go
func createOrder(c echo.Context) error { db.Exec("INSERT ...") // SQL прямо в handler }

Быстро написать, нечитаемо через месяц, нетестируемо вообще. Подходит для скрипта.

Слои есть, но без DI:

go
var repo = NewOrderRepo(db) var svc = NewOrderService(repo)

Глобальные переменные → невозможно подменить в тестах. Худшее из обоих миров.

Hexagonal/Clean: Сервис принимает интерфейс. Тестируется без БД. Меняется адаптер без рефакторинга домена. См. следующие темы.

Чек-лист ревью

  • Зависимости направлены строго сверху вниз
  • Handler не импортирует repository напрямую
  • Repository не возвращает HTTP-структуры
  • Внутри сервиса есть реальная логика, а не проброс
  • Один use case = один публичный метод сервиса (без God Service)
  • Модели БД не утекают в presentation
  • Если планируется развитие — уже инвертировал зависимости (interface в service)

Вопросы для интервью

Q: Что такое Layered Architecture? A: Подход, где код разделён на горизонтальные слои (presentation, business, data access), каждый зависит только от нижнего. Самая старая и самая распространённая архитектура.

Q: В чём главная проблема Layered? A: Бизнес-логика зависит от инфраструктуры (БД). Поменять postgres на mongo — переписать сервисы. Тесты требуют поднятия БД. Зависимости направлены не в ту сторону.

Q: Чем отличается Layer от Tier? A: Layer — логическое разделение кода в одном процессе. Tier — физическое (разные серверы). Можно иметь 3 layers в одном бинарнике или раскидать их по 3 серверам (3 tiers).

Q: Closed vs open layer? A: Closed — вызов только в соседний слой ниже. Open — можно через слой. Closed строже, но генерит boilerplate. Open удобнее для cross-cutting (логирование, кэш).

Q: Почему в Go Layered считают антипаттерном? A: В Go принят DIP: бизнес-логика определяет интерфейсы, инфраструктура их реализует. Layered нарушает это — service напрямую импортирует repository. Поэтому в Go обычно сразу делают Hexagonal/Clean.

Q: Как из Layered перейти в Hexagonal? A: Объявить интерфейс в пакете сервиса (порт). Перенести реализацию repository в infrastructure-пакет (адаптер). Собрать зависимости в main: инжектить адаптер через интерфейс. Service больше не импортирует postgres.

Q: Когда Layered всё-таки уместен? A: Простой CRUD без сложной логики, прототип, MVP, небольшая команда, короткий горизонт жизни. Если нужен быстрый результат и не планируется менять технологии.

Q: Что такое анемичная модель? A: Структура с публичными полями и без методов. Вся логика в сервисах. Антипаттерн DDD — нарушает инкапсуляцию, превращает домен в DTO. Следствие неправильно применённого Layered.


Практика

Quiz+10 XP

В чём главный риск классической Layered Architecture для долгоживущего Go-сервиса?

  • Handler становится слишком тонким
  • Бизнес-логика начинает зависеть от инфраструктуры: БД, ORM, SQL-моделей
  • Repository оказывается слишком легко тестировать
  • HTTP-слой нельзя отделить от роутера
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func allowed(from, to string) bool { if from == "handler" && to == "service" { return true } if from == "service" && to == "handler" { return false } return from == "service" && to == "repository" } func main() { fmt.Println(allowed("handler", "service")) fmt.Println(allowed("service", "handler")) fmt.Println(allowed("repository", "service")) }
Задача+20 XP

Реализуй ReviewDependency: верни "ok" только для допустимых зависимостей handler → service и service → repository.