Наверняка вам уже не раз попадалась на глаза статься, в которой рассказывают о том, какие пакеты и файлы нужно создать в вашем проекте, чтобы код получился легко расширяемым и поддерживаемым. Эта еще одна статья такого рода, посвященная декомпозиции проекта 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
Комментарии (22)
Gariks
21.05.2025 19:55«Нельзя добавить более десяти пунктов в список дел» — небольшой вариант оптимизации
make([]TodoListItem, 0, 10)
Правда, с подобной структурой проекта это «оптимизация» ни на что не повлияет, так, просто «хороший тон».
rostislaved
21.05.2025 19:55оптимизацией чего? Если будет 10 млн списков дел и в каждом спике по 1-2 делу, а всего в нескольких по 10, то это не оптимизация.
Без паттерна использования и измерений не надо ничего оптимизировать, а то как раз "преждевременная" и получится
olku
21.05.2025 19:55На Хабре в последнее время каждую неделю по DDD статье. Это отлично, но что за хайп я пропустил?
rostislaved
21.05.2025 19:55Отличная статья, спасибо. Статей на эти темы много, но зачастую они бестолковые и с уймой ошибок. Тут же видно, что автор изучал то, о чем пишет.
SolidSnack
21.05.2025 19:55Большая статья, но если коротко, почему вы вначале рисуете зависимости снаружи во внутрь? Почему домену нельзя пользоваться своей же оберткой? Для чего вообще тогда слои? CQS, на сколько я понимаю относится к методам класса, а не архитектуре приложения, и вы, по всей видимости, путаете его с паттерном ООП команда. И почему у людей в гексогональной архитектуре порты только в 1 месте всегда? А слои как между собой общаются?
sim6a Автор
21.05.2025 19:55Доброго времени суток!
Стрелки для меня - это `import`. В своих проектах делаю импорты направленнми снаружи во внутрь. В статье я делюсь тем, что делаю, поэтому и нарисовл так.
Не совсем понял что вы имеете в виду под оберткой. Приведите, пожалуйста, пример.SolidSnack
21.05.2025 19:55Импорт типов из пакета
internal/infra
в пакетinternal/domain
запрещен, поскольку такой стрелки нет на рисунке.Ну и стрелку я отчетливо вижу) Нет?
SolidSnack
21.05.2025 19:55У вас "стрелочки" из домена идут внаружу только для общих пакетов, что вы сами и пишите:
В пакете
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
можно было бы оставить и в слое инфраструктуры, поскольку эта функциональность используется только там.И причем тут CORS? У вас реализация корс в каком-то отдельном пакете? Чем глубже читаешь, тем больше понимаешь что лайки заслужены конечно)
sim6a Автор
21.05.2025 19:55CQS использую как повод отделить функции, изменяющие состояние БД от функций, считывющих состояние БД. Command здесь - это просто название dto. Здесь нет паттернов, только организация структур и функций в слое приложения.
SolidSnack
21.05.2025 19:55Я маленько углубился, и вы похоже пытаетесь сделать (или просто аппелируете к этим понятиям) CQRS, который основан на CQS, для которого есть специальный язык заточенный под эту парадигму.
F0rzend
21.05.2025 19:55Спасибо за статью. Многие решения откликаются во мне.
Предлагаю обсудить интерфейсы.
Почему интерфейсы репозиториев в домене?
Их необходимость ещё стоит обосновать (https://enterprisecraftsmanship.com/posts/ocp-vs-yagni/)
Но если они все-таки нужны... Мне кажется, что подход, при котором интерфейсы находятся в слое домена, взят из Джавы и прочих ООП языков, в которых в реализации явно указывается, что она реализовывает интерфейс.
В го типизация утиная и такой необходимости нет. Так зачем размазывать знание о хранении модели в слое модели?
Я предпочитаю определять интерфейсы по месту использования. В данном случае это слой приложения.sim6a Автор
21.05.2025 19:55Доброго времени суток!
Для меня репозиторий - это тоже доменная модель. Модель коллекции агрегатов. Репозитории используются многими command handles и domain services, поэтому я расцениваю репозиторий в своем роде как общий код. Пока удается сохранять небольшой список функций репозитория (add, update, delete, get) храню его рядом с агрегатами.
Однако часто в репозиториях появляются функции вроде `getByName`, `getBySomething`, `updateName` и т.д. Тогда это уже не модель предметной области, а набор функциональных интерфейсов, например `todoListGetterByName`.
В работе я активно использую интерфейсы, объявленные в месте использования (в слое приложения в основном), и активно комбинирую этот подход с репозиториями. Просто в статье не нашел место это показать.
totsamiynixon
21.05.2025 19:55Я обратил внимание, что у вас в доменных службах используется 2 репозитория.
Я пришел к выводу, что нужно быть аккуратным в этом вопросе, особенно в случаях, когда в рамках сервиса происходит изменение нескольких агрегатов. Если вы допускаете такое в своем домене, то такого рода изменение обязательно произойдёт (закон Мерфи).
Первая причина - это ограничивает масштабируемость приложения, происходит завязывание этих модулей друг на друга и разбить их например на микросервисы станет невозможным без изменения домена. Для вашего выдуманного сервиса это может быть актуально, т.к. using очевидно гораздо более demanding контекст, чем editing.
Вторая причина - проблемы с консистентностью. Агрегат подразумевает собой границу консистентности данных, но при попытке изменить несколько агрегатов в транзакции эти границы неконтролируемо раздвигаются. В целом можно изменять несколько агрегатов в одной транзакции, но только если они в одном контексте и разработчик четко отдает себе отчёт, что такая транзакция не имеет шансов нарушить какие либо инварианты или случайно перезаписать какие-то данные в БД, внесённые в параллельной транзакции. Тут тоже все по закону Мерфи.
Как правило в своих приложениях в рамках одной команды я обновляю только один агрегат. Если мне нужно информация типа состояния из editing контекста, то я передаю эту информацию на вход команды using контекста, при этом работая по контрактам using контекста; таким образом оба этих контекста работают независимо, и внесение изменений в один из них не означает внесение изменений в другой. Да, появляются "дубликаты" кода между контекстами, но по факту это не совсем дубликаты, и чем больше развивается приложение, тем более очевидным это становится.
Для композиции query из editing и вызова command из using, я использую слой оркестрации. Это application слой, который манипулирует доступными commands & queries. Оркестраторов может быть несколько, в зависимости от сложности бизнеса (кор бизнес, аналитика, отчёты, биллинг). Оркестратор управляет машиной состояний заложенной в некий процесс. Примером оркестратора может служить AWS Step Functions, или Mass Transit. Таким образом и слой домена и слой оркестрации содержат бизнес логику. Слой оркестрации содержит логику оператора, это собственно и есть то, что мы пытаемся автоматизировать. Слой домена предлагает кирпичики и ручки, с которыми работает оператор. Другая метафора оркестратора это блоки управления в авто. Есть агрегаты - ДВС, АКПП. БУ делает условный query в ДВС, далее БУ рассчитывает следующую передачу и на основе этого отправляет command в АКПП на переключение передачи. Точно так же есть отдельный БУ для системы мультимедиа и тд.
sim6a Автор
21.05.2025 19:55Доброго времени суток!
Спасибо за комментарий!
Если мне нужно информация типа состояния из editing контекста, то я передаю эту информацию на вход команды using контекста, при этом работая по контрактам using контекста
Подскажите, я правильно понял, что в соответствии с этим подходом клиент приложения сначала получает editing todo list с помощью query, а затем передает его в command контексту using?
Или, например, контекст editing публикует доменное событие `TodoListPublished`, а контекст using создает у себя модель PublishedTodoList, к которому потом можно направить команду TakeTodoList в контекст using?
Lewigh
Я правильно понимаю что на языке который намеренно создан чтобы не быть как в Java, философия которого не быть как Java, многие разработчики в который приходят потому что устали писать как в Java, и вообще одна из киллерфич и смыслов которого не быть как Java,
Вы пишете статью о том как на Go писать как в Java?
Поправьте конечно если я не прав, я не эксперт в Go, но из своего опыта я не понимаю зачем идти поперек языка? Вот к примеру зачем на каждый чих городить столько файлов? Ну в Java то для этого причина есть, там в файле не более одного публичного класса может быть. В Go нет никаких классов, как и в Rust философия модульно-пакетная, где концентрация логики идет по пакетам а не по отдельным файликам. Да и в целом язык очень ограниченный и призван брать простой и нужно использовать сильные стороны инструмента. А с таким подходом это будет пародия на Java так как в Go даже нет инструментов поддержки такой навороченности.
obabichev
У меня основной опыт тоже в Java/Kotlin, и сейчас пишу немного на Go. Я наоборот пришел к тому, что пакетная видимость очень удобна для дробления файлов.
Когда я вижу пакет, я мысленно рассматриваю его как один "обобщенный" файл. Т.е. я могу хоть по функции в файл дробить, но за счет того, что они в одном пакете - все "приватные" методы доступны. Если же мне надо что-то инкапсулировать (а ля создать новый Class), я запихиваю это в отдельный пакет.
Т.е. условно получается, что один class-file из java становится пакетом+файлом(пакетом+файлами) в Go...
Сейчас подумываю о создании базовой структуры с методом toString, hash, equals (joke)
rostislaved
Неправильно. Хватит демонизировать джаву. Го не создавали, чтобы он был НЕ КАК джава. Го скорее создавали для других типов задач, вроде инфраструктуры, где он себя кстати отлично чувствует (докер, кубер).
Но на го начали писать энтерпрайз приложения, а они обладают большой сложностю, которая продиктованна сложностью предметной области. И чтобы с этой сложностью совладать можно использовать разные паттерны, которые до этого использовались в джаве, как раз с целью совладания со сложностью!
Более абсурдным выглядит писать энтерпрайз приложения, где много бизнес логики и не использовать ничего из этого. В этом случае вы столкнетесь лицом к лицу с этой сложностью. Ее можно конечно отрицать и это почему-то популярно, но всё же приятнее, когда все упорядочено и не надо всё держать в голове.
А на файлы разделять за тем же, за чем и просто что-то большое разбивать на что-то мелкое - декомпозиция сложности. Все паттерны, все солиды, все эти DRY, KISS, GRASP и т.д. про совладание со сложностью.
Lewigh
Гугл создал себе целый язык общего назначения, со сборщиком мусора, горутинами и стандартной библиотекой полной компонентов для создания веб сервисом чтобы куберы писать? Интересно.
Большей чем чем Кубер в котором больше 2 миллионов строк кода?
И как, совладали? Или переложили сложность из одной категории в другую? Раньше сложно было разобраться из-за отсутствие структуры а сейчас невозможно разобраться но уже из-за тотального оверинженерига и десятков слоев абстракций. Однозначно победа. Давайте повторим.
Мир не крутиться вокруг Java и ее проблем а также вокруг проблем ООП языков. Не нужно если у Вас в руках молоток видеть все вокруг как гвозди. В мире есть не только ООП парадигма и проекты пишут в разных стилях и как то борются с этой вашей сложностью.
В Java это ограничение языка потому так и делали и так вошло в мейнстрим. Натягивать сову на глобус на другие языки где этой проблемы нет и где философия другая, такое себе занятие. Не нужно считать автором языков которые так намеренно сделали тупыми, что они не догадались сделать как в Java.
И еще раз, совладали то со сложностью по итогу? Например с той сложностью что SOLID уже несколько десятилетий пытаются объяснить в сотнях статей. Или ООП которым как выясняется за несколько десятков лет так никто толком пользоваться и не научился. Хороший же результат, что может пойти не так если такой подход внедрить туда где от этого решили отказаться и пойти другим путем?
MashinIvan
К сожалению, вы понимаете это неверно. Фраза «не как Java» не означает, что любое сходство с Java кодом автоматически является злом или дурным тоном. Она указывает на ряд фундаментальных различий, заложенных в сам язык, — например, в обработке ошибок или модели конкурентности.
Однако архитектура - это не про Java. Многие архитектурные принципы вообще не зависят от конкретного языка программирования. Речь идёт о системности, аккуратности и, если угодно, чистом коде.
Не стоит бояться изучать паттерны и архитектурные подходы - особенно если вы работаете над продуктовым приложением, а не пишете инфраструктурный тул, где уместна крайняя минимализация. В большинстве случаев как раз требуется системный подход.
И самое главное (постскриптум для начинающих разработчиков) - не стоит использовать фразу «Go не Java» как оправдание отсутствия архитектуры и незнания принципов проектирования.
Lewigh
Позвольте немного разверну ответ.
Фраза «не как Java» действительно не означает что Java это очень плохо и не нужно таким быть.
Вопрос в природе решений и их сильных и слабых сторонах. Go - это типичное асимметричное решение. Вместо того чтобы делать очередной более навороченный язык авторы принесли в жертву функциональность в обмен на минималистичность. Получился довольно топорный и не всегда удобный язык но который за счет этого был дико минималистичным а это значит что его очень быстро можно выучить и сильно ограниченным что значит что разработчики сильно ограничены в фантазиях по использованию языка. Из этого сложились сильные и слабые стороны языка: силен там где можно сделать просто и понятно и слаб там где к примеру нужно реализовывать DSL подобный код или строить сложные абстракции. И логично предположить что нужно развивать проекты в направлении сильных сторон языка.
Более того, это язык с другой философией и структурой. Если Java из времен где модно было ООП и не существует ничего вне классов, а классы связаны с файлами то Go это другая история скорее ближе к помеси C с современными языками, в нем нет никаких классов и построения логики вокруг них, есть пакеты, есть обычные функции и обычные структуры которые можно и нужно использовать не как ООП.
Когда проект пишется на Go в стиле Java то теряются многие сильные стороны языка потому как он для этого не предназначен и теряются сильные стороны, получается недо-Java 2.0 на минималках. По мне так это не очень хорошая история.
Захотелось привести хороший пример из истории. На заре Первой мировой войны, английский лорд Джон Фишер придумал идею линейных крейсеров т.е. кораблей с орудиями от линкоров но обладающих огромной скоростью, разумеется для этого пришлось пожертвовать броней. Как говаривал лорд - "скорость — лучшая броня ". Разумеется никто не подразумевал участие таких кораблей в линейном бою с линкорами противника. Это было типичное асимметричное решение - разменять броню на скорость и использовать согласно данным преимуществам. Но вот незадача, типичному адмиралу может прийти в голову мысль - "как можно не использовать корабли с такими большими пушками в линейном бою". Через определенное время так и случилось, в Ютландском сражении где адмиралы таки бросили линейные крейсера в бой и те начала гореть как спички и тонуть, Дэвидом Битти была сказана знаменитая фраза " There seems to be something wrong with our bloody ships today."
SolidSnack
Просто Go и микросервис это круто, а PHP и монолит это отстой.