Олег Марков
Архитектурные слои - принципы проектирования и практические примеры
Введение
Архитектурные слои (layers) — это способ упорядочить код системы по уровням ответственности. Каждый слой решает свою задачу и минимально знает о деталях других слоев. Когда вы разделяете систему на слои, становится проще:
- понимать структуру приложения;
- вносить изменения без «ломания» всего остального;
- тестировать отдельные части системы;
- менять детали реализации (база данных, UI, фреймворки) без переписывания бизнес-логики.
Давайте по шагам разберем, какие слои обычно выделяют, как они взаимодействуют, как это выглядит в коде и какие ошибки чаще всего встречаются в реальных проектах.
Базовые принципы слоистой архитектуры
Вертикальное разделение по ответственности
Смотрите, ключевая идея архитектурных слоев: разделять не по технологиям (например, «слой Spring», «слой PostgreSQL»), а по ответственности.
Обычно выделяют такие логические области:
- представление (UI, API);
- бизнес-логика (домен);
- работа с данными (хранилище, внешние сервисы);
- инфраструктура (логирование, авторизация, кэш и т.п.).
Дальше эти области раскладываются по слоям. Важно, чтобы каждый слой решал понятную задачу и не «лез» в зону другого слоя.
Направление зависимостей
Классическое правило: зависимости идут сверху вниз. Более «верхний» слой зависит от нижнего, но не наоборот.
Упрощенная схема:
- UI / API слой (самый верхний)
- зависит от слоя приложения (application / service layer)
- Слой приложения
- зависит от доменного слоя (domain layer)
- Доменный слой
- зависит только от абстракций инфраструктурного слоя (портов, интерфейсов)
- Инфраструктурный слой (DB, HTTP-клиенты и т.п.)
- реализует интерфейсы домена / приложения, но логически считается «ниже»
Похожую идею вы, возможно, встречали в концепции «чистой архитектуры» (Clean Architecture) и «луковичной архитектуры» (Onion Architecture).
Правило «каждый слой знает только о ближайших вниз»
Хорошая практика — не позволять верхнему слою перескакивать через промежуточные. Пример нарушения:
- контроллер из web-слоя вызывает репозиторий напрямую, минуя слой бизнес-логики.
Формулировка правила:
- UI может знать об application;
- application может знать о domain;
- domain может знать только о своих абстракциях (портов);
- инфраструктура знает о доменных абстракциях, но они не должны тянуть в себя детали инфраструктуры.
Такое ограничение помогает удерживать архитектуру «чистой» и понятной.
Типовая четырехслойная архитектура
Давайте разберем часто используемую схему, которую вы наверняка встречали в сервисах, написанных на Java, Go, C#, Node.js и других языках:
- Слой представления (Presentation / Interface / API)
- Слой приложения (Application / Service)
- Доменный слой (Domain / Model)
- Инфраструктурный слой (Infrastructure / Data Access)
Ниже я покажу вам примеры на языке, похожем на Go, но сама идея одинаково хорошо переносится на другие языки. Важно не то, на чем написан пример, а какие обязанности распределяются по слоям.
Слой представления (Presentation Layer)
Этот слой «смотрит наружу». Он отвечает за:
- прием запросов (HTTP, gRPC, CLI, UI);
- валидацию входных данных на уровне протокола;
- преобразование внешних DTO (Data Transfer Objects) к внутренним структурам;
- формирование ответа (JSON, HTML, protobuf и т.п.).
Смотрите, я покажу как может выглядеть HTTP-контроллер, который вызывает слой приложения:
// Package httpapi - реализация HTTP-слоя представления
package httpapi
import (
"encoding/json"
"net/http"
)
// UserService - интерфейс слоя приложения
// Слой представления зависит от абстракции, а не от конкретной реализации
type UserService interface {
RegisterUser(cmd RegisterUserCommand) (UserDTO, error)
}
// RegisterUserCommand - входная модель для слоя приложения
type RegisterUserCommand struct {
Email string
Password string
}
// UserDTO - данные, которые слой приложения возвращает наверх
type UserDTO struct {
ID string
Email string
}
// HTTPHandler - контроллер, который обрабатывает HTTP запросы
type HTTPHandler struct {
userService UserService
}
// NewHTTPHandler - конструктор хендлера
func NewHTTPHandler(us UserService) *HTTPHandler {
return &HTTPHandler{userService: us}
}
func (h *HTTPHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
// Здесь мы декодируем JSON из тела запроса
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
// Обработка ошибки парсинга тела запроса
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Простая валидация данных на уровне API
if req.Email == "" || req.Password == "" {
http.Error(w, "email and password required", http.StatusBadRequest)
return
}
// Формируем команду для слоя приложения
cmd := RegisterUserCommand{
Email: req.Email,
Password: req.Password,
}
// Вызываем слой приложения
userDTO, err := h.userService.RegisterUser(cmd)
if err != nil {
// Здесь мы маппим доменные / прикладные ошибки к HTTP статусам
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Формируем успешный ответ в формате JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(userDTO)
}
Обратите внимание:
- контроллер ничего не знает о базе данных;
- контроллер не содержит бизнес-правил (например, как хешировать пароль);
- все, что он делает — принимает данные, вызывает сервис приложения и выдает результат.
Слой приложения (Application Layer)
Слой приложения координирует действия:
- оркестрирует операции: вызывает доменные объекты, репозитории, шлюзы к внешним сервисам;
- реализует use case — конкретные сценарии (зарегистрировать пользователя, оформить заказ);
- знает, в какой последовательности выполнять шаги;
- часто работает с транзакциями (начать, зафиксировать, откатить).
Здесь я размещаю пример простого сервиса приложения:
// Package app - слой приложения
package app
import (
"context"
)
// User - упрощенная доменная сущность
type User struct {
ID string
Email string
PasswordHash string
}
// UserRepository - порт доступа к хранилищу пользователей
type UserRepository interface {
// Слой приложения работает через интерфейс репозитория
Save(ctx context.Context, u *User) error
FindByEmail(ctx context.Context, email string) (*User, error)
}
// PasswordHasher - порт для хеширования пароля
type PasswordHasher interface {
Hash(password string) (string, error)
}
// IDGenerator - порт для генерации идентификаторов
type IDGenerator interface {
NewID() string
}
// RegisterUserCommand - входные данные use case
type RegisterUserCommand struct {
Email string
Password string
}
// UserDTO - данные, которые возвращаются в верхние слои
type UserDTO struct {
ID string
Email string
}
// UserService - реализация use case "регистрация пользователя"
type UserService struct {
repo UserRepository
hasher PasswordHasher
idGen IDGenerator
}
// NewUserService - конструктор слоя приложения
func NewUserService(r UserRepository, h PasswordHasher, idGen IDGenerator) *UserService {
return &UserService{
repo: r,
hasher: h,
idGen: idGen,
}
}
func (s *UserService) RegisterUser(cmd RegisterUserCommand) (UserDTO, error) {
ctx := context.Background()
// Проверяем, что пользователя с таким email еще нет
existing, err := s.repo.FindByEmail(ctx, cmd.Email)
if err != nil {
return UserDTO{}, err
}
if existing != nil {
// Здесь можно вернуть доменную/прикладную ошибку "user_already_exists"
return UserDTO{}, ErrUserAlreadyExists
}
// Хешируем пароль с помощью порта PasswordHasher
hash, err := s.hasher.Hash(cmd.Password)
if err != nil {
return UserDTO{}, err
}
// Создаем доменную сущность
user := &User{
ID: s.idGen.NewID(),
Email: cmd.Email,
PasswordHash: hash,
}
// Сохраняем пользователя в хранилище
if err := s.repo.Save(ctx, user); err != nil {
return UserDTO{}, err
}
// Готовим DTO для верхнего слоя
return UserDTO{
ID: user.ID,
Email: user.Email,
}, nil
}
Здесь вы видите, как слой приложения:
- оперирует доменными сущностями (User);
- зависит от абстракций (UserRepository, PasswordHasher, IDGenerator);
- не знает, как именно устроена база данных или какая библиотека шифрования используется.
Доменный слой (Domain Layer)
Доменный слой — центральный слой многослойной архитектуры. Он описывает:
- модель предметной области (сущности, value-объекты, агрегаты);
- бизнес-правила и инварианты (что «можно» и «нельзя» в рамках домена);
- доменные события;
- доменные сервисы (если логика не помещается в сущности).
Главная особенность: доменный слой не должен зависеть от технических деталей. Все, что тянет за собой конкретные библиотеки, сети, БД — выносится в другие слои.
Пример кусочка домена для пользователя:
// Package domain - доменный слой
package domain
import (
"errors"
"regexp"
)
var (
ErrInvalidEmail = errors.New("invalid email")
ErrWeakPassword = errors.New("weak password")
)
// User - доменная сущность пользователя
type User struct {
id string
email string
passwordHash string
}
// NewUser - фабричный метод для создания пользователя
// Здесь мы проверяем бизнес-инварианты
func NewUser(id string, email string, passwordHash string) (*User, error) {
if !isValidEmail(email) {
return nil, ErrInvalidEmail
}
// Допустим, условие силы пароля мы тоже проверили заранее
if passwordHash == "" {
return nil, ErrWeakPassword
}
return &User{
id: id,
email: email,
passwordHash: passwordHash,
}, nil
}
func (u *User) ID() string {
return u.id
}
func (u *User) Email() string {
return u.email
}
func isValidEmail(email string) bool {
// Упрощенная валидация email регулярным выражением
re := regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
return re.MatchString(email)
}
Здесь слой домена:
- не знает о HTTP, SQL, JSON;
- не использует фреймворки;
- работает только с базовыми типами и собственной логикой.
Такой код легче тестировать и переносить между проектами.
Инфраструктурный слой (Infrastructure Layer)
Инфраструктура — это реализация деталей:
- подключение к базе данных;
- реализация репозиториев;
- интеграция с внешними API;
- месседж-брокеры;
- файловая система и т.п.
Покажу вам пример реализации репозитория на базе SQL:
// Package infra - инфраструктурный слой
package infra
import (
"context"
"database/sql"
"example.com/project/app"
)
// SQLUserRepository - реализация интерфейса UserRepository через SQL БД
type SQLUserRepository struct {
db *sql.DB
}
// NewSQLUserRepository - конструктор репозитория
func NewSQLUserRepository(db *sql.DB) *SQLUserRepository {
return &SQLUserRepository{db: db}
}
func (r *SQLUserRepository) Save(ctx context.Context, u *app.User) error {
// Здесь мы выполняем SQL INSERT или UPDATE
// Важно - доменная сущность не знает о SQL, только инфраструктура
_, err := r.db.ExecContext(
ctx,
"INSERT INTO users (id, email, password_hash) VALUES ($1, $2, $3)",
u.ID, u.Email, u.PasswordHash,
)
return err
}
func (r *SQLUserRepository) FindByEmail(ctx context.Context, email string) (*app.User, error) {
row := r.db.QueryRowContext(
ctx,
"SELECT id, email, password_hash FROM users WHERE email = $1",
email,
)
var id string
var e string
var hash string
if err := row.Scan(&id, &e, &hash); err != nil {
if err == sql.ErrNoRows {
// Не найдено - возвращаем nil без ошибки
return nil, nil
}
return nil, err
}
// Собираем сущность приложения / домена из данных БД
return &app.User{
ID: id,
Email: e,
PasswordHash: hash,
}, nil
}
Обратите внимание:
- инфраструктура зависит от интерфейсов верхних слоев (UserRepository);
- конкретная база данных никак не «просачивается» в домен и слой приложения;
- при необходимости можно заменить SQL на MongoDB или in-memory реализацию, не ломая доменный код.
Варианты слоистой архитектуры: классическая, гексагональная, чистая
Классическая трехслойная архитектура
Самый известный вариант:
- Presentation (UI, API)
- Business logic (Service)
- Data access (DAO / Repository)
Он распространен, но имеет недостатки:
- бизнес-логика часто «просачивается» в репозитории и контроллеры;
- доменная модель размыта, часто превращается в набор DTO;
- высокие риски «анемичной модели» (объекты без поведения).
Эта схема хороша как отправная точка, но для сложных доменов имеет смысл идти дальше.
Гексагональная архитектура (Hexagonal, Ports and Adapters)
Идея: домен в центре, все остальное — адаптеры:
- ядро (домен + application) — не знает о внешнем мире;
- вокруг — «порты» (интерфейсы) и «адаптеры» (конкретные реализации);
- слои по сути задаются теми же зависимостями, но теперь главное — центр и границы.
Такой подход помогает:
- проще подменять адаптеры на тестовые;
- изолировать домен от фреймворков;
- избегать ситуаций, когда домен начинает зависеть от конкретной БД или web-фреймворка.
Чистая архитектура (Clean Architecture)
В чистой архитектуре часто рисуют концентрические круги:
- в центре — сущности домена;
- вокруг — use case (слой приложения);
- дальше — интерфейсы, адаптеры;
- снаружи — фреймворки и инфраструктура.
Важно правило зависимостей:
- зависимости только внутрь, к центру;
- внешний круг может зависеть от внутреннего, но не наоборот.
Если соотнести это со слоями, получится:
- Domain (центр)
- Application (use case)
- Interface adapters (контроллеры, репозитории как интерфейсы)
- Infrastructure / framework (конкретная реализация)
Практические рекомендации по проектированию слоев
Как определить границу между слоями
Задайте себе несколько вопросов для каждого кусочка кода:
- Работает ли он с протоколом взаимодействия с внешним миром (HTTP, CLI, UI)?
- Да → Presentation.
- Описывает ли он бизнес-правило, понятное предметным экспертом?
- Да → Domain.
- Ответственен ли он за последовательность шагов use case, транзакции, координацию?
- Да → Application.
- Зависит ли он от конкретной технологии (SQL, Redis, Kafka, AWS SDK)?
- Да → Infrastructure.
Если код «проваливается» в несколько категорий сразу — скорее всего, его нужно разделить.
DTO, доменные объекты и «утечка» слоев
Частая проблема: вы начинаете использовать доменные сущности прямо в HTTP-контроллерах и репозиториях. Это удешевляет старт, но усложняет развитие.
Совет:
- используйте отдельные DTO для:
- входа/выхода API;
- обмена данными между слоями;
- доменные сущности не должны содержать JSON-тегов, ORM-аннотаций и прочих инфраструктурных деталей.
Пример неправильного подхода:
// Плохой пример - доменная сущность "знает" о JSON и ORM
type User struct {
ID string `json:"id" db:"id"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
}
Лучше разделить:
// Доменная сущность - только бизнес-логика
type User struct {
id string
email string
passwordHash string
}
// DTO для API
type UserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
}
Так коду проще эволюционировать: вы можете менять структуру хранения в БД или формат выдачи API, не переписывая доменную модель.
Обработка ошибок на разных слоях
Вы можете использовать различные уровни ошибок:
- доменные ошибки (например, ErrWeakPassword);
- ошибки инфраструктуры (ошибка сети, SQL-ошибка);
- маппинг ошибок в коды протокола (HTTP status, gRPC status).
Хорошая практика:
- доменные ошибки объявлять в доменном слое;
- прикладной слой решает, как их интерпретировать внутри use case;
- слой представления решает, какой ответ выдать клиенту.
Например:
// В домене
var ErrUserAlreadyExists = errors.New("user already exists")
// В приложении
if errors.Is(err, domain.ErrUserAlreadyExists) {
// Обрабатываем бизнес-кейс "пользователь уже есть"
}
// В HTTP-слое
if errors.Is(err, domain.ErrUserAlreadyExists) {
http.Error(w, "user already exists", http.StatusConflict)
return
}
Тестирование слоев
Слои хорошо тестируются по отдельности:
- Presentation:
- интеграционные тесты HTTP-эндпоинтов;
- Application:
- unit-тесты use case с моками репозиториев и адаптеров;
- Domain:
- unit-тесты доменных сущностей и сервисов (без БД, без сети);
- Infrastructure:
- интеграционные тесты с реальной БД / внешним сервисом (по возможности в окружении для тестов).
Так вы можете уверенно менять реализацию одного слоя, не ломая другие.
Частые ошибки при использовании архитектурных слоев и как их избежать
Ошибка 1. «Боже-контроллер» (God Controller)
Симптомы:
- контроллер содержит бизнес-логику;
- в контроллере много ветвлений и сложных условий;
- контроллер напрямую обращается к хранилищу.
Как исправить:
- вынести бизнес-логику в слой приложения или домена;
- контроллер оставить максимально тонким — только прием/ответ.
Ошибка 2. «Толстый репозиторий» с бизнес-логикой
Репозиторий начинает:
- проверять бизнес-условия;
- генерировать идентификаторы;
- выполнять кучу логики помимо сохранения/чтения.
Решение:
- репозиторий только читает и пишет;
- все бизнес-решения — в доменном или прикладном слое.
Ошибка 3. Домен зависит от фреймворка
Примеры:
- доменные сущности содержат аннотации ORM, JSON-теги;
- доменные сервисы используют типы фреймворков (например, контекст HTTP-запроса).
Как исправить:
- домен — чистые структуры и интерфейсы, только стандартная библиотека;
- адаптеры с фреймворками живут в инфраструктуре и интерфейсных слоях.
Ошибка 4. Избыточное количество слоев «ради слоев»
Иногда разработчики создают:
- сервисы, которые просто прокидывают вызов в другой сервис;
- DTO, которые один-в-один повторяют доменные сущности;
- пустые абстракции, которые ничего не скрывают.
Решение:
- каждый слой и каждая абстракция должны давать реальную пользу — изоляцию, упрощение, тестируемость;
- если слой не добавляет ценности — упростите архитектуру.
Связь слоев через зависимости и DI (Dependency Injection)
Настройка зависимостей в «композиционном корне»
Обычно есть место, где вы «собираете» приложение:
- создаете реализации репозиториев;
- создаете сервисы приложения;
- инициализируете контроллеры;
- подключаете роуты.
Покажу вам, как это может выглядеть:
// Package main - композиционный корень приложения
package main
import (
"database/sql"
"net/http"
"example.com/project/app"
"example.com/project/httpapi"
"example.com/project/infra"
)
func main() {
// Инициализируем БД
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
panic(err)
}
// Создаем инфраструктурный репозиторий
userRepo := infra.NewSQLUserRepository(db)
// Создаем инфраструктурные адаптеры (хешер, генератор id)
hasher := infra.NewBCryptHasher()
idGen := infra.NewUUIDGenerator()
// Создаем слой приложения
userService := app.NewUserService(userRepo, hasher, idGen)
// Создаем слой представления
handler := httpapi.NewHTTPHandler(userService)
// Регистрируем HTTP-маршруты
http.HandleFunc("/users/register", handler.RegisterUser)
// Запускаем HTTP-сервер
http.ListenAndServe(":8080", nil)
}
Как видите, зависимости «текут»:
- от композиционного корня к слоям;
- от верхних слоев к абстракциям нижних;
- конкретные реализации подставляются при инициализации.
Зачем нужен DI в контексте слоев
- тестируемость: можно подменить реальный репозиторий на in-memory;
- гибкость: заменили реализацию адаптера — не переписывали бизнес-логику;
- явность зависимостей: по сигнатуре конструктора видно, что нужно слою.
Заключение
Архитектурные слои — это способ структурировать систему так, чтобы:
- бизнес-логика была изолирована от технических деталей;
- изменения в одном месте не ломали код в другом;
- код оставался понятным даже при росте проекта.
Разделение на слои помогает:
- четко определить зоны ответственности;
- контролировать направление зависимостей;
- облегчить тестирование и поддержку.
При практическом применении слоистой архитектуры важно:
- не усложнять без необходимости;
- соблюдать четкие границы между слоями;
- контролировать, чтобы домен не «утекал» в инфраструктуру и наоборот;
- использовать интерфейсы и адаптеры там, где они реально упрощают жизнь.
Если вы будете смотреть на каждый фрагмент кода через призму вопроса «к какому слою он относится и почему», архитектура станет более предсказуемой и управляемой.
Частозадаваемые технические вопросы по теме и ответы
Как ограничить разработчиков от нарушения границ слоев на уровне проекта
Используйте отдельные модули / packages / namespaces для каждого слоя. В языках с модульностью (Go modules, Maven, Gradle, .NET projects) можно физически разнести слои по отдельным сборкам и запретить «обратные» зависимости через настройки сборки.
Как передавать транзакции между слоями
Создайте абстракцию UnitOfWork или Transaction в слое приложения. Он принимает фабрику транзакций (из инфраструктуры) и выполняет use case внутри замыкания. Слой домена не знает о транзакциях, он просто получает репозитории, уже «обернутые» в транзакцию.
Как реализовать кэширование по слоям
Кэш лучше размещать в инфраструктурном слое в реализации репозитория или адаптера внешнего сервиса. Например, вы создаете декоратор над репозиторием, который сначала смотрит в кэш, потом в БД. Слой домена и приложения не должны знать, кэш это или прямая БД.
Как быть с асинхронными событиями и очередями в многослойной архитектуре
Слой приложения генерирует доменные события и передает их в порт EventPublisher. Инфраструктура реализует адаптер для конкретной очереди (Kafka, RabbitMQ, SQS). Обработчики событий (консьюмеры) находятся на границе интерфейсного слоя и уже вызывают use case из приложения.
Как внедрять новые фичи без ломки существующих слоев
Добавляйте новые use case в слой приложения и, по возможности, переиспользуйте существующие доменные сущности. Если домена не хватает, расширяйте его, не смешивая старую и новую логику в одном классе без необходимости. Для кардинально новых возможностей имеет смысл добавить отдельные сервисы приложения или даже отдельные bounded context с собственной слоистой архитектурой.