Coding Style. Архитектура.

Оглавление

В качестве основных архитектурных правил мы придерживаемся принципов S.O.L.I.D. В стиле кода мы придерживаемся Google Java Style Guide для Java и Kotlin Coding Conventions для Kotlin (с опущением некоторых правил).

Спорные правила Kotlin Coding Conventions

Правило Комментарий
Мы приветствуем размещение нескольких программных сущностей (классы, верхнеуровневые функции / переменные) в одном файле, если эти сущности тесно связаны друг с другом семантически, а размер файла остаётся разумным и не превышает нескольких сотен строк. Контроллер, сервис, репозиторий, бизнес-сущность, модель DAO, мапер — все эти типы компонентов слишком разные и каждый имеет слишком чёткую функциональную нагрузку, чтобы смешивать их в одном файле. «Луковичная архитектура» кричит от негодования, ей вторят Роберт Мартин и Мартин Фаулер.

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

Если в классе есть два концептуально одинаковых поля, одно из которых является частью общепринятого API, а второе — деталью реализации, используйте в имени приватного поля нижнее подчёркивание в качестве префикса «Венгерская нотация» считается устаревшей, независимо от причин её использования.
Использовать имена, состоящие из нескольких слов, как правило, не рекомендуется, но если это необходимо, Вы можете соединить их вместе или использовать Camel Case (org.example.myProject). Имена пакетов в camelCase — один из самых неожиданных пунктов в Kotlin Coding Convention. Возможно, эта привычка переехала из Android Studio, которая имеет более жёсткие правила в части допустимых символов.
Для перечислений (enum), допускается использовать или Screaming Snake Case (enum class Color { RED, GREEN }), или Upper Camel Case. Имена пакетов в camelCase — один из самых неожиданных пунктов в Kotlin Coding Convention. Возможно, эта привычка переехала из Android Studio, которая имеет более жёсткие правила в части допустимых символов.

Верхнеуровневая компоновка приложения

Мы придерживаемся компоновки по уровням. Компоновка по уровням подразумевает создание пакетов на верхнем уровне, разделённых по слоям. Например, controllerservicerepository и так далее.

Более подробно этот способ компоновки описан в статье Симона Брауна «Недостающая глава» книги Роберта Мартина «Чистая архитектура».

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

Таким образом, приложение состоит из следующих основных пакетов:

Пакет Описание
configuration Общие конфигурации приложения.
dao Слой DAO. Все интеграции приложения с другими системами (внешние сервисы, базы данных, клиенты).
domain Домен. Предметная область приложения.
exception Кастомные исключения приложения.
service Сервисный слой.
util Утильные функции.
Architecture — Packages

Разберём каждый из этих слоёв и их структуру.

Domain

В пакете domain хранится предметная область приложения.

Предметная область — это совокупность тесто связанных понятий, которые описывают сущности приложения. Более подробно про предметную область можно почитать в моей статье на Хабре или посмотреть в докладе с IT Link 2025.

Другими словами, это бизнес-сущности приложения.

Например, при проектировании трекера задач, предметной областью будут такие сущности, как:

Проект, Спринт, Тэг, Задача, Комментарий к задаче, История изменения задачи, Команда, Член Команды, Пользователь и так далее. Все эти сущности являются предметной областью приложения и находятся в пакете domain. Сущности-перечисления (такие, как Статус проекта, Статус спринта, Приоритет задачи, Статус задачи, Роль пользователя и другие) находятся в пакете domain.type.

Service

Пакет service хранит бизнес-логику приложения. Он работает только с предметной областью приложения.

Сервис состоит из двух обязательных компонентов:

  • интерфейса, предоставляющего контракт и декларирующего бизнес-логику;
  • N-ного количества реализаций

Работа сервисов основана на правилах.

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

Могут ли сервисы работать с несколькими сущностями сразу? Могут. Но это не сервисы в привычном понимании.

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

И, наконец, правило третье. Сервисы взаимодействуют друг с другом только через интерфейсы.

Структура пакета service выглядит следующим образом:

В корне: интерфейсы сервисных классов.

Пакеты: impl, model, mapper.

Пакет Описание содержимого
impl Реализации сервисных интерфейсов.
model Внутренние сервисные модели, являющиеся служебными и не входящие в предметную область приложения. Например, data-класс с параметрами поиска.
mapper Маперы для преобразования данных внутри сервисного слоя.

Почему мы пишем интерфейсы

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

Согласно «Чистой архитектуре» Роберта Мартина, зависимости между компонентами приложения должны строиться на базе стабильных, устойчивых абстракций. Самой стабильной абстракцией является интерфейс, поскольку он не содержит логику, а только декларации. В этом аспекте, интерфейсы используются в качестве «скелета» приложения. Такой подход также согласуется с DIP — Dependency Inversion Principle.

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

Помимо очевидной архитектурной функции, использование интерфейсов позволяет добиться положительных побочных эффектов (множественная реализация, мок клиентов и прочих), а также, соблюдения других принципов SOLID (Open-Closed Principle, Liskov Substitution Principle и вышеупомянутого Dependency Inversion Principle).

Data Access Object

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

Для каждого компонента предусмотрен отдельный пакет, название которого описывает бизнесовую суть подключаемого интерфейса там, где это возможно (например, gigachat для подключения к GigaChat) и техническую там, где бизнесовую суть выделить невозможно (filestorage, repository).

Каждый компонент DAO обычно включает в себя следующие пакеты:

Пакет Описание содержимого
impl Реализации интерфейсов.
model Предметная область внешнего интерфейса, с которым взаимодействует компонент.
mapper Маперы для преобразования наших внутренних бизнес-сущностей в сущности предметной области подключаемого интерфейса и обратно.
configurarion Конфигурации компонента DAO.

Разберём каждый из них отдельно.

configuration. Конфигурации приложения.

Мы разделяем конфигурации приложения и конфигурации компонентов DAO. Компонентов DAO может быть очень много, и нахождение их в одном пакете смешивает их. Также, всё, что касается подключаемых компонентов, должно быть инкапсулировано в отдельном слое (что мы и делаем, вынося все внешние подключения в пакет dao и разделяя их по компонентам внутри). Поэтому в каждом таком слое, как правило, присутствует пакет configuration, который содержит в себе конфигурации компонента, и только их.

model. Модели.

В этом пакете содержатся модели предметной области подключаемого интерфейса. Если назначение компонента подразумевает внутреннее разделение моделей, мы создаём дополнительные пакеты. Например, это могут быть пакеты request / response для пакета controller.

maper. Маперы.

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

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

Повторим главное правило: задача мапера — преобразовывать одни данные в другие. И ничего больше.

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

Копировать