15. Layered Architecture. Классическая трехслойка
После погружения в строгие и многослойные каноны Clean Architecture и Hexagonal Architecture у инженера может возникнуть ложное ощущение, будто классическая «трёхслойка» — это архаичный пережиток прошлого, недостойный пера опытного бэкендера.
Однако реальность такова: подавляющее большинство успешных мировых систем начинали свой жизненный путь именно с трёхслойной структуры. Более того, понимание её механических ограничений — это единственный способ осознанно, а не слепо по веянию моды, выбирать более сложные архитектурные стили.
Как любил замечать Брайан Керниган:
«Каждый может написать сложную программу. Искусство в том, чтобы написать простую систему, решающую задачу без лишних абстракций».
В этой главе мы беспристрастно исследуем классическую трёхслойную архитектуру (Layered Architecture), её воплощение в идиоматическом Go, границы её применимости, неизбежные структурные ловушки и механику эволюционного перехода к инвертированным архитектурам.
Структура трехслойной архитектуры
Классическая трёхслойная архитектура разделяет приложение по горизонтали на три изолированных функциональных эшелона, где каждый вышестоящий уровень вызывает нижележащий:
- Presentation Layer (Слой представления / доставки):
Отвечает за протоколы взаимодействия с внешним миром: веб-браузерами, мобильными клиентами или операционной системой. В Go-сервисах это HTTP-хэндлеры (net/http, Gin, Fiber), серверные обработчики gRPC или консольные команды CLI. Задача слоя — принять сетевой пакет, распарсить параметры, передать управление в бизнес-слой и упаковать результат в HTTP/gRPC ответ. - Business Logic Layer (Слой бизнес-логики / сервисов):
Ядро прикладных вычислений и алгоритмов. Здесь осуществляются проверки прав, вычисление скидок, транзакционная валидация и бизнес-правила. В Go этот слой традиционно представлен пакетамиserviceилиusecase. - Data Access Layer (Слой доступа к данным / репозиториев):
Отвечает за физическое взаимодействие с накопителями данных: реляционными базами данных (PostgreSQL, MySQL), распределёнными кэшами (Redis) или внешними интеграционными API. В коде на Go этот слой живёт в пакетахrepository,daoилиstorage.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Client["Клиент"]:::entry
Presentation["Presentation Layer<br>HTTP Handlers"]:::network
Business["Business Logic Layer<br>Services"]:::process
Data["Data Access Layer<br>Repositories"]:::process
DB["База данных"]:::process
Client --> Presentation
Presentation --> Business
Business --> Data
Data --> DB
Реализация в Go: типичная структура
В традиционных Go-проектах трёхслойная организация кода принимает интуитивную и компактную форму:
project/
├── cmd/
│ └── main.go
├── internal/
│ ├── handler/ # Presentation Layer: сетевой транспорт
│ │ ├── order.go
│ │ └── user.go
│ ├── service/ # Business Logic Layer: бизнес-логика
│ │ ├── order.go
│ │ └── user.go
│ ├── repository/ # Data Access Layer: SQL и хранение
│ │ ├── order_repo.go
│ │ └── user_repo.go
│ └── model/ # Общие структуры данных (Entities / DTO)
│ ├── order.go
│ └── user.go
└── go.mod
Посмотрим, как выглядит прохождение запроса сверху вниз через все три слоя.
1. Слой обработчиков (Presentation)
Обработчик распаковывает тело входящего JSON-запроса и вызывает сервис:
// internal/handler/order.go
package handler
import (
"encoding/json"
"net/http"
"project/internal/service"
)
type CreateOrderRequest struct {
UserID string `json:"user_id"`
Items []service.ItemInput `json:"items"`
}
type OrderHandler struct {
orderService *service.OrderService
}
func NewOrderHandler(svc *service.OrderService) *OrderHandler {
return &OrderHandler{orderService: svc}
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
order, err := h.orderService.CreateOrder(r.Context(), req.UserID, req.Items)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(order)
}
2. Слой бизнес-логики (Service)
Сервис содержит цепочку прикладных вычислений и напрямую передаёт готовую сущность в репозиторий:
// internal/service/order.go
package service
import (
"context"
"errors"
"project/internal/model"
"project/internal/repository"
)
type ItemInput struct {
ProductID string
Quantity int
Price int64
}
type OrderService struct {
orderRepo *repository.OrderRepository
}
func NewOrderService(repo *repository.OrderRepository) *OrderService {
return &OrderService{orderRepo: repo}
}
func (s *OrderService) CreateOrder(ctx context.Context, userID string, items []ItemInput) (*model.Order, error) {
order := &model.Order{
ID: generateID(),
UserID: userID,
Items: convertItems(items),
Status: model.StatusNew,
}
// Бизнес-логика: доменная проверка и калькуляция суммы
if err := s.validateOrder(order); err != nil {
return nil, err
}
order.TotalAmount = s.calculateTotal(order.Items)
// Прямое сохранение через конкретный репозиторий
if err := s.orderRepo.Save(ctx, order); err != nil {
return nil, err
}
return order, nil
}
func (s *OrderService) validateOrder(order *model.Order) error {
if len(order.Items) == 0 {
return errors.New("order must contain at least one item")
}
return nil
}
func (s *OrderService) calculateTotal(items []model.OrderItem) int64 {
var total int64
for _, it := range items {
total += it.Price * int64(it.Quantity)
}
return total
}
func generateID() string { return "ord-12345" }
func convertItems(inputs []ItemInput) []model.OrderItem {
res := make([]model.OrderItem, len(inputs))
for i, in := range inputs {
res[i] = model.OrderItem{ProductID: in.ProductID, Quantity: in.Quantity, Price: in.Price}
}
return res
}
3. Слой доступа к данным (Repository)
Репозиторий взаимодействует с драйвером реляционной базы данных:
// internal/repository/order_repo.go
package repository
import (
"context"
"database/sql"
"project/internal/model"
)
type OrderRepository struct {
db *sql.DB
}
func NewOrderRepository(db *sql.DB) *OrderRepository {
return &OrderRepository{db: db}
}
func (r *OrderRepository) Save(ctx context.Context, order *model.Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders (id, user_id, total_amount, status) VALUES ($1, $2, $3, $4)",
order.ID, order.UserID, order.TotalAmount, order.Status)
return err
}
Направление зависимостей: корень проблем
Внимательно взгляните на стрелки вызовов и импортов в исходном коде выше. Ключевая анатомическая черта классической трёхслойки — жесткое нисходящее направление зависимостей:
handler ──(import)──> service ──(import)──> repository ──(import)──> database/sql
│ │ │
└─────────────────────┴───────────────────────┴──(import)──> model
На первых порах это кажется интуитивным и кристально ясным. Однако по мере роста проекта эта нисходящая лестница неизбежно превращается в капкан:
- Бизнес-логика намертво привязана к деталям хранения:
Пакетserviceнапрямую импортирует конкретный тип*repository.OrderRepository, а репозиторий привязан кdatabase/sqlи PostgreSQL. Попытка переключиться на документоориентированную базу (MongoDB), перейти на драйверpgxс бинарным протоколом или написать легковесный in-memory фейк для тестов потребует глубокой хирургической операции на всём сервисном слое. - Мучительное модульное тестирование:
ПосколькуOrderServiceдержит прямой указатель*repository.OrderRepository, вы лишены возможности подставить мок в юнит-тесте без костылей. Вам приходится либо поднимать реальный контейнер PostgreSQL (превращая быстрые юнит-тесты в медленные интеграционные прогоны), либо менять типы полей на лету. - Божественная модель данных (God Model):
Пакетmodel.Orderстановится общим котлом. В одну структуру сваливаются теги HTTP-сериализации (json:"..."), теги маппинга базы данных (db:"..."), валидационные аннотации и методы домена. В результате изменение схемы таблицы в базе данных может случайно сломать публичный JSON API сервиса. - Хрупкость границ при масштабировании:
При разрастании проекта разработчики начинают совершать «короткие замыкания»: например, вызывать репозиторий напрямую из хэндлера в обход сервиса («зачем писать сервис ради одного SELECT?»), что быстро превращает систему в неконтролируемый клубок спагетти.
Когда трехслойка всё ещё хороша
Несмотря на архитектурные компромиссы, классическая трёхслойная архитектура — это не ошибка, а практичный инструмент. Для целого класса инженерных задач она остаётся лучшим выбором по соотношению затрат и пользы:
- Простые CRUD-сервисы: когда 90% операций сводятся к сохранению и чтению плоских записей из базы данных без сложных доменных инвариантов.
- Прототипы и MVP (Minimum Viable Product): когда критически важно проверить гипотезу за две недели, а не полировать слои абстракций ради идеальной чистоты.
- Внутренние утилиты и административные панели: сервисы с ограниченной аудиторией и коротким горизонтом жизни.
- Стартапы на ранней стадии: когда требования бизнеса меняются трижды в неделю, избыточная изоляция портов и адаптеров лишь снижает скорость манёвра команды.
В этих сценариях трёхслойка выигрывает за счёт минимального количества файлов, отсутствия промежуточных DTO-мапперов и предельно низкого порога входа для любого джуниор-разработчика.
Переход к более чистым архитектурам
Когда проект перерастает рамки простого CRUD, а симптомы жесткой связанности начинают тормозить релизные циклы, трёхслойка легко и органично рефакторится в гексагональную архитектуру (13. Hexagonal Architecture. Ports and Adapters).
Секрет этого превращения заключается в инверсии зависимостей (Dependency Inversion). Мы не переписываем проект с нуля — мы лишь переносим границу контракта:
// До рефакторинга: service зависит от конкретного типа репозитория
type OrderService struct {
repo *repository.OrderRepository // Жесткая привязка к реализации!
}
// После рефакторинга: service определяет потребительский интерфейс (порт)
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
}
type OrderService struct {
repo OrderRepository // Абстракция: service свободен от инфраструктуры!
}
В одно мгновение:
- Слой сервиса становится чистым ядром, владеющим интерфейсами.
- Слой репозитория превращается в вторичный адаптер, реализующий эти интерфейсы.
- Слой хэндлеров становится первичным адаптером.
Mechanical Sympathy: трехслойка и производительность Go
С точки зрения кремния и рантайма Go классическая трёхслойка демонстрирует превосходную механическую эффективность:
- Прямые вызовы функций:
Поскольку сервис вызывает конкретный метод(*OrderRepository).Save(), здесь нет косвенной адресации через интерфейсную таблицуitab. Компилятор Go может применить агрессивный inlining (встраивание кода функции в место вызова), полностью устраняя накладные расходы на вызов стекового кадра. - Нулевые затраты на конвертацию данных:
Поскольку одна и та же структураmodel.Orderпередаётся сквозь все три слоя по указателю, данные не копируются в памяти, что снижает нагрузку на CPU-кэш и аллокатор. - Обратная сторона медали:
Единая структура в памяти провоцирует гонки данных (data races), если разные слои или параллельные горутины начинают одновременно модифицировать её поля.
Сравнение с чистыми архитектурами
| Критерий | Трехслойная архитектура | Clean / Hexagonal Architecture |
|---|---|---|
| Направление зависимостей | Сверху вниз (Presentation → Service → Repository) | Снаружи внутрь (Адаптеры → Use Cases → Entities) |
| Зависимость бизнес-логики от БД | Прямая (импорт конкретного типа репозитория) | Инвертированная (через интерфейс в ядре) |
| Тестирование бизнес-логики | Требует поднятия БД или сложных ухищрений | Мгновенно тестируется с in-memory заглушками |
| Замена хранилища | Требует каскадного изменения пакета service |
Требует лишь написания нового изолированного адаптера |
| Порог входа в кодовую базу | Минимальный (понятно любому начинающему инженеру) | Средний / высокий (требует глубокого понимания DIP и DDD) |
| Объем шаблонного кода | Минимальный (одна общая модель на всю систему) | Выше (интерфейсы, DTO, явные мапперы слоёв) |
Итог
Классическая трёхслойная архитектура — это надёжная рабочая лошадка бэкенд-разработки. Она даёт превосходный старт для небольших сервисов и прототипов, сохраняя код простым и компактным. Однако зрелый архитектор всегда держит руку на пульсе: как только сложность домена начинает задыхаться в тисках прямых зависимостей, трёхслойку следует своевременно перевести на рельсы инверсии зависимостей.
Теперь, исследовав внутреннюю архитектуру классов и слоёв, мы должны сделать следующий фундаментальный шаг — разобраться, как принято физически организовывать файлы и каталоги в реальном Go-репозитории: 16. Standard Go Project Layout и его ограничения.