С чего начинать писать новое приложение? Когда приложение должно заработать? Когда оно должно быть покрыто тестами? Зачем использовать интерфейсы? Что важнее - бизнес-сущность или табличка в базе данных?

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

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

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

В статье ниже мы разберём основные этапы разработки приложения, их детали и особенности.

Я выделяю 4 основных этапа разработки приложения:

  1. Проектирование предметной области.

  2. Проектирование логического скелета.

  3. Реализация логики.

  4. Интеграция с внешним миром.

Основные этапы разработки приложения
Основные этапы разработки приложения

Да, эти этапы и их очерёдность являются прямым следствием "луковичной архитектуры".

Луковичная архитектура
Луковичная архитектура

В "луковичной архитектуре" ключевым программным компонентом является бизнес-сущность. Слой бизнес-сущностей окружён сервисным слоем, в котором описано поведение бизнес-сущностей. Далее следует слой Data Access Object - клиенты для подключения к внешним интерфейсам (база данных, сторонние сервисы, брокеры сообщений). За пределами DAO располагаются, собственно, внешние интерфейсы, которые уже не являются частями приложения.

Уверен, Вы уже достаточно знаете про "луковичную" архитектуру. Если не знаете, или, к примеру, позабыли, рекомендую к прочтению статью Луковичная архитектура в компоновке backend-приложения и куда в итоге класть маперы, написанную около года назад Вашим покорным слугой.

Наверняка, первый вопрос, который Вам захочется задать, будет:

Окей, сначала одно проектирование, потом другое проектирование, потом TDD (зачем это вообще?), потом одна реализация, потом другая реализация... Сколько вообще времени уйдёт на всю итерацию? Год?

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

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


Проектирование предметной области

Любое приложение существует в особом предметном мире, который создаём мы, разработчики. Ключевые элементы этого мира мы получаем от бизнес-аналитиков. Их задача - дать наиболее полную информацию об этом мире. Наша задача - спроектировать этот мир через бизнес-сущности.

Бизнес-сущности ничего не умеют делать. Их поведение ограничивается обслуживанием самих себя. Они не знают ни про что, кроме как про себя и про другие такие же бизнес-сущности. Они полностью беспомощны. Они просто существуют и всё. Их единственная задача - наполнять своим существованием предметную область приложения.

Почти сферические бизнес-сущности в почти вакууме. Спасибо Kandinsky 2.2
Почти сферические бизнес-сущности в почти вакууме. Спасибо Kandinsky 2.2

Нам, как разработчикам, нужно спроектировать основные бизнес-сущности и частично второстепенные. Конечно, по мере развития приложения бизнес-сущностей будет становиться больше, но на первом этапе нам будет достаточно тех сущностей, которые появятся на основе первых бизнес-требований.

Зачем нам нужен этот шаг, и почему он должен быть первым?

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

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

Поэтому этот этап так важен, и важно пройти его в самом начале.

Также, как уже было отмечено ранее, бизнес-сущности являются ядром луковичной архитектуры и ни от чего не зависят. Их описание в коде даёт основу для следующего "луковичного" слоя - Вы просто не сможете написать сервисный слой, не имея бизнес-сущностей.

На этап проектирования предметной области обычно уходит несколько дней, и он включает в себя три шага:

  1. Описание предметной области

  2. Визуализацию

  3. Написание кода

На данном проекте, не надо подключать никакие сторонние библиотеки, кроме, пожалуй, Spring Web Starter. Наш проект должен просто стартовать, и всё. Брокеры сообщений, конфигурации подключения к базе данных, сериализаторы / десериализаторы нам не нужны. Всё, что нам нужно - это стандартный языковой пакет. Ведь наши бизнес-сущности ничего не умеют, а для их описания достаточно стандартной библиотеки.

Этап 1A. Описываем предметную область

Давайте потренируемся на примере интернет-магазина.

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

Нам необходимо описать основные бизнес-сущности и связи между ними.

Главную сущность в интернет-магазине мы определим как "Товар" (Good). Без, собственно, товара не может быть ни Заказа в привычном понимании, ни Покупателя, ни Платежа, ни всего остального. Все остальные сущности так и или иначе, прямо или косвенно, связаны с товаром.

На один Товар может быть несколько Заказов (Order). В одном Заказе может быть много товаров. Many-To-Many.

У одного Товара может быть несколько покупателей, но зачем нам на данном этапе лишняя связь? Мы свяжем Товар с Покупателем (Customer) через Заказ. У одного Заказа может быть один Покупатель. У одного Покупателя может быть много Заказов. One-To-Many.

У одного Заказа может быть много платежей (например, первый платёж может по каким-то причинам не пройти, и тогда будет создан новый, или Покупатель использовал популярный сплит). Связь Заказ - Оплата (Payment). One-To-Many.

Да, Корзина (Cart). Корзина связана как с Покупателем, так и с Товаром. В Корзине может быть много Товаров, Товар может быть в разных Корзинах. Many-To-Many. У Покупателя может быть только одна Корзина. One-To-One.

Я не являюсь большим специалистом по проектированию предметной области интернет-магазинов, поэтому бизнес-сущности будут условными.

Этап 1B. Визуализируем

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

Итак, на первоначальном этапе, интернет-магазин будет визуализирован следующим образом:

Простейший пример визуализации бизнес-сущностей интернет-магазина.
Простейший пример визуализации бизнес-сущностей интернет-магазина.

Наш пример максимально прост. В реальной жизни предметная область будет изобиловать десятками бизнес-сущностей. Это нормально.

Этап 1C. Пишем код

После этапа визуализации мы описываем предметную область в коде.

Существует заблуждение, согласно которому, многие разработчики воспринимают данные из базы данных как первоисточник предметной области, и стараются в первую очередь запроектировать таблицы базы данных. Это неверное суждение. Бизнес-сущность должна быть описана в коде в виде data-классов.

База данных как внешний интерфейс лишь хранит состояние. И не более. На начало проекта Вы даже можете не быть уверены, какая именно база данных Вам подойдёт больше - реляционная, документоориентированная или графовая, например. Более точный ответ на этот вопрос Вы сможете дать только после детальной проработки бизнес-сущностей и связей между ними. Именно поэтому выбор базы данных следует отодвигать на самый последний, четвёртый, этап.

Помните: при проектировании бизнес-сущности первичен data-класс.

Связи вышеописанных бизнес-сущностей в коде будут выглядеть так:

data class Good(
    val id: UUID,
    val name: String,
    val price: BigDecimal,
    val carts: Collection<Cart>
)

data class Customer(
    val id: UUID,
    val name: String,
    val cart: Cart,
    val orders: Collection<Order>
)

data class Cart(
    val id: UUID,
    val goods: Collection<Good>,
    val customer: Customer
)

data class Order(
    val id: UUID,
    val status: OrderStatus,
    val goods: Collection<Good>,
    val payments: List<Payment>?,
    val customer: Customer
)

data class Payment(
    val id: UUID,
    val status: PaymentStatus,
    val order: Order
)

Глаз цепляется за перекрёстные ссылки на классы выше по иерархии вместо ссылок на идентификаторы, что в перспективе грозит StackOverflowException при инициализации, но для наглядности связей такой пример подойдёт как нельзя лучше.

Итак, первый этап состоит из трёх последовательных шагов:

  1. Описываем бизнес-сущности.

  2. Визуализируем связи между ними.

  3. Описываем в коде.

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

Не задерживайтесь долго на этом этапе. Используйте итеративный подход. Если Вам кажется, что предметная область уже достаточно сформирована, переходите к следующему этапу. Пройдя весь цикл, Вы сможете опять вернуться к первому этапу и пройти цикл заново. Что Вы и будете делать при реализации каждой фичи, кстати.

Итак, что мы должны иметь после реализации первого этапа:

  1. Чёткое понимание предметной области в первом приближении, зафиксированное при помощи блок-схем.

  2. Реализацию слоя "бизнес-сущность" в коде.

В итоге, у нас есть единственный пакет entity, в котором находятся бизнес-сущности. Вот как это выглядит в проекте:

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

На данный момент, в нашем проекте нет application.properties. Так и должно быть - нам пока нечего конфигурировать.

Я часто вижу проекты, на самом раннем этапе реализации которых разработчики стараются добавить как можно больше конфигураций - авось пригодятся потом. Чего там только не увидишь. Я даже молчу про драйверы базы данных и пути ко внешним сервисам - буквально на днях в одном из таких проектов я видел конфигурации кварца! Пакет main практически пустой, в предметной области не создано ни одного класса, но уже добавлены конфигурации кварца - авось в проекте будет кварц.

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

Добавляйте конфигурации только тогда, когда им есть, что конфигурировать.

Итак, мы прошли все три шага, а значит, можно переходить к следующему этапу - проектированию логического скелета.


Проектирование логического скелета

Мы перешли к самому спорному шагу. Заставьте разработчиков писать интерфейсы, и вас возненавидят 8 из 10. Заставьте их объяснить, зачем нужны интерфейсы, и Вас возненавидят остальные двое.

Разработчики в своей массе не любят писать интерфейсы. Интерфейс в обычном понимании разработчика - это костылёк, позволяющий поддерживать несколько реализаций только там, где это необходимо. Да, отсутствие интерфейсов прямо противоречит принципам S.O.L.I.D, но когда это останавливало? Такой разработчик объяснит Вам, что "он пишет код, руководствуясь здравым смыслом" (но поскольку все мы считаем собственные решения здравыми, на самом деле, эта фраза звучит как "я пишу код так, как мне хочется").

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

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

Но, как мы знаем, в мире разработки всё не так. Продукт постоянно изменяется и развивается, многие функции приложения появляются, изменяются и исчезают. Приложение живёт. И нам, как разработчикам, необходимо заложить в приложение гибкость, которая позволит ему легко изменяться. Да, интерфейсы добавят приложению некоторую сложность. Но посмотрите на скелет человека. Вот кость. Она очень простая и прочная. Но она не сгибается, и мы ничего не можем с этим поделать. Две кости, соединённые суставом, легко сгибаются и могут принять разные формы. Да, такая конструкция уже сложнее, но это та цена, которую мы платим за гибкость и изменяемость системы.

Итак, на втором этапе мы пишем интерфейсы.

Первое, что мы делаем - назначаем каждой бизнес-сущности свой сервисный интерфейс, в котором мы спроектируем основные операции, проводимые с бизнес-сущностью и при помощи неё. Всё, что умеет сущность - она умеет в своём сервисе, и нигде более.

Часто приходится видеть сервисные функции, в которых то тут, то там происходят изменения со сторонними бизнес-сущностями. Это нарушает гибкость всего приложения.

Если Вам требуется совершить операции со сторонней сущностью, передайте её в её личный сервис и сделайте всё необходимое в нём. Все операции с бизнес-сущностью должны происходить в её личном сервисе. Точка.

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

Придерживайтесь правила:

Два сервиса не могут взаимно зависеть друг от друга.

Что это значит? Мы уже знаем, что если сервис А вызовет сервис Б, а сервис Б вызовет сервис А, это вызовет циркулярную зависимость. Но это не самое страшное. Циркулярная зависимость - это не более чем сигнал - в первую очередь, о том, что с Вашей архитектурой что-то не так. Именно поэтому на этапе проектирования нужно точно определить, как бизнес-сущности, а значит, и их сервисы, зависят друг от друга.

Зависеть - это значит, знать детали и иметь возможность влиять на компонент, от которого ты зависишь.

Посмотрим на наш интернет-магазин.

Начнём со связи "Покупатель - Корзина". Знает ли покупатель детали своей корзины? Да, конечно. Он может зайти в неё и посмотреть, какие товары в ней лежат. Он может изменить её свойства - например, добавить или удалить товар. Знает ли корзина о своём покупателе? Нет. Она не может изменять свойства покупателя. Она ничего о нём не знает. Она просто знает, что неё есть какой-то покупатель, вот его id.

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

Составим блок-схему

Пробежавшись, таким образом, по всем сервисам, мы выстроим чёткое дерево зависимостей между ними:

Упрощённая иерархия сервисов
Упрощённая иерархия сервисов

Схема зависимостей, которую мы нарисовали - постоянна на протяжении всей разработки. GoodService больше никогда не сможет зависеть от CustomerService, а если так вышло - ищите, где Вы ошиблись.

Популярный пример: циркулярная зависимость. Вы понимаете, что CustomerService через OrderService зависит от GoodService. И вдруг Вам надо вызвать CustomerService из GoodService. Ну вот надо (чтобы получить Customer из базы данных, к примеру). Худшее решение (и, кстати, самое популярное) - вызвать CustomerRepository из GoodService.

Забудьте. Ищите, где Вы допустили архитектурную ошибку. И обязательно найдёте. Решение, скорее всего, Вам не понравится - цена архитектурной ошибки обычно очень высока. Но если Вы её нашли - Вы обязаны её исправить здесь и сейчас - если оставить всё как есть, дальше будет только хуже.

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

Зафиксируем блок-схему в коде

После составления блок-схемы, мы фиксируем интерфейсы в коде. Здесь тоже нужно пользоваться принципом YAGNI. Мы не декларируем лишние функции. Мы декларируем только те функции, которые, на наш взгляд, нам точно потребуются. К примеру, мы не планируем удалять покупателей. Значит, в CustomerService не будет функции delete. То же касается и корзины - она существует до тех пор, пока существует пользователь. Также, мы не видим примеров, когда нам может потребоваться запросить корзину отдельно от пользователя. Значит, для корзины на данном этапе не требуется функция get.

У меня получилось спроектировать первоначальные возможности интерфейсов следующим образом:

interface CustomerService {

    fun create(customer: Customer): Customer

    fun get(id: UUID): Customer
}

interface GoodService {

    fun create(good: Good): Good

    fun update(good: Good): Good

    fun get(id: UUID): Good

    fun getAll(filter: List<Filter>): List<Good>

    fun delete(id: UUID)
}

interface CartService {

    fun createByCustomer(customerId: UUID): Cart
}

interface OrderService {

    fun create(order: Order): Order

    fun update(order: Order): Order

    fun get(id: UUID): Order

    fun getAll(customerId: UUID): List<Order>

    fun delete(id: UUID)
}

interface PaymentService {

    fun create(payment: Payment): Payment

    fun updateStatus(id: UUID, status: PaymentStatus): Payment
    
    fun get(id: UUID): Payment
    
    fun getAll(orderId: UUID): List<Payment>
}

Да, они получились довольно примитивными, как и наш пример в целом, но для данного поста пример вполне подходит.

По итогам второго этапа, мы получаем:

  1. Блок-схему однонаправленных взаимосвязей между сервисами.

  2. Реализацию сервисов в коде через интерфейсы.

В дальнейшем, мы будем поддерживать эту блок-схему в актуальном состоянии. Она будет хорошим документом архитектуры приложения и поможет нам не запутаться в иерархии зависимостей.

Что же касается архитектуры приложения, то вот она вся:

По-прежнему, ничего лишнего. Бизнес-сущности, логический скелет. И мы по-прежнему не добавили ни одной зависимости в gradle.properties.

Имея схему и основанную на ней структуру интерфейсов, мы можем переходить к третьему этапу - реализации.


Реализация логики

Имея готовые бизнес-сущности и проект бизнес-логики, основанный на интерфейсном скелете, мы можем приступить к реализации задуманного.

И всё-таки: TDD?

Да, мы стараемся следовать Test-Driven Development (TDD). Это не значит, что мы заставляем каждого разработчика писать код только через TDD и никак иначе. Но мы всецело стараемся использовать TDD в работе.

Поначалу TDD выглядит сложным и непонятным. Но посмотрите, как мастерски управляется Кирилл Толкачёв с реализацией логики приложения при помощи TDD (видео без проблем можно найти в интернете по запросу "Как познавать Spring Boot с помощью TDD", организаторы JPoint'23 уже выложили все видео конференции в свободный доступ). Менее чем за час доклада рождается готовое приложение, покрытое тестами.

При этом, приложение появляется даже более естественным путём, чем если бы мы сначала написали реализацию, а потом написали тесты - ведь сначала мы понимаем, что мы хотим, и только потом - как это будет реализовано. То же самое происходит и при следовании TDD - мы описываем в тесте, что должна делать функция, а потом пишем в пакете main, как она будет это делать.

Давайте ещё раз.

Test-Driven Development раскрывает первичность замысла. Вначале существует функциональное требование. Потом, силами системного аналитика, оно перерождается в техническое задание. Потом следует техническая реализация (более подробно об этом можно почитать в статье Жизненный цикл задачи: как быстро и бесконфликтно провести задачу от замысла до реализации). На момент написания приложения у нас есть техническое задание, но нет технической реализации. Посредством тестов мы документируем техническое задание ("реализовать сохранение того-то, чтобы в результате оно работало так-то"), и на основании этой документации, мы пишем техническую реализацию в пакете main.

Тесты - это лучшая документация технического задания. Эта документация жива, и постоянно изменяется на протяжении всего жизненного цикла приложения.

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

Тут-то и пригодились наши интерфейсы

В своём видео Кирилл Толкачёв опускает этап проектирования приложения. Это можно объяснить задачей доклада - показать простоту разработки через тестирование на реальном примере. Но проект, который приводит в пример Кирилл, подчёркнуто прост, и автор заостряет на этом внимание - например, вместо бизнес-сущности и DTO Кирилл использует String.

Но мы с Вами подготовились, и у нас уже есть:

  1. Бизнес-сущности.

  2. Интерфейсы поведения.

Наличие этих готовых программных компонентов ещё более упростит задачу, и реализация приложения через TDD будет проще, чем показал Кирилл.

Реализуйте интерфейсы, начиная от самых независимых. Мы реализуем интерфейсы с разрешёнными зависимостями. Для интерфейсов без зависимостей нет разрешённых зависимостей. Когда мы реализуем их, мы поднимемся выше и реализуем интерфейсы, которые зависят от интерфейсов первой очереди.

В нашем случае, независимыми интерфейсами являются GoodService и PaymentService. С них и начнём. Дальше, реализуем CartService и PaymentService. Закончим CustomerService.

Приступим. Начнём с GoodService.

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

Следующим шагом будет реализация самой функции, но прежде нам предстоит ответить на вопрос на миллион.

Вопрос на миллион: как на этом этапе быть с DAO?

Мы подошли к очень важному шагу: впереди реализация сервисной функции. Но сервисные функции редко работают сами с собой - они или запрашивают данные, или работают с состоянием сущности. Иными словами, они работают с DAO. Та же функция create, как уже понятно из названия, создаёт новый экземпляр сущности. И, весьма вероятно, где-то его сохранит. В нашем случае, в базе данных.

Как быть?

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

  1. Мокать компоненты DAO на стороне тестов (Mockito и проч.).

  2. Мокать компоненты на стороне реализации (в нашем примере, это будет условный GoodRepositoryMock).

Я рекомендую выбирать подход, исходя из дальнейших планов на тесты. Если Вы планируете инкапсулировать тесты в рамках своего слоя (сервисные тесты проверяют только логику и не сохраняют в базу, контроллерные тесты не ходят в сервис и проверяют только путь и параметры), тогда возьмите вариант 1. Если же Вы планируете сделать упор на сценарных тестах, с тестированием всего сценария, от контроллера до репозитория и обратно, ваш вариант - 2.

Мокаем клиент.
Мокаем клиент.

Лично я предпочитаю сценарные. Я воспринимаю выполнение функции как работу всех функций, которые она вызывает. Если такая функция вызывает приватную функцию, происходит тестовое покрытие приватной функции. Если вызывает функцию DAO, значит, функция DAO является частью работы той функции, которая её вызывает, и тоже должна быть проверена в составе единого целого.

И, что немало важно, благодаря нашей подготовке в части шагов 1 и 2, второй вариант не будет слишком дорогим. Создавая интерфейсы и мокая их, мы попутно проектируем слой DAO, закладывая, таким образом, фундамент для четвёртого шага. Слой DAO будет спроектирован исходя из потребностей логического слоя, в нём будет ровно необходимое количество функций, и Вы избежите риска чего-нибудь наоверинжинирить.

Именно этот подход мы и возьмём в качестве примера.

Итак, для сохранения товара нам необходимо сохранить его состояние в базе данных. Значит, нам необходим интерфейс GoodRepository. Создадим такой интерфейс с единственной функцией save.

interface GoodRepository {
    
    fun save(good: Good): Good
}

Мы помним, что согласно TDD, нужно писать только то количество кода, которое необходимо для успешного прохождения теста, так что, наличие единственной функции save нас вполне устроит.

Далее, мы пишем мок-реализацию для функции сохранения товара в базе данных. Это вряд ли займёт больше минуты.

@Repository
class GoodRepositoryMock : GoodRepository {

    val goods = mutableMapOf<UUID, Good>()

    override fun save(good: Good): Good = good.copy(id = UUID.randomUUID()).also { goods[it.id!!] = it }
}

Далее, пишем реализацию. Ещё 30 секунд:

@Service
class GoodServiceImpl(
    private val repository: GoodRepository
) : GoodService {

    override fun create(good: Good): Good = repository.save(good)

Ура. Тест прошёл.

А как быть с реализацией функции, для которой уже есть DAO-клиент?

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

Как быть?

Если слой DAO уже есть, и он работает, принимайте решение исходя из реалий тестирования. Если это, предположим, база данных, которую Вы тестируете через testcontainers, то проще сразу написать этот запрос. Для клиентов, которые Вы и так мокаете при тестировании, ничего не меняется - Вы так же пишете мок для тестов, а на четвёртом этапе пишете реализацию.

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


Интеграция с внешними интерфейсами

Заключительным шагом реализации приложения является интеграция с внешними сервисами.

Этот этап состоит из 3 шагов:

  1. Создание моделей взаимодействия с внешним интерфейсом - Data Transfer Object.

  2. Создание мапингов из бизнес-сущности в DTO и обратно.

  3. Создание клиентских интерфейсов и их реализация.

Именно на этом этапе создаются / дополняются клиенты внешних интерфейсов. Именно на этом этапе создаются / дополняются их конфигурации.

Как мы можем заметить, шаги напоминают этапы 1-3 создания приложений, но в масштабе DAO.

Внешний интерфейс, будь то база данных, соседний микросервис в Вашем окружении или полностью сторонний сервис, живёт в своей предметной области. И первым нашим шагом будет создание этой предметной области в DAO - компоненте приложения, специально для этого предназначенном (более подробно о компоновке DAO можно прочитать в статье Луковичная архитектура в компоновке backend-приложения и куда в итоге класть маперы).

Классическая схема интеграции с внешним сервисом
Классическая схема интеграции с внешним сервисом

Второй шаг - адаптация предметных областей - нашей и внешнего интерфейса. Мы пишем маперы трансформации из нашей предметной области в предметную область стороннего интерфейса и обратно.

Третий шаг - реализация интерфейсов. Этот шаг в точности повторяет третий этап реализации приложения: пишем интерфейс, покрываем тестом, пишем реализацию.

Но есть одно но.

Модульные тесты должны быть полностью изолированы. Иными словами, они должны одинаково хорошо выполняться на любом окружении. Но любой тест должен воспроизводить сценарий, максимально приближенный к реальному. Как быть с внешними интерфейсами? С одной стороны, мы не можем во время тестирование подключиться к внешнему сервису. С другой - мы не можем гарантировать, что наш SQL-скрипт, который мы вызвали, отработает корректно, пока не протестируем его.

Мы считаем, что изоляция тестов первична. Но в пределах изоляции мы стараемся протестировать всё, до чего можем дотянуться.

Существуют практики, позволяющие протестировать внешний интерфейс без нарушения изоляции тестирования. Наиболее распространённая из них - testcontainers, и мы с удовольствием ей пользуемся, когда хотим протестировать репозиторный слой. Во время тестирования в тестовом окружении поднимается контейнер с базой данных, приложение подключается к этой базе и отрабатывает скрипты "на живую". Таким образом, мы точно можем быть уверены, что наши скрипты корректно работают.

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


Заключение

Вы начали реализовывать приложение. Описали предметную область. Определили логику. Написали тесты. Реализовали интеграцию.

Что дальше?

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

Вы получили функциональные требования. Освежите бизнес-сущности (если надо). Напишите интерфейсы поведения (если появились новые сущности) или допишите необходимые функции к старым. Напишите тесты, реализуйте заложенные в них технические требования. Реализуйте интеграцию.

Следуйте этому рецепту, и в Вашей работе будет больше порядка.

Комментарии (0)