Наверняка вам уже не раз попадалась на глаза статься, в которой рассказывают о том, какие пакеты и файлы нужно создать в вашем проекте, чтобы код получился легко расширяемым и поддерживаемым. Эта еще одна статья такого рода, посвященная декомпозиции проекта go на минимально зависимые друг от друга части. В качестве движущих сил декомпозиции будут использоваться следующие известные практики:
архитектура слоев;
предметно-ориентированное проектирование (DDD);
разделение команд и запросов (CQS);
архитектура портов и адаптеров.
Также будет затронута тема именования файлов .go и вопросы связности (low coupling/high cohesion).
Приведенный выше перечень подходов часто можно встретить в проектах на Java или C#. Точнее говоря, эти подходы предназначены для задач, которые традиционно решаются с использованием этих языков. Однако бывают случаи, когда подобные задачи необходимо решать на Go. Надеюсь, эта статья поможет начинающим разработчикам Go с выбором подходящей структуры проекта. Возможно, и опытные разработчики найдут в ней что-то полезное.
Всё, что вы прочитаете далее, — лишь субъективное видение автора и, разумеется, не претендует на звание "шаблон проекта".
Для наглядности идеи, представленные в статье, воплощены в этом проекте, который далее я буду называть demo-приложением.
Декомпозиция пакета internal с помощью архитектурных слоев
Пакет internal
будет присутствовать в повествовании, поскольку автор статьи в свое время вдохновился https://github.com/golang-standards/project-layout.
Самое простое, с чего можно начать - это создать в пакете internal
еще три пакета, соответствующие архитектурным слоям:
internal/domain
- содержит модели предметной области и обеспечивает выполнение инвариантных правил (слой предметной области, domain layer), в этом пакете будем искать компоненты с помощью DDD;internal/app
- отвечает за координацию приложения и внешних систем (слой приложения, application layer), в этом пакете будем искать компоненты с помощью CQS;internal/infra
- содержит код адаптеров внешних систем (слой инфраструктуры, infrastructure layer) в этом пакете будем искать компоненты с помощью ports and adapters.
Далее, в процессе рассмотрения каждого архитектурного слоя по отдельности, я в общих чертах обрисую свое представление о границах слоев и выделю структуры и функции go, которые буду называть строительными блоками приложения. Строительные блоки — это элементы, обладающие высокой архитектурной значимостью и формирующие каркас приложения.
В каждом проекте есть участки кода, в которых можно проявить творческий подход, и те, где прагматичнее опираться на универсальные решения. Строительные блоки относятся ко второй категории: это универсальные решения, применимые во множестве проектов. Вокруг них формируются уже уникальные элементы, характерные для конкретного проекта.
В demo-приложении действуют следующие правила зависимостей (dependency rules), помогающие провести границы между слоями и изолировать код одного слоя от кода другого слоя.

Если стрелка направлена из слоя приложения в слой предметной области, то в коде допустим импорт типов из пакета internal/domain
в пакет internal/app
. Импорт типов из пакета internal/infra
в пакет internal/domain
запрещен, поскольку такой стрелки нет на рисунке.
Декомпозиция пакета domain с помощью предметных подобластей
Обычно приложения создаются с целью автоматизировать некоторые процессы. Предметная область приложения - это процессы, которые приложение должно автоматизировать. Часто предметную область приложения можно разбить на предметные подобласти. Этим фактом и воспользуемся для декомпозиции пакета internal/domain
.
В demo-приложении автоматизируются выдуманные процессы. Познакомимся с ними.
Пусть в некотором вымышленном городе есть организация, которая занимается составлением списков дел (todo list). В данной организации над составлением списков дел трудятся редакторы. Редакторы составляют самые разнообразные списки дел, например, список ингридиентов для яблочного пирога. Списками дел пользуются жители города. Они приходят к редакторам и переписывают себе на листок бумаги список ингридиентов, затем идут в магазины, приобретают нужные ингридиенты и вычеркивают их карандашом по мере совершения покупок. Нам как разработчикам требуется автоматизировать работу городской организации и ее клиентов.
Основной моделью предметной является список дел. Существуют две категории людей, которые работают со списками дел:
редакторы;
пользователи.

Редакторы выбирают заголовок списка и добавляют туда пункты. Пользователи же только читают придуманные редакторами заголовок и список пунктов. Пользователи могут вычеркивать пункты из списка, чего не могут делать редакторы. Таким образом, редакторы и пользователи смотрят на один и тот же список по-разному. Сначала список существует в контексте редактирования, а затем этот же список существует в контексте использования.
Создадим в пакете internal/domain
еще два пакета:
internal/domain/editing
- предметная подобласть редактирования списков дел;internal/domain/using
- предметная подобласть использования списков дел.
Получились еще два компонента. На самом деле разделив предметную область на подобласти мы фактически разделили все приложение на две части. Далее станет видно, что пакеты using
и editing
встречаются в обоих оставшихся слоях. Предметные подобласти - это самый мощный инструмент декомпозиции, который известен автору статьи. С помощью предметных подобластей и особой сноровки можно даже разделить монолитное приложение на микросервисы. Но мы этого делать не будем - нам достаточно разделение на пакеты.
Строительные блоки слоя предметной области
В качестве строительных блоков слоя предметной области выберем модели данных предметной области и сервисные функции, также известные как сервисы предметной области. В качестве примера моделей данных предметной подобласти рассмотрим модели из пакета internal/domain/using
.

Выделим категории моделей:
объект-значение (value object);
сущность (entity);
агрегат (aggregate).
Краткое описание данных категорий:
сущность - это структура go, часто соответствующая строке в таблице БД;
объект-значение - это структура go, часто соответствующая полю в таблице БД;
агрегат - это кластер сущностей и объектов-значений.
Более подробно описывать не буду, потому что мы договорились, что DDD будет без фанатизма.
Каждый файл пакета internal/domain/using
содержит по одной модели. По названию файла можно определить, какая модель в нем объявлена.
Когда требуется выполнить операцию с более чем одним агрегатом имеет смысл применить сервис предметной области. Сервис предметной области - это функция, привязанная к go-структуре. Поля структуры ссылаются на типы go, которые необходимы для выполнения задач сервиса. От этих типов зависит успешное выполнение сервисной функции. Будем называть такие типы зависимостями сервиса.

Поскольку сервис предметной области в demo-приложении импортирует типы из пакетов /internal/domain/editing
и /internal/domain/using
, он не объявлен ни в одном из этих пакетов. Вместо этого используется специальный пакет для размещения сервисов /internal/domain/services
. Файл с сервисом предметной области TakeTodoListService называется take_todo_list.go
. Название пакета говорит о том, что это сервис предметной области, а название файла говорит о том, что этот сервис делает.
Границы слоя предметной области
Границы слоя предметной области определим с помощью функций, обеспечивающих соблюдение инвариантных правил. Если функция обеспечивает соблюдение инвариантных правил, то она должна быть объявлена в слое предметной области.
Вот несколько инвариантных правил demo-приложения:
изначально список дел создается со статусом "Черновик";
нельзя опубликовать пустой список дел;
нельзя добавить более десяти пунктов в список дел;
нельзя использовать список дел со статусом "Черновик".
Инвариантные правила могут соблюдаться конструкторами сущностей и агрегатов.

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

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

Непосредственным клиентом слоя предметной области является слой приложения. Слой предметной области предоставляет слою приложения свой API - набор функций, которые будут вызывать функции из слоя приложения. В API слоя предметной области входят:
экспортируемые функции-конструкторы агрегатов;
экспортируемые функции агрегатов;
экспортируемые функции сервисов предметной области;
экспортируемые интерфейсы, объявленные в слое предметной области.
Нужно отметить, что в demo-приложении соблюдение большинства инвариантных правил обеспечивается агрегатами, и сервис предметной области всего один. В коде из реальной жизни сервисов предметной области бывает гораздо больше. Но мы договорились, что DDD будет без фанатизма. Поэтому, пожалуй, на этом с DDD закончим и пойдем дальше.
Предостережение: неправильно разделение предметной области на подобласти может оказать медвежью услугу и усилить запутанность кода во всем приложении. Поэтому при отсутствии опыта в DDD я рекомендую отложить этот способ декомпозиции до лучших времен и не делить пакет /internal/domain
, ведь далее мы рассмотрим более очевидные и вместе с тем довольно эффективные способы декомпозиции слоя приложения и слоя инфраструктуры.
Декомпозиция пакета app с помощью команд и запросов
В соответствии с CQS (command-query separation) структуры данных, которые внешние системы отправляют для обработки в приложение, делятся на два типа:
команды (commands);
запросы (queries).
Commands меняют состояние приложения, которое обычно хранится в БД. Queries не меняют состояние приложения, а считывают его и возвращают внешней системе. На основании данных, полученных в результате выполнения queries, внешняя система принимает решение о том, какую команду отправить в приложение следующей.

Строительные блоки слоя приложения
Для слоя приложения выберем такие строительные блоки:
функции, выполняющие обработку commands и queries;
модели commands и результатов commands (dto, data transfer objects);
модели queries и результатов queries (тоже dto).
Обработчики команд и запросов имеют те же анатомические особенности, что и сервисы предметной области - состоят из функции-обработчика и структуры с зависимостями.

Модели команд и запросов можно объявить явно как структуры.

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

Границы слоя приложения
В demo-приложении обработчики команд и запросов выполняют следующие функции:
определяют границы транзакций;
конвертируют входные структуры данных в модели предметной области и наоборот.
В обработке commands всегда задействован слой предметной области, потому что выполнение команды ведет к изменению состояния БД. Слой предметной области с помощью инвариантных правил гарантирует то, что состояние БД после выполнения команды останется согласованным. Поэтому важной зоной ответственности слоя приложения в целом и обработчиков команд в частности является определение границы транзакций.

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

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

Разделение слоя приложения на commands и queries кажется простой задачей. Чтобы не запутаться удобно сопоставлять commands с методами http POST
, PUT
, PATCH
и DELETE
. Метод GET
соответствует queries.
В целом декомпозиция слоя приложения с помощью CQS вполне выполнимая задача, не требующая той особой сноровки, которая нужна для декомпозиции слоя предметной области.
Декомпозиция пакета infra с помощью входных и выходных адаптеров
Commands и queries поступают в приложение из внешних систем. Приложение также делает запросы во внешние системы. Для обмена данными между приложением и внешними системами используются различные технологии: сетевые протоколы, библиотеки и т.д.
Входные адаптеры
Входные адаптеры обрабатывают входные (в приложение) запросы, инициируемые внешними системами. Входные адаптеры - это функции, в которых полученные из внешних систем данные конвертируются в структуры данных приложения - в commands и queries. Входные адаптеры, соответствующие некоторой технологии обмена данными помещаются в пакет, название которого отражает название технологии. Примеры таких пакетов:
/internal/infra/in/http
/internal/infra/in/grpc
/internal/infra/in/graphql
/internal/infra/in/tcp
/internal/infra/in/kafkaconsumer
Пусть приложение использует два http сервера: один для обработки запросов к application layer, а другой для обработки запросов технического характера (/ready
, /health
, /metrics
и т.д.). Тогда имеет смысл использовать два пакета:
/internal/infra/in/http
/internal/infra/in/httptech
Аналогично можно разделить любой пакет входных адаптеров общей технологии.
Выходные адаптеры
Выходные адаптеры передают выходные (из приложения) запросы внешним системам. Выходные адаптеры - это функции, в которых структуры данных приложения конвертируются в данные для передачи внешним системам. Выходные адаптеры, соответствующие некоторой технологии обмена данными помещаются в пакет, название которого отражает название технологии. Примеры таких пакетов:
/internal/infra/out/http
/internal/infra/out/grpc
/internal/infra/out/redis
/internal/infra/out/postgres
/internal/infra/out/kafkaproducer
Пусть приложение инициирует запросы к сервисам auth
и notify
с помощью протокола http. Тогда имеет смысл использовать два пакета:
/internal/infra/out/httpauth
/internal/infra/out/httpnotify
Аналогично можно разделить любой пакет выходных адаптеров общей технологии.

Строительные блоки слоя инфраструктуры
Слой инфраструктуры представлен разнообразными технологиями поэтому трудно выбрать универсальные строительные блоки. Вместе с тем входные адаптеры часто служат для программистов "точками входа" в код приложения, поэтому сосредоточимся на них и рассмотрим в качестве примера строительные блоки входного адаптера http-запросов.
В demo-приложении эти строительные блоки похожи на те, что используются в слое приложения: обработкой http-запросов занимаются функции-обработчики, тела запроса и ответа моделируется с помощью dto. Обычно dto в слое инфраструктуры проектируются для нужд сериализации/десериализации в структуры данных, пригодных для передачи по сети - в demo-приложении это json-теги. Поэтому всегда стоит разделять dto слоя приложения и dto слоя инфраструктуры.

Границы слоя инфраструктуры
Сформулировать универсальные правила, которые опишут границы слоя инфраструктуры трудно. Здесь ситуация та же, что и с выбором строительных блоков. Поэтому воспользуемся методом исключения - будем называть слоем инфраструктуры все то, что не относится ни к слою приложения, ни к слою предметной области.
И конечно же нужно выделить в слое инфраструктуры входные и выходные адаптеры. Провести границы этих компонентов легко. Если речь идет о входном http адаптере, то соответствующем пакете будет храниться код, связанный с обработкой http запросов. Если речь идет о выходном адаптере postgres, то в соответствующем пакете будет сосредоточен sql-код. И так далее.
Утилиты и общий код
Существует и такой код, который не вписывается в концепцию строительных блоков или каркаса приложения. Обычно такой код используется сразу несколькими функциями в разных архитектурных слоях (общий код), либо носит вспомогательный характер (утилиты).
В таких ситуациях я предлагаю определиться с тем, насколько такой код зависит от разрабатываемого приложения. Для этого можно провести мысленный эксперимент: если этот код можно выложить в публичный репозиторий, то он не зависим от приложения. Зависимый от приложения код будем хранить либо в пакете слоя (internal/domain
, internal/app
, internal/infra
) либо в специальном "приближенном" к слоям пакете internal/shared
. Название можно придумать любое. Независимый код будем хранить в пакете internal/pkg
, который предлагает использовать для этой цели Standard go project layout.

Порассуждаем о том, что может храниться в предложенных пакетах.
В пакете internal/shared
можно хранить, например, строковые константы, используемые во всех слоях или единый перечень ошибок (var Err...), используемых повсеместно в приложении:
internal/shared/strconst
internal/shared/errors
В internal/pkg
можно поместить middleware fiber с вашей собственной реализацией CORS. Напротив, если в middleware fiber обрабатываются ошибки из пакета internal/domain
, то такой код нужно оставить в слое инфраструктуры. В демо-приложении в internal/pkg
размещен пакет pgxtx
, который отвечает за передачу транзакции БД в context.Context
. Однако содержимое pgxtx
можно было бы оставить и в слое инфраструктуры, поскольку эта функциональность используется только там.
А вот другой пример. Требуется расширить стандартную библиотеку go одной функцией. Этот код полностью независим от разрабатываемого приложения и потенциально может быть использован в любом слое. Такой код хорошо будет чувствовать себя в internal/pkg
.


В целом разделение на internal/shared
и internal/pkg
не несет сверхвысокой выгоды, однако когда кода много, этот способ декомпозиции будет не лишним.
Анализ связности поддоменов
Если вы уже заглянули в исходный код demo-приложения, то, вероятно, обратили внимание на заметное влияние поддоменов на структуру пакетов. Хотя выделение поддоменов — во многом инженерное искусство, я все же внесу немного формализма. Давайте попробуем оценить, насколько поддомены editing
и using
независимы и как сильно они связаны между собой.

Обычно, говоря о связности в разработке ПО, имеют в виду граф, в котором некоторые вершины сильнее связаны друг с другом, чем с остальными. Такие группы вершин называют сообществами или модулями.
Граф - это абстрактная модель. Чтобы воспользоваться этой моделью нужно определиться с тем, что считать вершинами, а что - ребрами. Для анализа связности поддоменов за вершину графа примем сущность (entity). В demo-приложении четыре сущности:
e_list (editable todo list)
e_list_item (editable todo list item)
u_list (usable todo list)
u_list_item (usable todo list item)
Будем считать, что между парой вершин есть ребро, если соответствующая пара сущностей необходима для выполнения command или query. Рассмотрим пример с TakeTodoListCommandHandler
. В этом случае из БД извлекаются экземпляры сущностей e_list и e_list_item, а затем создаются экземпляры сущностей u_list и u_list_item. Поскольку все указанные сущности используются совместно, граф, описывающий TakeTodoListCommandHandler
, является полносвязным.

Аналогичным образом все остальные command handlers и query handlers можно представить как полносвязные графы, если они используют более одной сущности. Далее все полученные полносвязные графы объединяем в один общий взвешенный граф, где вес ребра соответствует количеству раз, которое это ребро встречается в исходных графах. В построенном взвешенном графе сообщества вершин определим с помощью Лувенского
метода
.
Автоматизированную версия вышеописанной процедуры вы можете найти в файле scripts/analyze_decomposition.py
.

Результирующий граф показывает, что поддомены в demo-приложении содержат сильно связанные сущности даже несмотря на невпечатляющие цифры: две связи не намного больше одной. В реальных проектах такие показатели точно не следует считать поводом для создания поддоменов, однако в нашем случае для демонстрации этого достаточно.
Итак, полученные сообщества можно использовать, чтобы определить группы наиболее связных сущностей. Однако решения о том, стоит ли поместить сущности из таких групп в один поддомен или даже объединить в агрегаты, нужно принимать с учетом знаний о предметной области. Полагаться на одну лишь связность не стоит.
Заключение
Кому-то вышеописанная структура проекта может показаться избыточной, ведь часто перед разработчиком встает вопрос о выборе структуры, когда кода еще крайне мало или его вообще нет. Я предпочитаю начинать проект, используя минимально затратные инструменты. Например, создать три пакета domain
, app
и infra
и использовать их по назначению очень просто и к тому же крайне полезно, если проект будет стремительно обрастать функциональностью в будущем.
Разделение на входные и выходные адаптеры также не потребует значительных усилий. Впрочем это никогда не поздно сделать и можно обойтись без этого на начальных этапах. Пакет domain в самом начале разработки может оказаться довольно простым и без поддоменов. При этом я рекомендую всегда его создавать, когда вы чувствуете, что через некоторое время появятся инвариантные правила.
Весьма просто на уровне приложения в пакете app
использовать структуры с одной основной функцией (как CommandHandler и QueryHandler), вместо структур со словом Service в названии и 10-20 привязанными функциями. Сервисная функция - это гибкое решение, маловероятно, что в ней нарушится принцип единственной ответственности, ведь она всего одна в структуре. К тому же для тестирования такой функции понадобится инициализировать зависимости только этой функции, а не все зависимости десятков функций гигантского Service.
HttpRequest > Command > DomainModel > CommandResult > HttpResponse
HttpRequest > Query > (БД) > QueryResult > HttpResponse
Каждый раз, когда так много dto кажется мне избыточным, я вспоминаю, как много раз жалел о том что во всех слоях у меня была одна структура, которая проверяла инвариантные правила, отображалась в json и в поля таблицы БД.
Надеюсь, что статья вам оказалась немного полезной. Спасибо за то, что ее прочитали!
Список литературы
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
https://martinfowler.com/bliki/CommandQuerySeparation.html
https://codesoapbox.dev/ports-adapters-aka-hexagonal-architecture-explained