← назад к методологии · ступень 1 из четырёх (0–3)

Почти каждый сервис начинается одинаково: контроллер вызывает сервис, сервис идёт в репозиторий. Эту схему называют слоёной архитектурой. Она простая, понятная и в большинстве случаев работает отлично — до тех пор, пока сервис не разрастается до полусотни методов.

Обязательно

Что такое слоёная архитектураспросят на собеседовании

Три слоя, выстроенных в цепочку:

HTTP-запрос Controller принимает запрос, зовёт сервис, отдаёт ответ Service бизнес-логика, проверки, оркестрация Repository запросы в базу данных

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

Каждый слой отвечает за своё. Контроллер не лезет в базу — это дело репозитория. Репозиторий не принимает HTTP — это дело контроллера. Сервис в середине держит логику.

Код выглядит примерно так:

@RestController
class OrderController {
    private final OrderService service;

    @PostMapping("/orders")
    OrderResponse create(@RequestBody CreateOrderRequest req) {
        return service.createOrder(req);
    }
}

@Service
public class OrderService {
    private final OrderRepository repo;

    @Transactional
    public OrderResponse createOrder(CreateOrderRequest req) {
        // проверки, логика, сохранение
        Order order = new Order(req.getCustomerId(), req.getItems());
        repo.save(order);
        return OrderResponse.from(order);
    }
}
type OrderHandler struct {
	service *OrderService
}

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
	}
	resp, err := h.service.CreateOrder(r.Context(), req)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	json.NewEncoder(w).Encode(resp)
}

type OrderService struct {
	repo *OrderRepository
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (OrderResponse, error) {
	// проверки, логика, сохранение
	order := NewOrder(req.CustomerID, req.Items)
	if err := s.repo.Save(ctx, order); err != nil {
		return OrderResponse{}, err
	}
	return OrderResponseFrom(order), nil
}
@Controller('orders')
class OrderController {
  constructor(private readonly service: OrderService) {}

  @Post()
  create(@Body() req: CreateOrderRequest): Promise<OrderResponse> {
    return this.service.createOrder(req);
  }
}

@Injectable()
class OrderService {
  constructor(private readonly repo: OrderRepository) {}

  async createOrder(req: CreateOrderRequest): Promise<OrderResponse> {
    // проверки, логика, сохранение
    const order = new Order(req.customerId, req.items);
    await this.repo.save(order);
    return OrderResponse.from(order);
  }
}
router = APIRouter()


@router.post("/orders")
async def create(req: CreateOrderRequest, service: OrderService = Depends()) -> OrderResponse:
    return await service.create_order(req)


class OrderService:
    def __init__(self, repo: OrderRepository) -> None:
        self._repo = repo

    async def create_order(self, req: CreateOrderRequest) -> OrderResponse:
        # проверки, логика, сохранение
        order = Order(req.customer_id, req.items)
        await self._repo.save(order)
        return OrderResponse.from_order(order)

Никаких специальных абстракций — только три класса и их взаимодействие.

В Java-варианте две вещи стоит заметить. Первая: класс и метод объявлены public не случайно. Spring по умолчанию оборачивает бин в прокси, а прокси перехватывает только публичные методы — повесьте @Transactional на пакетно-приватный метод, и аннотация молча ничего не сделает: транзакции не будет, а ошибки тоже не будет.

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

Когда этого достаточно

Слоёная архитектура отлично подходит для:

  • Настоящего CRUD. Справочники, словари, простые административные панели. Операции — создать, прочитать, изменить, удалить. Никакой сложной логики внутри.
  • Прототипа или короткоживущего сервиса. Если сервис проживёт год-два и потом будет переписан или выключен — вкладываться в сложную структуру не имеет смысла.
  • Маленькой команды с понятным доменом. Когда домен укладывается в голове за полчаса, а инвариантов почти нет, дополнительные абстракции только мешают.
  • Тонкого прокси. Сервис принимает запрос и перекладывает его в другой сервис или внешнее API — бизнес-логики минимум.

Уровень 1 — не «недоделанный» уровень. Для части сервисов он остаётся достаточным навсегда. Добавлять сложность ради сложности — ошибка.

Про последний пункт стоит договорить очевидное продолжение: в тонком прокси обычно не нужен и сервисный слой. Если операция состоит из «принять запрос, перевести в чужой формат, вызвать, перевести ответ обратно», то сервис-посредник не делает ничего, кроме передачи — это слой, который добавляет файл и не добавляет смысла.

Рабочая раскладка для прокси: контроллер зовёт клиента внешней системы напрямую, а преобразование форматов живёт в отдельном преобразователе. Сервисный слой заводят тогда, когда появляется хоть что-то из этого списка:

  • решение, к какой из нескольких внешних систем идти;
  • склейка двух вызовов в один ответ;
  • кеширование, повторы, запасной путь при отказе;
  • собственные проверки до вызова;
  • своё хранилище, пусть даже журнал вызовов.

До этого момента «контроллер → клиент» честнее: меньше кода и видно, что своей логики нет. И это не «нарушение слоёв», а признание того, что второй слой пуст.

Что должно быть на этом уровне

Тонкие контроллеры. Контроллер делает ровно одно: принимает запрос, зовёт сервис, возвращает ответ. Никакой логики внутри контроллера.

Сервисы по предметным областям. OrderService, CustomerService — каждый отвечает за свою тему. Логика и обращения к репозиторию живут внутри сервиса.

Транзакция на уровне метода. Одна операция — один @Transactional метод. Либо всё сохраняется, либо ничего.

Одна модель данных. То, что приходит из API, и то, что лежит в базе, связано простым маппингом. Отдельной доменной модели и объектов-значений на этом уровне нет — они появляются на Уровне 3.

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

Первый практический вопрос уровня: оплата заказа — это OrderService или PaymentService? Операция трогает обе области, и по названию не выбрать.

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

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

Практическая раскладка, которая избавляет от спора:

СитуацияГде операцияПочему
Меняется состояние одной областитамочевидный случай
Меняются обе, но одна ведёт процессу ведущейвторая — шаг внутри
Меняются обе, и они равноправныу третьей, координирующейпризнак, что появился процесс
Ничего не меняется, только чтение из двух месту того, кто отдаёт результатэто запрос, а не команда

Три правила гигиены, без которых расползается любой вариант:

  • Односторонние зависимости. OrderService зовёт PaymentService, но не наоборот. Взаимный вызов двух сервисов — самый частый способ получить неразбираемый клубок, и на уровне 1 он ничем не ограничен, кроме дисциплины.
  • Операция не дублируется. Если «оплатить заказ» есть в обоих сервисах, один из них — обёртка, и её надо удалить, а не поддерживать «на всякий случай».
  • Третий случай в таблице — сигнал. Когда операция равно принадлежит двум областям, на уровне 1 её обычно кладут в тот сервис, куда первым посмотрели. Это работает, пока таких операций одна-две; когда их пять, это и есть та самая потеря явности, о которой раздел ниже.

Ошибки и валидация на этом уровне

Раздел про то, что должно быть, молчит о самом частом источнике беспорядка: где проверяются входные данные, где ловятся исключения и что в итоге видит клиент. На уровне 1 ответ проще, чем на следующих, но его надо дать.

Три места проверки, и они не заменяют друг друга.

Что проверяемГдеЧто при нарушении
Форма запроса: обязательность, длина, диапазон, форматна границе, аннотациями валидации на модели запроса400, до вызова сервиса
Существование и правав начале метода сервиса404 или 403
Бизнес-правило: статус, остаток, лимитв методе сервиса, рядом с изменением409 или 422

Смысл разделения: форма проверяется до транзакции, правило — внутри неё. Проверка формата в сервисе означает, что до неё уже открыли транзакцию и сходили в базу; проверка остатка на границе означает, что между проверкой и изменением остаток мог поменяться.

Исключения бросает сервис, переводит их в ответ — одно место. Обработчик ошибок на границе, единый на приложение: он знает, какое исключение в какой статус и какое тело ответа превращается. Ловить и превращать в контроллере — самый дорогой из вариантов: сколько контроллеров, столько и вариантов ответа на одну и ту же ошибку.

Свои исключения, а не общие. OrderNotFound и OrderAlreadyPaid — разные классы, потому что у них разные статусы и разные коды для клиента. Общий IllegalStateException на всё означает, что различать их придётся по тексту сообщения, а текст поменяется при первой правке.

Что уходит клиенту — одинаково для всех ошибок. Код ошибки (машиночитаемый), сообщение (человекочитаемое, без внутренних деталей), идентификатор запроса для поиска в журнале. Ни стека, ни имени класса, ни текста ошибки базы: это утечка устройства наружу.

Слабое место уровня 1, о котором стоит знать заранее: единый порядок держится только дисциплиной. Нет точки, через которую проходят все операции, поэтому «в этом методе проверили права, а в том забыли» ловится только ревью. Именно это и становится одним из доводов за следующий уровень.

Где это начинает мешатьспросят на собеседовании

Слоёная модель хороша до тех пор, пока сервис остаётся небольшим. Проблемы начинаются, когда он растёт:

Это не «плохой код» — это предел слоёной модели: у неё просто нет механизма явно выделять отдельные операции. Проявляется это в четырёх местах.

Сервис обрастает десятками методов. OrderService с тридцатью методами — это уже не очевидная структура. Непонятно, какие из них реальные бизнес-операции, а какие — вспомогательные. Список операций приходится собирать вручную, просматривая сигнатуры.

Одна операция вызывается из нескольких мест с отличиями. Если одну операцию вызывают из трёх контроллеров с чуть разными проверками, эти проверки со временем расходятся — появляются незаметные ошибки.

createOrder() веб-контроллер права и лимит админ-панель только права обработчик очереди без проверок заказ в базе

Одну и ту же операцию зовут из трёх мест, и набор проверок у каждого свой; смотрите на пояснения слева, расхождение видно сразу.

Нет единой точки контроля. Метрики на операцию, аудит, единый способ обработки ошибок — всё это приходится добавлять в каждый метод руками. Легко пропустить, сложно поддерживать.

Сложно тестировать операцию изолированно. Тест бизнес-операции тянет за собой весь сервис-класс со всеми зависимостями. Нет способа запустить только одну операцию без остальных.

Операция не влезает в один метод. Пятое место, которое ломает слоёную модель раньше остальных и о котором реже всего говорят: транзакция перестаёт совпадать с методом.

Случаи, где это происходит:

  • Операция трогает две области, и обе меняют данные. Списать со счёта и подтвердить заказ. Пока обе части в одной базе, спасает один транзакционный метод, который зовёт оба сервиса, — но тогда транзакция начинается в третьем месте, и правило «транзакция на уровне метода сервиса» уже нарушено.
  • Внутри операции есть вызов наружу. Обращение к платёжному шлюзу внутри открытой транзакции — классическая ошибка: сеть медленная, транзакция держит блокировки, база задыхается на пиках. Правильный порядок — готовим в транзакции, вызываем снаружи, фиксируем результат новой транзакцией, но на уровне 1 это уже три метода вместо одного.
  • Операция трогает два сервиса в разных базах. Общей транзакции нет вовсе: либо согласованность с задержкой и компенсация, либо исходящее сообщение. Ни то ни другое в слоёную схему не умещается.
  • Долгая операция. Формирование отчёта, массовое изменение. Держать транзакцию минутами нельзя; работа режется на части, и каждая — своя транзакция.

Что с этим делают, не покидая уровень 1:

  • Не вызывать внешние системы внутри транзакции. Самое важное правило, и оно не требует смены уровня: разнести подготовку и вызов по разным методам.
  • Сделать операцию повторяемой. Если после внешнего вызова что-то упало, повтор не должен списать второй раз: ключ повтора и проверка «уже сделано» решают большинство случаев.
  • Явный координирующий метод. Когда операция ведёт два сервиса, честнее завести для неё отдельный метод-координатор и признать, что это операция, а не часть OrderService. Это, по сути, и есть шаг к следующему уровню.

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

Что фиксируется в спецификации на этом уровне

Если проект использует Use Case спецификацию, на уровне 1 она минимальна: глоссарий, роли, схема данных и список операций. Агрегатов и доменных событий нет — они помечаются «не применимо на Уровне 1». Центральная сущность всё равно выносится в отдельный файл — формат раскладки единый для всех уровней.

Что именно пишут в файл домен-юнита, когда агрегатов нет, — вопрос, который встаёт сразу, потому что формат раскладки единый, а агрегата в коде нет. В файл кладут центральную сущность контекста, и разделы заполняются так:

Раздел файлаЧто на уровне 1
Доменная модельтаблица полей центральной сущности плюс связанные таблицы: по сути ER, а не диаграмма классов; пометка «агрегаты в коде не выделены»
Жизненный циклстатусы из перечисления и матрица переходов — заполняется полностью, это самая полезная часть файла на этом уровне
Доступматрица «операция × роль» по тому, что реально проверяется в сервисе
Бизнес-правиласписок правил с кодами, даже если в коде они лежат условиями внутри методов; в колонке «где» — метод сервиса
Командыкарточки на публичные операции сервиса: вход, предусловия, логика, ошибки; поле «Эмитит» пустое или помечено
Доменные события«не применимо на Уровне 1» с причиной, если событий нет
Запросыкарточки на чтение: вопрос, параметры, что возвращает

Главное, что стоит понять про этот файл: он описывает поведение, а не структуру классов. Отсутствие агрегата не означает отсутствия статусов, правил и операций — они есть, просто живут в методах сервиса, а не в модели. Матрица переходов и список правил на уровне 1 заполняются ровно так же, как на уровне 3, и именно они переживут переход между уровнями без изменений.

И практический довод в пользу того, чтобы заполнять этот файл на уровне 1 всерьёз: когда придёт время переходить на следующий уровень, готовая матрица переходов и список правил и есть проект будущего агрегата. Сделанная заранее работа не пропадает.

Признаки, что пора на уровень 2

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

Если хотя бы одно из этого актуально — пора к следующему шагу: Уровень 2: Use Case Pattern. Там каждая бизнес-операция выделяется в отдельный UseCase + Handler.

Признаки, что переходить рано

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

Переходить рано, если:

  • Сервис-классы по десятку методов, и операции в них различимы. Переход оправдан потерей явности, а не размером сам по себе. Пятнадцать понятных методов лучше пятнадцати пар классов.
  • Вы не можете назвать три операции, которым нужны метрики и аудит. Единая точка контроля — главная ценность следующего уровня. Если она не нужна ни одной операции сейчас, покупаете сложность впрок.
  • Тесты через HTTP на контейнере вас устраивают. Изолированный тест операции — второй главный довод. Если текущие тесты пишутся быстро и ловят ошибки, довода нет.
  • Домен — настоящий CRUD. Справочник, где операции «создать, изменить, удалить», не станет понятнее от разделения на пары классов. Он станет длиннее.
  • Сервису меньше полугода и он ещё меняется формой. Структуру фиксируют, когда понятно, что фиксировать. До этого момента переезд придётся делать дважды.
  • Команда не работала с этим раньше и учится на боевом сервисе. Не запрет, но повод сначала сделать одну операцию по-новому рядом со старыми и посмотреть, как живётся.
  • Единственный довод — «так правильнее». Самый частый и самый плохой. Уровень выбирают по боли, а не по чистоте.

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

Промежуточный ход, если сомневаетесь. Не обязательно переводить весь сервис: возьмите одну операцию, у которой есть настоящие правила и которой нужны метрики, и сделайте её по-новому рядом с остальными. Два стиля в одном сервисе на время — терпимая цена за ответ на вопрос «станет ли лучше». Если через месяц захочется перевести вторую операцию — довод появился. Если нет — вы сэкономили переезд.

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

Дополнительно: при первом чтении можно пропустить

Глубже: что тестируют на каждом уровнерасширенное

«Сложно тестировать операцию изолированно» это боль уровня 1, а что именно тестируют на каждом уровне и чем, стоит собрать в одном месте, потому что уровень определяет форму тестов сильнее, чем инструмент.

Уровень 1, слоёный. Логика размазана по сервису с зависимостями на репозитории и клиенты, и изолированный тест операции требует заглушек на всё. Честный набор здесь: интеграционные тесты через HTTP на настоящей базе в контейнере, по одному на сценарий, и модульные только на чистые куски (расчёты без зависимостей). Тесты через сервис с десятком заглушек дают ложную уверенность и падают при каждом рефакторинге.

Уровень 2, UseCase и Handler. Обработчик это класс с явными зависимостями, и у него три вида тестов. Тест обработчика без фреймворка: репозиторий заглушка или память, вызов handle, проверка результата и вызовов исходящих команд; на каждое правило и ошибку из карточки по тесту. Тест среза контроллера с заглушкой диспетчера: перевод JSON в команду, статусы и тело ошибок. И один интеграционный на операцию через HTTP на контейнере, который проверяет склейку. Тесты валидации входа отдельно, потому что валидация живёт в аннотациях команды.

Уровень 3, агрегаты и порты. Добавляется самый дешёвый и самый ценный слой: тесты инвариантов агрегата без базы и без фреймворка, по одному на правило BR-N из спеки, плюс тесты событий (после операции ровно ожидаемые). Обработчик тестируют с настоящим агрегатом и заглушками портов, адаптеры отдельно по видам (WireMock, срез, контейнер), и архитектурные тесты на направление зависимостей в CI. Интеграционные через HTTP остаются, но их меньше: по главному сценарию, а не на каждую ручку.

Скилл проектирования тестов выводит этот набор из карточек спеки: критерии приёмки становятся сквозными тестами, правила BR тестами агрегата или обработчика, ошибки тестами границы. Общее правило для всех уровней: тест называют правилом, а не методом, и тест, который проходит при любом коде, удаляют, о чём статья про пирамиду тестов.

Коротко

  • Слоёная архитектура — три слоя: Controller, Service, Repository.
  • Подходит для CRUD, прототипов, маленьких сервисов с простым доменом. Контроллер тонкий, логика в сервисе, транзакция на уровне метода.
  • Ломается, когда сервис разрастается: теряется явность операций, метрики и аудит не масштабируются. Уровень 1 — осознанный выбор, а не недоделка. Для части сервисов он достаточен навсегда.
  • Тесты по уровням: на первом интеграционные через HTTP и модульные на чистые куски, на втором тест обработчика без фреймворка на каждое правило плюс срез контроллера и один сквозной, на третьем тесты инвариантов агрегата, адаптеров по видам и архитектурные.
  • В тонком прокси сервисный слой обычно пуст: контроллер зовёт клиента напрямую, а сервис заводят, когда появляется выбор системы, склейка вызовов, повторы, свои проверки или своё хранилище.
  • Операция живёт там, чьё состояние она меняет; если меняются обе области — у ведущей по жизненному циклу; равноправные области означают, что появился процесс и нужен координатор.
  • Форму запроса проверяют на границе до транзакции, существование и права — в начале метода, бизнес-правило — рядом с изменением; исключения свои, а перевод их в ответ в одном месте на приложение.
  • Транзакция перестаёт совпадать с методом при двух меняющихся областях, внешнем вызове внутри транзакции, разных базах и долгой работе; внешние вызовы выносят из транзакции и делают операцию повторяемой.
  • В файле домен-юнита на уровне 1 заполняют поведение, а не классы: матрицу переходов, правила с кодами, доступ и карточки операций — и это готовый проект будущего агрегата.
  • Переходить рано, если операции различимы, метрики и аудит не нужны ни одной из них, тесты устраивают, домен это CRUD или сервису меньше полугода; сомневаетесь — переведите одну операцию рядом со старыми.

Что почитать дальше