Архитектурные слои в программных системах - слоеная архитектура просто и по делу

28 января 2026
Автор

Олег Марков

Введение

Архитектурные слои (layers) — один из самых распространенных способов организовать структуру приложения. Смысл подхода в том, чтобы разделить систему на уровни с разной ответственностью и ограничить, кто с кем может взаимодействовать.

Идея кажется простой: есть слой интерфейса, есть бизнес-логика, есть доступ к данным. Но на практике именно здесь часто появляются «божественные» сервисы, утечки зависимостей, слои, которые начинают знать друг о друге больше, чем должны, и код становится трудно поддерживать.

В этой статье я покажу, как мыслить слоями, какие слои обычно выделяют, как настроить зависимости между ними и как это выглядит в коде на примере. Мы поговорим о:

  • базовых принципах слоеной архитектуры
  • типичных наборах слоев (классический, DDD-подход и т. д.)
  • правилах зависимостей между слоями
  • примерах кода для простого веб-приложения
  • типичных ошибках и способах их избежать

Смотрите, я буду опираться на примеры, которые вы сможете адаптировать под свой стек — не важно, работаете ли вы с Go, Java, C#, Node.js или чем-то еще. Код будет условным, но вполне «узнаваемым».


Что такое архитектурный слой

Основная идея

Архитектурный слой — это логический уровень системы, который:

  • отвечает за определенный набор задач
  • скрывает внутреннюю реализацию за четко определенным интерфейсом
  • знает только про соседние (или нижележащие) слои, но не про все подряд

Давайте сформулируем три ключевые цели слоеной архитектуры:

  1. Разделение ответственности
  2. Ограничение зависимостей
  3. Упрощение модификации и тестирования

Когда вы добавляете новую фичу, хорошо спроектированные слои позволяют вам:

  • понять, в какой слой должна попасть логика
  • не «протаскивать» детали базы данных в пользовательский интерфейс
  • не вызывать HTTP прямо из слоя работы с БД и т. д.

Вертикальное и горизонтальное разбиение

Важно различать два вида разбиения:

  • Горизонтальное — собственно слои (UI, бизнес-логика, инфраструктура и т. д.).
  • Вертикальное — разбиение по функциональным областям (модули, bounded contexts, фичи).

Слои — это «слои по горизонтали», а доменные модули — «разрезы по вертикали». На практике вы почти всегда комбинируете оба подхода: у вас есть, например, модуль Users, и внутри него свои слои: контроллер, сервис, репозиторий и т. д.


Типовые наборы архитектурных слоев

Классическая трехслойная архитектура

Самый популярный набор слоев:

  1. Слой представления (Presentation / UI)
  2. Слой бизнес-логики (Domain / Application / Service)
  3. Слой доступа к данным (Data Access / Persistence)

Иногда добавляют еще один слой:

  1. Инфраструктурный слой (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/domain
  • user/application
  • user/infrastructure
  • user/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-типы в доменные сущности

то вы уже используете слоеную архитектуру осознанно.

В реальных проектах структура может отличаться — добавляться кэш, очереди, отдельные модули. Но базовые принципы остаются теми же: каждый слой отвечает за свое, зависимости направлены сверху вниз, а детали реализации спрятаны за понятными интерфейсами.


Частозадаваемые технические вопросы по теме статьи и ответы на них

Как предотвратить нарушение границ слоев в большом проекте

Используйте несколько уровней защиты:

  1. Явные правила импортов — договоритесь, какие пакеты могут импортировать какие.
  2. Линтеры и статический анализ — для некоторых языков есть плагины, проверяющие архитектурные зависимости.
  3. Code review — на ревью обращайте внимание на утечки типов между слоями.

Когда лучше использовать интерфейсы между слоями, а когда прямые зависимости

Интерфейсы стоит вводить, когда:

  • нижележащая реализация может меняться (разные БД, разные адаптеры)
  • слой нужно легко подменять в тестах

Если реализация одна и вряд ли изменится, а тесты можно писать без моков, прямые зависимости допустимы — они проще и понятнее.

Можно ли объединять доменный и прикладной слой в одном модуле

Да, для небольших проектов это распространенный подход. Главное — даже в одном модуле логически разделять:

  • сущности и бизнес-правила
  • сценарии использования и оркестрацию

Так вам будет проще при необходимости «разнести» это по разным слоям позже.

Как быть с транзакциями в слоеной архитектуре

Обычно транзакции управляются:

  • либо на уровне application слоя (сервис открывает транзакцию и передает ее в репозитории)
  • либо через единый UnitOfWork, который виден приложению и инфраструктуре

Важно не прятать транзакции в произвольных местах инфраструктуры, иначе управление бизнес-сценариями станет неявным.

Как согласовать разные модели данных между слоями (DTO, доменные сущности, ORM-модели)

Используйте явное маппирование:

  • DTO представления ↔ DTO application слоя
  • DTO application слоя ↔ доменные сущности
  • доменные сущности ↔ ORM-модели

Это добавляет немного кода, но делает границы явными и позволяет менять один уровень, не ломая другие. Лучше потратить немного времени на маппинг, чем потом отлавливать скрытые связи между слоями.

Стрелочка влевоИзоляция модулей - подробно о module-isolation на практических примерах

Все гайды по Fsd

Открыть базу знаний

Отправить комментарий