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, которая имеет более жёсткие правила в части допустимых символов. |
Верхнеуровневая компоновка приложения

Мы придерживаемся компоновки по уровням. Компоновка по уровням подразумевает создание пакетов на верхнем уровне, разделённых по слоям. Например, controller, service, repository и так далее.
Более подробно этот способ компоновки описан в статье Симона Брауна «Недостающая глава» книги Роберта Мартина «Чистая архитектура».
Компоновка по уровням является одной из реализаций «луковичной» архитектуры, смысл которой сводится к изображению «луковицы», в центре которой находится бизнес-сущность, которая ничего не знает о реализации приложения. Её окружает бизнес-слой, который знает только про бизнес-сущность и ни про что более. Бизнес-сущность окружает слой DAO (Data Access Object), который служит для подключения к внешним интерфейсам. Слой DAO окружают внешние интерфейсы, которые не являются частью приложения.
Таким образом, приложение состоит из следующих основных пакетов:
| Пакет | Описание |
|---|---|
| configuration | Общие конфигурации приложения. |
| dao | Слой DAO. Все интеграции приложения с другими системами (внешние сервисы, базы данных, клиенты). |
| domain | Домен. Предметная область приложения. |
| exception | Кастомные исключения приложения. |
| service | Сервисный слой. |
| util | Утильные функции. |

Разберём каждый из этих слоёв и их структуру.
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 может выглядеть так: