Наверняка вам уже не раз попадалась на глаза статься, в которой рассказывают о том, какие пакеты и файлы нужно создать в вашем проекте, чтобы код получился легко расширяемым и поддерживаемым. Эта еще одна статья такого рода, посвященная декомпозиции проекта 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.

Файлы в пакете using
Файлы в пакете using

Выделим категории моделей:

  • объект-значение (value object);

  • сущность (entity);

  • агрегат (aggregate).

Краткое описание данных категорий:

  • сущность - это структура go, часто соответствующая строке в таблице БД;

  • объект-значение - это структура go, часто соответствующая полю в таблице БД;

  • агрегат - это кластер сущностей и объектов-значений.

Более подробно описывать не буду, потому что мы договорились, что DDD будет без фанатизма.

Каждый файл пакета internal/domain/using содержит по одной модели. По названию файла можно определить, какая модель в нем объявлена.

Когда требуется выполнить операцию с более чем одним агрегатом имеет смысл применить сервис предметной области. Сервис предметной области - это функция, привязанная к go-структуре. Поля структуры ссылаются на типы go, которые необходимы для выполнения задач сервиса. От этих типов зависит успешное выполнение сервисной функции. Будем называть такие типы зависимостями сервиса.

Сервисная функция TakeTodoListService
Сервисная функция TakeTodoListService

Поскольку сервис предметной области в 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).

Обработчики команд и запросов имеют те же анатомические особенности, что и сервисы предметной области - состоят из функции-обработчика и структуры с зависимостями.

Обработчик команд
Обработчик команд

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

Модель результата запроса
Модель результата запроса

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

Обработчик запроса без DTO
Обработчик запроса без DTO

Границы слоя приложения

В demo-приложении обработчики команд и запросов выполняют следующие функции:

  • определяют границы транзакций;

  • конвертируют входные структуры данных в модели предметной области и наоборот.

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

Обработка команды: демонстрация транзакции
Обработка команды: демонстрация транзакции

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

Обработка команды: демонстрация конвертеров
Обработка команды: демонстрация конвертеров

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

Структура пакета app
Структура пакета app

Разделение слоя приложения на 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-запроса

Границы слоя инфраструктуры

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

И конечно же нужно выделить в слое инфраструктуры входные и выходные адаптеры. Провести границы этих компонентов легко. Если речь идет о входном 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, является полносвязным.

Граф TakeTodoListCommandHandler
Граф 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://vaughnvernon.com

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

https://threedots.tech/post

https://martinfowler.com/bliki/CommandQuerySeparation.html

https://codesoapbox.dev/ports-adapters-aka-hexagonal-architecture-explained

https://arxiv.org/abs/0803.0476

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


  1. Lewigh
    21.05.2025 19:55

    Я правильно понимаю что на языке который намеренно создан чтобы не быть как в Java, философия которого не быть как Java, многие разработчики в который приходят потому что устали писать как в Java, и вообще одна из киллерфич и смыслов которого не быть как Java,
    Вы пишете статью о том как на Go писать как в Java?

    Поправьте конечно если я не прав, я не эксперт в Go, но из своего опыта я не понимаю зачем идти поперек языка? Вот к примеру зачем на каждый чих городить столько файлов? Ну в Java то для этого причина есть, там в файле не более одного публичного класса может быть. В Go нет никаких классов, как и в Rust философия модульно-пакетная, где концентрация логики идет по пакетам а не по отдельным файликам. Да и в целом язык очень ограниченный и призван брать простой и нужно использовать сильные стороны инструмента. А с таким подходом это будет пародия на Java так как в Go даже нет инструментов поддержки такой навороченности.


    1. obabichev
      21.05.2025 19:55

      У меня основной опыт тоже в Java/Kotlin, и сейчас пишу немного на Go. Я наоборот пришел к тому, что пакетная видимость очень удобна для дробления файлов.

      Когда я вижу пакет, я мысленно рассматриваю его как один "обобщенный" файл. Т.е. я могу хоть по функции в файл дробить, но за счет того, что они в одном пакете - все "приватные" методы доступны. Если же мне надо что-то инкапсулировать (а ля создать новый Class), я запихиваю это в отдельный пакет.

      Т.е. условно получается, что один class-file из java становится пакетом+файлом(пакетом+файлами) в Go...

      Сейчас подумываю о создании базовой структуры с методом toString, hash, equals (joke)


    1. rostislaved
      21.05.2025 19:55

      Неправильно. Хватит демонизировать джаву. Го не создавали, чтобы он был НЕ КАК джава. Го скорее создавали для других типов задач, вроде инфраструктуры, где он себя кстати отлично чувствует (докер, кубер).

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

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

      А на файлы разделять за тем же, за чем и просто что-то большое разбивать на что-то мелкое - декомпозиция сложности. Все паттерны, все солиды, все эти DRY, KISS, GRASP и т.д. про совладание со сложностью.


      1. Lewigh
        21.05.2025 19:55

        Неправильно. Хватит демонизировать джаву. Го не создавали, чтобы он был НЕ КАК джава. Го скорее создавали для других типов задач, вроде инфраструктуры, где он себя кстати отлично чувствует (докер, кубер).

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

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

        Большей чем чем Кубер в котором больше 2 миллионов строк кода?

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

        И как, совладали? Или переложили сложность из одной категории в другую? Раньше сложно было разобраться из-за отсутствие структуры а сейчас невозможно разобраться но уже из-за тотального оверинженерига и десятков слоев абстракций. Однозначно победа. Давайте повторим.

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

        Мир не крутиться вокруг Java и ее проблем а также вокруг проблем ООП языков. Не нужно если у Вас в руках молоток видеть все вокруг как гвозди. В мире есть не только ООП парадигма и проекты пишут в разных стилях и как то борются с этой вашей сложностью.

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

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

        Все паттерны, все солиды, все эти DRY, KISS, GRASP и т.д. про совладание со сложностью.

        И еще раз, совладали то со сложностью по итогу? Например с той сложностью что SOLID уже несколько десятилетий пытаются объяснить в сотнях статей. Или ООП которым как выясняется за несколько десятков лет так никто толком пользоваться и не научился. Хороший же результат, что может пойти не так если такой подход внедрить туда где от этого решили отказаться и пойти другим путем?


    1. MashinIvan
      21.05.2025 19:55

      К сожалению, вы понимаете это неверно. Фраза «не как Java» не означает, что любое сходство с Java кодом автоматически является злом или дурным тоном. Она указывает на ряд фундаментальных различий, заложенных в сам язык, — например, в обработке ошибок или модели конкурентности.

      Однако архитектура - это не про Java. Многие архитектурные принципы вообще не зависят от конкретного языка программирования. Речь идёт о системности, аккуратности и, если угодно, чистом коде.

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

      И самое главное (постскриптум для начинающих разработчиков) - не стоит использовать фразу «Go не Java» как оправдание отсутствия архитектуры и незнания принципов проектирования.


      1. Lewigh
        21.05.2025 19:55

        К сожалению, вы понимаете это неверно. Фраза «не как Java» не означает, что любое сходство с Java кодом автоматически является злом или дурным тоном. Она указывает на ряд фундаментальных различий, заложенных в сам язык, — например, в обработке ошибок или модели конкурентности.

        Позвольте немного разверну ответ.
        Фраза «не как Java» действительно не означает что Java это очень плохо и не нужно таким быть.
        Вопрос в природе решений и их сильных и слабых сторонах. Go - это типичное асимметричное решение. Вместо того чтобы делать очередной более навороченный язык авторы принесли в жертву функциональность в обмен на минималистичность. Получился довольно топорный и не всегда удобный язык но который за счет этого был дико минималистичным а это значит что его очень быстро можно выучить и сильно ограниченным что значит что разработчики сильно ограничены в фантазиях по использованию языка. Из этого сложились сильные и слабые стороны языка: силен там где можно сделать просто и понятно и слаб там где к примеру нужно реализовывать DSL подобный код или строить сложные абстракции. И логично предположить что нужно развивать проекты в направлении сильных сторон языка.
        Более того, это язык с другой философией и структурой. Если Java из времен где модно было ООП и не существует ничего вне классов, а классы связаны с файлами то Go это другая история скорее ближе к помеси C с современными языками, в нем нет никаких классов и построения логики вокруг них, есть пакеты, есть обычные функции и обычные структуры которые можно и нужно использовать не как ООП.
        Когда проект пишется на Go в стиле Java то теряются многие сильные стороны языка потому как он для этого не предназначен и теряются сильные стороны, получается недо-Java 2.0 на минималках. По мне так это не очень хорошая история.

        Захотелось привести хороший пример из истории. На заре Первой мировой войны, английский лорд Джон Фишер придумал идею линейных крейсеров т.е. кораблей с орудиями от линкоров но обладающих огромной скоростью, разумеется для этого пришлось пожертвовать броней. Как говаривал лорд - "скорость — лучшая броня ". Разумеется никто не подразумевал участие таких кораблей в линейном бою с линкорами противника. Это было типичное асимметричное решение - разменять броню на скорость и использовать согласно данным преимуществам. Но вот незадача, типичному адмиралу может прийти в голову мысль - "как можно не использовать корабли с такими большими пушками в линейном бою". Через определенное время так и случилось, в Ютландском сражении где адмиралы таки бросили линейные крейсера в бой и те начала гореть как спички и тонуть, Дэвидом Битти была сказана знаменитая фраза " There seems to be something wrong with our bloody ships today."


    1. SolidSnack
      21.05.2025 19:55

      Просто Go и микросервис это круто, а PHP и монолит это отстой.


  1. Gariks
    21.05.2025 19:55

    «Нельзя добавить более десяти пунктов в список дел» — небольшой вариант оптимизации

    make([]TodoListItem, 0, 10)

    Правда, с подобной структурой проекта это «оптимизация» ни на что не повлияет, так, просто «хороший тон».


    1. rostislaved
      21.05.2025 19:55

      оптимизацией чего? Если будет 10 млн списков дел и в каждом спике по 1-2 делу, а всего в нескольких по 10, то это не оптимизация.

      Без паттерна использования и измерений не надо ничего оптимизировать, а то как раз "преждевременная" и получится


  1. olku
    21.05.2025 19:55

    На Хабре в последнее время каждую неделю по DDD статье. Это отлично, но что за хайп я пропустил?


  1. rostislaved
    21.05.2025 19:55

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


  1. SolidSnack
    21.05.2025 19:55

    Большая статья, но если коротко, почему вы вначале рисуете зависимости снаружи во внутрь? Почему домену нельзя пользоваться своей же оберткой? Для чего вообще тогда слои? CQS, на сколько я понимаю относится к методам класса, а не архитектуре приложения, и вы, по всей видимости, путаете его с паттерном ООП команда. И почему у людей в гексогональной архитектуре порты только в 1 месте всегда? А слои как между собой общаются?


    1. sim6a Автор
      21.05.2025 19:55

      Доброго времени суток!
      Стрелки для меня - это `import`. В своих проектах делаю импорты направленнми снаружи во внутрь. В статье я делюсь тем, что делаю, поэтому и нарисовл так.
      Не совсем понял что вы имеете в виду под оберткой. Приведите, пожалуйста, пример.


      1. SolidSnack
        21.05.2025 19:55

        Импорт типов из пакета internal/infra в пакет internal/domain запрещен, поскольку такой стрелки нет на рисунке.

        Ну и стрелку я отчетливо вижу) Нет?


      1. 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? У вас реализация корс в каком-то отдельном пакете? Чем глубже читаешь, тем больше понимаешь что лайки заслужены конечно)


        1. SolidSnack
          21.05.2025 19:55

          Еще и на картинке тоже показываете


    1. sim6a Автор
      21.05.2025 19:55

      CQS использую как повод отделить функции, изменяющие состояние БД от функций, считывющих состояние БД. Command здесь - это просто название dto. Здесь нет паттернов, только организация структур и функций в слое приложения.


      1. SolidSnack
        21.05.2025 19:55

        Я маленько углубился, и вы похоже пытаетесь сделать (или просто аппелируете к этим понятиям) CQRS, который основан на CQS, для которого есть специальный язык заточенный под эту парадигму.


  1. F0rzend
    21.05.2025 19:55

    Спасибо за статью. Многие решения откликаются во мне.

    Предлагаю обсудить интерфейсы.
    Почему интерфейсы репозиториев в домене?
    Их необходимость ещё стоит обосновать (https://enterprisecraftsmanship.com/posts/ocp-vs-yagni/)
    Но если они все-таки нужны... Мне кажется, что подход, при котором интерфейсы находятся в слое домена, взят из Джавы и прочих ООП языков, в которых в реализации явно указывается, что она реализовывает интерфейс.
    В го типизация утиная и такой необходимости нет. Так зачем размазывать знание о хранении модели в слое модели?

    Я предпочитаю определять интерфейсы по месту использования. В данном случае это слой приложения.


    1. sim6a Автор
      21.05.2025 19:55

      Доброго времени суток!

      Для меня репозиторий - это тоже доменная модель. Модель коллекции агрегатов. Репозитории используются многими command handles и domain services, поэтому я расцениваю репозиторий в своем роде как общий код. Пока удается сохранять небольшой список функций репозитория (add, update, delete, get) храню его рядом с агрегатами.

      Однако часто в репозиториях появляются функции вроде `getByName`, `getBySomething`, `updateName` и т.д. Тогда это уже не модель предметной области, а набор функциональных интерфейсов, например `todoListGetterByName`.

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


  1. 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 в АКПП на переключение передачи. Точно так же есть отдельный БУ для системы мультимедиа и тд.


    1. sim6a Автор
      21.05.2025 19:55

      Доброго времени суток!

      Спасибо за комментарий!

      Если мне нужно информация типа состояния из editing контекста, то я передаю эту информацию на вход команды using контекста, при этом работая по контрактам using контекста

      Подскажите, я правильно понял, что в соответствии с этим подходом клиент приложения сначала получает editing todo list с помощью query, а затем передает его в command контексту using?
      Или, например, контекст editing публикует доменное событие `TodoListPublished`, а контекст using создает у себя модель PublishedTodoList, к которому потом можно направить команду TakeTodoList в контекст using?