Иконка подарка

Весенняя распродажа! Скидка 15% по промокоду

до 01.04.2026

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

27 марта 2026
Автор

Олег Марков

Введение

Архитектурные слои (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 и других языках:

  1. Слой представления (Presentation / Interface / API)
  2. Слой приложения (Application / Service)
  3. Доменный слой (Domain / Model)
  4. Инфраструктурный слой (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 (конкретная реализация)

Практические рекомендации по проектированию слоев

Как определить границу между слоями

Задайте себе несколько вопросов для каждого кусочка кода:

  1. Работает ли он с протоколом взаимодействия с внешним миром (HTTP, CLI, UI)?
    • Да → Presentation.
  2. Описывает ли он бизнес-правило, понятное предметным экспертом?
    • Да → Domain.
  3. Ответственен ли он за последовательность шагов use case, транзакции, координацию?
    • Да → Application.
  4. Зависит ли он от конкретной технологии (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 с собственной слоистой архитектурой.

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

Все гайды по Feature-sliced_design

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

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