Layered Architecture (N-Layer)
Суть
Код разделён на горизонтальные слои по ответственности. Каждый слой зависит только от слоя ниже. Самый старый и самый интуитивный способ структурировать backend.
┌─────────────────────────────────────┐
│ 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 (классика):
Presentation → Business → Data Access
4-Layer (с DTO/Application слоем):
Presentation → Application → Domain → Data Access
Application здесь — оркестрация сценариев (use case), Domain — чистые правила.
N-Layer (enterprise):
UI → API Gateway → Application → Domain → Persistence → Database
Чем больше слоёв, тем больше boilerplate. В Go обычно хватает 3-4.
Layer vs Tier
- Layer — логическое разделение кода в одном процессе
- Tier — физическое разделение (отдельный сервер/процесс)
3 layers на одном сервере — нормально. 3 tiers = 3 разных сервера. Не путать.
Пример: Layered в Go
Структура:
internal/
handler/ ← Presentation
order.go
service/ ← Business
order.go
repository/ ← Data Access
order.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
}
// 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})
}
// 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)
}
Выглядит чисто. Но смотри на импорты: service → repository. Бизнес-логика зависит от инфраструктуры.
Где Layered начинает болеть в Go
Layered сам по себе не зло. Для простого CRUD, админки или прототипа это нормальная стартовая точка: структура понятна, файлов мало, новичкам легко читать поток запроса.
Проблема начинается там, где слой business начинает импортировать pgx, database/sql, ORM-модели, Redis-клиент или конкретный пакет repository/postgres. В Go принято: бизнес-логика не должна знать про БД. Антипаттерн не в названии Layered, а в направлении зависимости: важные правила зависят от деталей хранения.
Проблемы
1. Бизнес-логика прибита к БД
// service импортирует repository → меняешь postgres на mongo → переписываешь service
import "myapp/internal/repository/postgres"
2. Тесты требуют БД
// Не получится протестировать 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. Объявить интерфейс рядом с сервисом (где он используется):
// service/order.go
package service
type OrderRepo interface {
Save(ctx context.Context, o Order) error
}
type OrderService struct {
repo OrderRepo // теперь интерфейс, не конкретный тип
}
2. Реализация уезжает в infrastructure-пакет:
// 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:
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. Это не архитектура, это видимость.
Сравнение: что если без слоёв?
Все в одном файле:
func createOrder(c echo.Context) error {
db.Exec("INSERT ...") // SQL прямо в handler
}
Быстро написать, нечитаемо через месяц, нетестируемо вообще. Подходит для скрипта.
Слои есть, но без DI:
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.
Практика
В чём главный риск классической Layered Architecture для долгоживущего 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"))
}
Реализуй ReviewDependency: верни "ok" только для допустимых зависимостей handler → service и service → repository.