Олег Марков
Архитектурные слои в программных системах - слоеная архитектура просто и по делу
Введение
Архитектурные слои (layers) — один из самых распространенных способов организовать структуру приложения. Смысл подхода в том, чтобы разделить систему на уровни с разной ответственностью и ограничить, кто с кем может взаимодействовать.
Идея кажется простой: есть слой интерфейса, есть бизнес-логика, есть доступ к данным. Но на практике именно здесь часто появляются «божественные» сервисы, утечки зависимостей, слои, которые начинают знать друг о друге больше, чем должны, и код становится трудно поддерживать.
В этой статье я покажу, как мыслить слоями, какие слои обычно выделяют, как настроить зависимости между ними и как это выглядит в коде на примере. Мы поговорим о:
- базовых принципах слоеной архитектуры
- типичных наборах слоев (классический, DDD-подход и т. д.)
- правилах зависимостей между слоями
- примерах кода для простого веб-приложения
- типичных ошибках и способах их избежать
Смотрите, я буду опираться на примеры, которые вы сможете адаптировать под свой стек — не важно, работаете ли вы с Go, Java, C#, Node.js или чем-то еще. Код будет условным, но вполне «узнаваемым».
Что такое архитектурный слой
Основная идея
Архитектурный слой — это логический уровень системы, который:
- отвечает за определенный набор задач
- скрывает внутреннюю реализацию за четко определенным интерфейсом
- знает только про соседние (или нижележащие) слои, но не про все подряд
Давайте сформулируем три ключевые цели слоеной архитектуры:
- Разделение ответственности
- Ограничение зависимостей
- Упрощение модификации и тестирования
Когда вы добавляете новую фичу, хорошо спроектированные слои позволяют вам:
- понять, в какой слой должна попасть логика
- не «протаскивать» детали базы данных в пользовательский интерфейс
- не вызывать HTTP прямо из слоя работы с БД и т. д.
Вертикальное и горизонтальное разбиение
Важно различать два вида разбиения:
- Горизонтальное — собственно слои (UI, бизнес-логика, инфраструктура и т. д.).
- Вертикальное — разбиение по функциональным областям (модули, bounded contexts, фичи).
Слои — это «слои по горизонтали», а доменные модули — «разрезы по вертикали». На практике вы почти всегда комбинируете оба подхода: у вас есть, например, модуль Users, и внутри него свои слои: контроллер, сервис, репозиторий и т. д.
Типовые наборы архитектурных слоев
Классическая трехслойная архитектура
Самый популярный набор слоев:
- Слой представления (Presentation / UI)
- Слой бизнес-логики (Domain / Application / Service)
- Слой доступа к данным (Data Access / Persistence)
Иногда добавляют еще один слой:
- Инфраструктурный слой (Infrastructure) — обертки над внешними сервисами, кэш, очереди и т. д.
Смотрите, коротко:
- Presentation: принимает запросы от пользователя (браузер, мобильное приложение, API-клиент), валидирует ввод, вызывает бизнес-логику, формирует ответ.
- Business (Application + Domain): содержит правила предметной области, сценарии использования, валидацию на уровне домена, оркестрацию.
- Data / Infrastructure: отвечает за хранение и вызов внешних систем.
Слоистая архитектура по DDD
В контексте Domain-Driven Design часто выделяют:
- Application layer — сценарии использования (use cases)
- Domain layer — сущности, value objects, доменные сервисы
- Infrastructure layer — имплементации репозиториев, интеграции
- Interface / Presentation layer — API, UI
Разница в том, что домен отделяется от прикладной логики, а приложения (Application layer) ничего не знают о технических деталях.
Принципы взаимодействия между слоями
Правило направленных зависимостей
Ключевой принцип: зависимости направлены сверху вниз.
- Верхний слой может вызывать нижний
- Нижний слой не должен знать о верхнем
- Данные могут «подниматься» наверх, но это не означает зависимость от кода верхнего уровня
Например:
- Контроллер вызывает сервис
- Сервис вызывает репозиторий
- Репозиторий не знает, кто его вызвал (контроллер, фоновый джоб и т. д.)
Если нижний слой начнет зависеть от типов верхнего слоя (например, репозиторий возвращает HttpResponse), архитектура начинает разрушаться.
Явные контракты между слоями
Каждый слой должен общаться с соседним через понятные контракты:
- интерфейсы
- DTO (объекты передачи данных)
- четко определенные методы
Здесь важно не путать: контракт не обязан быть слабо типизированным, наоборот, лучше использовать строгие типы, но:
- DTO для UI может отличаться от доменной модели
- сущности БД могут отличаться от доменных сущностей
- объекты запросов/ответов API не обязаны совпадать с тем, что у вас в бизнес-логике
Пример слоистой архитектуры для веб-приложения
Давайте разберемся на примере простого приложения управления пользователями. Задача: реализовать операцию создания пользователя.
Слои:
- Presentation: HTTP-контроллер
- Application: сервис сценариев использования
- Domain: сущность User, доменные правила
- Infrastructure: репозиторий, работа с БД
Для иллюстрации возьмем псевдокод, близкий к Go/TypeScript.
Структура проекта
Представим, что у нас такой набор пакетов (директорий):
presentation/http— контроллеры, обработчики запросовapplication— сервисы сценариев (use cases)domain/user— сущности и интерфейсыinfrastructure/db— реализация репозиториев
Слой домена (Domain Layer)
Модель пользователя и правила
Сначала опишем доменную сущность и бизнес-правила. Здесь я размещаю пример, чтобы вам было проще понять:
// package domain
// User - доменная сущность пользователя
type User struct {
ID string
Email string
Name string
Active bool
}
// NewUser - фабричный метод создания пользователя с доменной валидацией
func NewUser(email string, name string) (*User, error) {
// Здесь мы проверяем базовые доменные правила
if email == "" {
// В домене лучше возвращать осмысленные ошибки
return nil, fmt.Errorf("email is required")
}
if len(name) < 2 {
return nil, fmt.Errorf("name is too short")
}
// Как видите, домен ничего не знает о HTTP, БД и т. д.
// Он оперирует только своими понятиями.
return &User{
ID: "", // Идентификатор может присваиваться позже
Email: email,
Name: name,
Active: true,
}, nil
}
Ключевой момент: доменная модель:
- не знает про базу данных
- не знает про HTTP
- не использует структуры запросов/ответов API
Интерфейс репозитория в домене
Давайте посмотрим, что происходит в следующем примере:
// UserRepository - интерфейс, описывающий, что нужно домену
type UserRepository interface {
// Save - сохранить или обновить пользователя
Save(user *User) error
// FindByEmail - найти пользователя по email
FindByEmail(email string) (*User, error)
}
Обратите внимание:
- В домене мы описываем, какие операции нам нужны
- Мы не говорим, как именно это реализовано (PostgreSQL, MongoDB, файл, память)
- Инфраструктура позже реализует этот интерфейс
Прикладной слой (Application Layer)
Задача прикладного слоя
Application layer отвечает за сценарии использования (use cases):
- «Создать пользователя»
- «Активировать пользователя»
- «Сбросить пароль»
Он не должен содержать технических деталей, но может:
- вызывать доменные сущности и доменные сервисы
- orchestrate (оркестрировать) несколько доменных операций
- работать с трансформацией DTO между слоями
Пример сервиса «Создать пользователя»
// package application
// CreateUserCommand - входные данные сценария (DTO уровня приложения)
type CreateUserCommand struct {
Email string
Name string
}
// CreateUserResult - результат сценария
type CreateUserResult struct {
ID string
Email string
Name string
}
// UserService - прикладной сервис, использующий домен и репозиторий
type UserService struct {
// repo - интерфейс репозитория, определенный в доменном слое
repo domain.UserRepository
// idGenerator - абстракция генерации идентификаторов
idGenerator func() string
}
// NewUserService - конструктор сервиса
func NewUserService(repo domain.UserRepository, idGen func() string) *UserService {
return &UserService{repo: repo, idGenerator: idGen}
}
// CreateUser - сценарий создания пользователя
func (s *UserService) CreateUser(cmd CreateUserCommand) (*CreateUserResult, error) {
// 1. Проверяем, что пользователя с таким email еще нет
existing, err := s.repo.FindByEmail(cmd.Email)
if err != nil {
return nil, fmt.Errorf("failed to check existing user: %w", err)
}
if existing != nil {
return nil, fmt.Errorf("user with this email already exists")
}
// 2. Создаем доменную сущность через фабричный метод
user, err := domain.NewUser(cmd.Email, cmd.Name)
if err != nil {
return nil, err
}
// 3. Назначаем идентификатор доменному объекту
user.ID = s.idGenerator()
// 4. Сохраняем пользователя через репозиторий
if err := s.repo.Save(user); err != nil {
return nil, fmt.Errorf("failed to save user: %w", err)
}
// 5. Возвращаем DTO результата
return &CreateUserResult{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}, nil
}
Как видите:
- Application слой знает про домен (
domain.User,domain.UserRepository) - Он ничего не знает о HTTP, SQL-запросах, JSON и т. д.
- Это делает слой легко тестируемым: вы можете подставить фейковый репозиторий и протестировать сценарij.
Слой инфраструктуры (Infrastructure Layer)
Роль инфраструктурного слоя
Инфраструктура реализует:
- интерфейсы репозиториев
- клиентские обертки над внешними API
- кэш
- логирование, отправку писем и т. д.
Главная идея: инфраструктура подстраивается под домен и прикладной слой, а не наоборот.
Реализация репозитория пользователей
Теперь вы увидите, как это выглядит в коде:
// package infrastructure
// UserRepositorySQL - реализация доменного интерфейса UserRepository для SQL БД
type UserRepositorySQL struct {
db *sql.DB // Здесь мы храним соединение с базой данных
}
// NewUserRepositorySQL - конструктор репозитория
func NewUserRepositorySQL(db *sql.DB) *UserRepositorySQL {
return &UserRepositorySQL{db: db}
}
// Save - сохраняем пользователя в БД
func (r *UserRepositorySQL) Save(user *domain.User) error {
// Здесь используем SQL для сохранения данных
// Реализация зависит от конкретной БД и драйвера
query := `
INSERT INTO users (id, email, name, active)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET email = $2, name = $3, active = $4
`
_, err := r.db.Exec(query, user.ID, user.Email, user.Name, user.Active)
if err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
return nil
}
// FindByEmail - ищем пользователя по email
func (r *UserRepositorySQL) FindByEmail(email string) (*domain.User, error) {
query := `
SELECT id, email, name, active
FROM users
WHERE email = $1
`
row := r.db.QueryRow(query, email)
var user domain.User
if err := row.Scan(&user.ID, &user.Email, &user.Name, &user.Active); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Если пользователь не найден - возвращаем nil без ошибки
return nil, nil
}
return nil, fmt.Errorf("failed to scan row: %w", err)
}
return &user, nil
}
Обратите внимание:
- Реализация привязана к
*sql.DBи SQL-синтаксису - Но интерфейс, который ожидает домен и прикладной слой, не изменился
- Если вы захотите перейти на другую БД (например, MongoDB), вы реализуете новый репозиторий, не трогая домен и application слой
Слой представления (Presentation Layer)
Основная задача
Presentation слой отвечает за:
- прием запросов от клиентов
- парсинг параметров (тело запроса, query-параметры, заголовки)
- первичную валидацию формата данных
- вызов application layer
- формирование ответа (JSON, HTML, gRPC-ответ и т. д.)
Здесь не должно быть бизнес-логики. Любое «если пользователь уже существует, то…» должно находиться выше (в application или домене).
HTTP-контроллер для создания пользователя
Покажу вам, как это реализовано на практике:
// package presentation
// CreateUserRequest - структура для парсинга JSON запроса
type CreateUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
// CreateUserResponse - структура ответа клиенту
type CreateUserResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
// UserHandler - HTTP-обработчик, использующий UserService
type UserHandler struct {
service *application.UserService
}
// NewUserHandler - конструктор обработчика
func NewUserHandler(service *application.UserService) *UserHandler {
return &UserHandler{service: service}
}
// HandleCreateUser - обработка POST /users
func (h *UserHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
// 1. Парсим тело запроса
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Здесь мы возвращаем ошибку формата данных
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
// 2. Простая валидация уровня представления (например, обязательные поля)
if req.Email == "" || req.Name == "" {
http.Error(w, "email and name are required", http.StatusBadRequest)
return
}
// 3. Формируем команду для application слоя
cmd := application.CreateUserCommand{
Email: req.Email,
Name: req.Name,
}
// 4. Вызываем сценарий создания пользователя
result, err := h.service.CreateUser(cmd)
if err != nil {
// Здесь мы решаем, какой статус вернуть в зависимости от типа ошибки
// Для простоты считаем все доменные ошибки 400
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// 5. Формируем ответ
resp := CreateUserResponse{
ID: result.ID,
Email: result.Email,
Name: result.Name,
}
// 6. Отправляем JSON-ответ
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(resp); err != nil {
// Если мы не смогли отправить ответ - логируем ошибку
// Обычно это уже инфраструктурная логика
fmt.Println("failed to write response:", err)
}
}
Как видите:
- Контроллер знает про HTTP и JSON
- Он не знает, как устроена БД
- Он не создает доменные объекты напрямую, а делегирует это сервису
Связывание слоев между собой
Сборка зависимостей (Composition Root)
Где-то наверху (чаще всего в точке входа приложения) слои нужно связать между собой:
- создать подключение к БД
- создать инфраструктурный репозиторий
- передать его в application service
- передать сервис в контроллер
- зарегистрировать контроллер в HTTP-роутере
Пример:
// package main
func main() {
// 1. Настраиваем соединение с базой данных
db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")
if err != nil {
panic(err) // В реальном коде лучше обработать ошибку аккуратнее
}
defer db.Close()
// 2. Создаем инфраструктурный репозиторий
userRepo := infrastructure.NewUserRepositorySQL(db)
// 3. Создаем генератор идентификаторов
idGen := func() string {
// Здесь, например, можно использовать UUID
return uuid.New().String()
}
// 4. Создаем application сервис
userService := application.NewUserService(userRepo, idGen)
// 5. Создаем HTTP-обработчик
userHandler := presentation.NewUserHandler(userService)
// 6. Настраиваем роутер и регистрируем маршрут
mux := http.NewServeMux()
mux.HandleFunc("/users", userHandler.HandleCreateUser)
// 7. Запускаем HTTP сервер
fmt.Println("Server is running on http://localhost:8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
panic(err)
}
}
Смотрите, важный момент:
- «Зависимости текут» сверху вниз: main → инфраструктура → application → домен
- Но домен и application не знают о main, HTTP, sql.DB и т. д.
- Это и есть суть слоеной архитектуры: верхний слой «композирует» нижележащие
Типичные варианты разбиения на слои
Минимальный набор слоев для небольших проектов
Для небольшого сервиса, где не хочется слишком усложнять, часто делают:
handlers— HTTP-обработчикиservices— бизнес-логика (объединяет domain и application)repos— работа с базой данных
Такой вариант проще, но важно все равно:
- не тащить в
servicesдетали инфраструктуры - не позволять
reposзависеть от HTTP и DTO уровня представления
Модульная слоеная архитектура
Более масштабируемый подход:
- каждый доменный модуль (например,
user,order,billing) - внутри себя имеет мини-набор слоев:
Пример структуры:
user/domainuser/applicationuser/infrastructureuser/presentation
Такой подход помогает:
- изолировать домены друг от друга
- уменьшить размер слоев
- проще выделять модули в отдельные сервисы (микросервисы), если потребуется
Распространенные ошибки при проектировании слоев
1. Слои только «для галочки»
Частая ситуация: в проекте есть папки controllers, services, repositories, но:
- сервисы начинают знать о HTTP-реквестах
- репозитории начинают возвращать HTTP-статусы
- доменные сущности «знают» про таблицы БД
По сути, это уже не слоеная архитектура, а просто разнесенный по файлам код.
Как избежать:
- следить за зависимостями: кто может импортировать что
- вводить правила (например, через линтер или ревью): инфраструктура не импортирует presentation, домен не импортирует infrastructure
- не передавать между слоями «чужие» типы (например,
*http.Requestв сервис)
2. «Анемичная» доменная модель
Иногда доменный слой превращается просто в набор структур без логики, а вся бизнес-логика оказывается в application или даже в контроллерах.
Это упрощает старт, но со временем логика размазывается по слоям, и становится трудно понять, где правила домена, а где — технические детали.
Рекомендация:
- переносить проверку бизнес-правил в домен (конструкторы, методы сущностей, доменные сервисы)
- оставлять в application только сценарии (оркестрацию)
3. Утечка инфраструктуры наверх
Примеры:
- передача
sql.Rowв application слой - использование типов ORM (например,
gorm.Model) в доменных сущностях - возвращение из домена ошибок с HTTP-кодами
Чем это плохо:
- домен становится не переиспользуемым
- тестировать чистую бизнес-логику становится сложно
- любая смена ORM/библиотеки требует правок во всех слоях
Лучше:
- маппить (преобразовывать) инфраструктурные типы в доменные на границе слоев
- использовать доменные сущности как основной формат данных в бизнес-логике
4. Слишком много слоев без реальной пользы
Иногда разработчики создают:
- 5 уровней абстракций перед обращением к репозиторию
- по 2–3 интерфейса на каждый метод
- «слой над слоем» без четкого смысла
В результате:
- становится трудно понять, где что происходит
- маленькое изменение требует правок в 10 местах
- новичкам сложно разобраться в проекте
Подход:
- добавляйте слой или абстракцию только тогда, когда у нее есть понятная ответственность и выгода
- не бойтесь упростить, если слой не приносит ценности
Как выбрать набор слоев под свой проект
Критерии выбора
На что стоит ориентироваться:
- Размер и срок жизни проекта
- Количество разработчиков
- Вероятность изменений (бизнес-правила, база данных, интерфейсы)
- Наличие сложной доменной логики
Примерные рекомендации:
- Маленький сервис, простой CRUD: 2–3 слоя достаточно
- Средний продукт с растущей функциональностью: классическая трехслойная модель (UI / Application / Infrastructure)
- Проект со сложным доменом: стоит рассмотреть выделение доменного слоя по DDD
Что важно в любом случае
Независимо от количества слоев, сохраняйте:
- явные границы ответственности
- направление зависимостей сверху вниз
- минимизацию утечки деталей нижележащих слоев наверх
Тестирование в разрезе слоев
Юнит-тесты домена
Доменный слой тестируется проще всего:
- вы создаете сущности
- вызываете их методы
- проверяете бизнес-правила
Преимущество в том, что не нужно поднимать БД, HTTP-сервер и т. д.
Тестирование прикладного слоя
Для application слоя удобно использовать подмены (mocks) репозиториев:
- сервис получает
UserRepositoryкак интерфейс - в тесте вы передаете фейковую реализацию
- проверяете, что сервис корректно вызывает методы репозитория и обрабатывает сценарии
Интеграционные тесты инфраструктуры
Слой инфраструктуры тестируется с реальной или тестовой БД / внешним сервисом:
- проверка SQL-запросов
- миграций
- трансформации данных
Важно: такие тесты обычно тяжелее и медленнее, их запускают реже (например, в CI).
Когда слоеная архитектура может быть избыточной
Иногда проект настолько прост, что строгие слои только мешают:
- небольшой скрипт или утилита
- прототип, который может быть выброшен
- экспериментальная фича
В таких случаях можно:
- начать с более простой структуры (например, handler + repository)
- следить за тем, чтобы не смешивать все в одном месте
- постепенно выделять слои, когда появляются новые требования и сложность
Главное — помнить, что слои — это инструмент, а не самоцель.
Архитектурные слои помогают структурировать систему, сделать зависимости управляемыми и подготовить проект к изменениям. Если вы:
- отделяете домен от инфраструктуры
- держите бизнес-логику вне контроллеров
- не тащите HTTP/SQL-типы в доменные сущности
то вы уже используете слоеную архитектуру осознанно.
В реальных проектах структура может отличаться — добавляться кэш, очереди, отдельные модули. Но базовые принципы остаются теми же: каждый слой отвечает за свое, зависимости направлены сверху вниз, а детали реализации спрятаны за понятными интерфейсами.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как предотвратить нарушение границ слоев в большом проекте
Используйте несколько уровней защиты:
- Явные правила импортов — договоритесь, какие пакеты могут импортировать какие.
- Линтеры и статический анализ — для некоторых языков есть плагины, проверяющие архитектурные зависимости.
- Code review — на ревью обращайте внимание на утечки типов между слоями.
Когда лучше использовать интерфейсы между слоями, а когда прямые зависимости
Интерфейсы стоит вводить, когда:
- нижележащая реализация может меняться (разные БД, разные адаптеры)
- слой нужно легко подменять в тестах
Если реализация одна и вряд ли изменится, а тесты можно писать без моков, прямые зависимости допустимы — они проще и понятнее.
Можно ли объединять доменный и прикладной слой в одном модуле
Да, для небольших проектов это распространенный подход. Главное — даже в одном модуле логически разделять:
- сущности и бизнес-правила
- сценарии использования и оркестрацию
Так вам будет проще при необходимости «разнести» это по разным слоям позже.
Как быть с транзакциями в слоеной архитектуре
Обычно транзакции управляются:
- либо на уровне application слоя (сервис открывает транзакцию и передает ее в репозитории)
- либо через единый
UnitOfWork, который виден приложению и инфраструктуре
Важно не прятать транзакции в произвольных местах инфраструктуры, иначе управление бизнес-сценариями станет неявным.
Как согласовать разные модели данных между слоями (DTO, доменные сущности, ORM-модели)
Используйте явное маппирование:
- DTO представления ↔ DTO application слоя
- DTO application слоя ↔ доменные сущности
- доменные сущности ↔ ORM-модели
Это добавляет немного кода, но делает границы явными и позволяет менять один уровень, не ломая другие. Лучше потратить немного времени на маппинг, чем потом отлавливать скрытые связи между слоями.