← назад к методологии · ступень 1 из четырёх (0–3)
Почти каждый сервис начинается одинаково: контроллер вызывает сервис, сервис идёт в репозиторий. Эту схему называют слоёной архитектурой. Она простая, понятная и в большинстве случаев работает отлично — до тех пор, пока сервис не разрастается до полусотни методов.
Что такое слоёная архитектураспросят на собеседовании
Три слоя, выстроенных в цепочку:
Каждый слой знает только соседа снизу: контроллер не ходит в базу, репозиторий ничего не знает про 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 с тридцатью методами — это уже не очевидная структура. Непонятно, какие из них реальные бизнес-операции, а какие — вспомогательные. Список операций приходится собирать вручную, просматривая сигнатуры.
Одна операция вызывается из нескольких мест с отличиями. Если одну операцию вызывают из трёх контроллеров с чуть разными проверками, эти проверки со временем расходятся — появляются незаметные ошибки.
Одну и ту же операцию зовут из трёх мест, и набор проверок у каждого свой; смотрите на пояснения слева, расхождение видно сразу.
Нет единой точки контроля. Метрики на операцию, аудит, единый способ обработки ошибок — всё это приходится добавлять в каждый метод руками. Легко пропустить, сложно поддерживать.
Сложно тестировать операцию изолированно. Тест бизнес-операции тянет за собой весь сервис-класс со всеми зависимостями. Нет способа запустить только одну операцию без остальных.
Операция не влезает в один метод. Пятое место, которое ломает слоёную модель раньше остальных и о котором реже всего говорят: транзакция перестаёт совпадать с методом.
Случаи, где это происходит:
- Операция трогает две области, и обе меняют данные. Списать со счёта и подтвердить заказ. Пока обе части в одной базе, спасает один транзакционный метод, который зовёт оба сервиса, — но тогда транзакция начинается в третьем месте, и правило «транзакция на уровне метода сервиса» уже нарушено.
- Внутри операции есть вызов наружу. Обращение к платёжному шлюзу внутри открытой транзакции — классическая ошибка: сеть медленная, транзакция держит блокировки, база задыхается на пиках. Правильный порядок — готовим в транзакции, вызываем снаружи, фиксируем результат новой транзакцией, но на уровне 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 или сервису меньше полугода; сомневаетесь — переведите одну операцию рядом со старыми.
Что почитать дальше
- Use Case Pattern — все уровни зрелости — общая картина и когда переходить между уровнями.
- Уровень 0: снимок существующего кода показывает, откуда сюда приходят, когда описания сервиса не было.
- Уровень 2: Use Case Pattern — явное выделение каждой операции в
UseCase+Handler. - REST API Style Guide — контракты для внешнего API.