Прелюдия

Важность хорошей структуризации проекта могут недооценивать только совсем уж начинающие программисты. Сопровождение и развитие проекта с непродуманной структурой кода со временем превращается в сущий ад. Тут выплывают и проблемы со связанностью модулей и проблемы с плохой читабельностью и экспонтециально растущая трудоемкость развития проекта. Но всякая структура должна подчиняться некой логической концепции. И тут появляется великое поле для холиваров.

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

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

С небес на землю

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

Ближе к телу

Я перешел в Go разработку, имея хороший опыт разработки в Delphi, и небольшой - разработки приложений под Android. Поэтому, вопрос выбора архитектуры отнюдь не считал праздным. Ну и следуя принципу "не изобретай то, что изобретено до тебя", полез читать статьи, смотреть примеры... Потратив на это некое приличное волне время, обнаружил, что ясности в голове сильно больше не стало. Да везде встречаю слои, везде упоминается некая "чистая" архитектура, но и всё. Дальше - каждый молится своему богу. Разбивают на слои по-разному, изолируют слои по разному, называют сущности по-разному. И при этом мою голову сверлила одна и та же мысль - насколько оправдано "городить огород" следуя прекрасной идеалистической концепции, когда мы пишем небольшой микросервис? Можно ведь так дойти до абсурда, когда 90% кода обеспечивают бескомпромиссную архитектуру, а функциональность - оставшиеся 10%. И главное, поддерживать потом всё это будет сложнее, а не проще. Как говорится - за что боролись, на то и напоролись. Ну взять хотя бы пресловутую изоляцию слоёв - так ли всегда оправданы отдельные модели для каждого слоя для одной и той же сущности в микросервисе?

Предмет разговора

Во многих шаблонах, которые я смотрел, пытаясь выбрать удобный для себя, меня смущало отсутствие однозначной логики в разбивке на слои. Хотелось видеть максимально понятный и простой принцип, по которому код разбит на слои. Чтоб не ломать потом голову - куда правильнее поместить новую структуру (модуль) и не кусать локти, когда в итоге она оказалась не в том месте. Вероятно, я не достаточно вчитывался и проникался... Тем не менее, в конечном итоге, я изобрел свой велосипед, хотя и являюсь противником велосипедостроения.

Собственно слои нас в первую очередь избавляют от перекрестной связанности между модулями, что является самым большим (наверное) злом в любой архитектуре.
Принцип, которым я руководствовался - "клиент/сервер" в максимально абстрактном понимании. Клиент - тот кто потребляет некую услугу, Сервер - тот кто предоставляет эту услугу. Всё. Всего слоев - три. Таким образом, первый слой моего шаблона - это некие внешние "ручки" (REST API. Брокер очередей и т. п.). Этот слой носит название "api" (просьба сильно не бить ногами за нейминг тут и далее, он спорный, но не имеет принципиального значения). Слой api является клиентом по отношению к нижележащему слою бизнес логики, именуемому "service". Ну тут, думается, можно обойтись без пояснений - бизнес-логика, она бизнес-логика и есть. Слой "service" в свою очередь является клиентом по отношению к слою "controller". Слой "controller", это слой низкоуровневых тупых исполнителей, максимально лишенный логики - repository, отправка любых запросов наружу и пр. Собственно, слово "controller" взято из лексикона компьютерной техники - контроллер дисковода, контроллер дисплея. И да, термин спорный. Таким образом, все вызовы между слоями распространяются сверху вниз, в одну сторону - от клиента к серверу. Это главное и это гарантировано избавляет от перекрестной связанности. И логика тут максимально проста и понятна. Собственно, это и всё, в основном.

Некоторые пояснения

В слое api помимо вызовов, приходящих извне должны, следуя логике, располагаться и любые внутренние клиенты, генерирующие вызовы к слою service, например планировщик заданий. Если какой-то модуль может играть роль и клиента и сервера, его следует разбить на два интерфейса, даже если под ними будет лежать один объект (лучше не использовать callback для перекрестных вызовов). Пример - модуль брокера очередей, который может и отправлять сообщения и реагировать на входящие сообщения. Создаем один объект, реализующий два интерфейса и используем его как два разных интерфейса в слоях api и controller. Как вариант, если такая необходимость появляется.

Прочие плюшки

Вопреки принципам "чистой архитектуры", по возможностью я использую одну модель для одной сущности во всех слоях. Общие модели, используемые в разных слоях складываю в пакет internal/model (не внутрь одного из слоёв!). Мой опыт показывает, что адаптация модели под работу одновременно и с БД и с HTTP API (struct tags, marshalling/unmarshalling) - всё-таки меньшее зло, чем бессмысленное в плане функциональности копирование и переупаковка одних и тех же данных из объекта в объект. Но это не всегда возможно, особенно если вы используете кодогенерацию Design-First, когда структуры создаются автоматически. Кодогенерация Code-First при реализации API избавляет от этой проблемы и многих других, в частности с ней количество кода в слое API получается в разы меньше и он более гибок и понятен.

Внутри слоев я стараюсь единообразно разбивать код на файлы/пакеты если это возможно. К примеру, если в слое api есть два файла user.go goods.go, то и в слое service есть одноименные файлы и в пакете repository слоя controller - ровно те же user.go goods.go. Это мелочь, но она существенно снижает когнитивную нагрузку при работе с проектом.

Послесловие

Я не пытаюсь навязать кому-то свой подход как лучший из лучших. Просто хочу поделиться опытом. Опыт вполне себе успешный - больше десятка сервисов. Конструктивных недостатков у данной концепции я пока не обнаружил. В любом случае буду благодарен за аргументированную критику и советы.

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


  1. vkomp
    06.11.2025 07:53

    Не очень понятно, что вы предлагаете. В принципе все делят на слои, а как - это уже "все мужчины имеют свой способ избавления от последней капли".
    Понятно выделение слоя данных, а вот с бизнес-логикой не все понятно - где границы связности сущностей?! База в том, что сущности без связности вообще не работают. А если связывать в логике, то голанг видит зацикленность импортов. Кто-то разбивает через интерфейсы, это весьма тяжело поддерживать, когда много сущностей связано с многими сущностями.
    При этом не вижу ценности делать "тупые контроллеры" - я обнаружил, что этот слой очень ценен, так как обладает особенной логикой (https://habr.com/ru/articles/901938/). Например: http-обработчик проверяет доступ через jwt-токен, и выдает http-ошибку, а обработчик из очереди не будет проверять доступ перед исполнением, и выдает ошибку только в логгер.
    И такой подход юзаю уже год - код легко поддерживать, изменения вносить очень удобно. Пока кризиса не предвижу.


    1. stoi Автор
      06.11.2025 07:53

      "где границы связности сущностей?!" - не понял ваш вопрос. Если вы о моделях (DTO), то общие живут в отдельной папке internal/models не общие - соответственно в <layer>/models. Что вы подразумеваете под "связывать в логике"?
      Моя идея (не моя изначально конечно) - вся бизнес-логика - только в слое service. Проверка доступов очевидно не относится к бизнес-логике. Бизнес-логику (именно "бизнес") лучше не размазывать по слоям а сосредоточить в слое service. Тогда и понимать код и сопровождать будет проще. Ну это вроде как очевидный тезис...
      Например: репозиторий выполняет только атомарные функции с одной таблицей - читать/писать/изменять/удалять. Если нужно создать запись в таблице "заказы" и при этом изменить запись в таблице "оплаты" - делаем один метод "СоздатьЗаказ" в слое service, который в транзакции делает два обращения к репозиторию. Но именно в слое service - я об этом.
      Если же у вас возникает зацикленный импорт - верный признак ошибочной архитектуры.


    1. stoi Автор
      06.11.2025 07:53

      Собственно если в двух словах о том, что я предлагаю: Объекты из верхнего слоя могут обращаться к объектам нижнего слоя, но не наоборот. Нижележайщий слой относится к тому что выше - как сервер к клиенту. Клиент может вызывать методы сервера, но сервер не может вызывать методы клиента. Таким образом, вызовы распространяются всегда в одну сторону "сверху вниз", что исключает зацикленные ссылки между слоями. Порядок слоев сверху вниз: "api" -> "service" -> "controller". Всё. В этом суть моего предложения.


      1. vkomp
        06.11.2025 07:53

        Модели DTO весьма странные штуки, которые, имхо, появились в go из другого языка. В go стоит передавать данные как аргументы. Но когда полей много, и их порядок сложен, то возникает соблазн объединить в один объект. Норм если точечно, но если юзать везде, то вдруг оказывается, что их также надо поддерживать. И это горькая пилюля.
        Пример зацикленности - организация и рабочая группа как часть. Организация знает и спрашивает вложенные рабгруппы. Но в рабгруппе хочется узнать про организацию, и кто ее директор. И "в лоб" уже цикл. Разруливается, но если таких много - персона и участник организации и туда-обратно. Вот про такие границы между сущностями я думал.
        Логика сверху-вниз - это обычное явление. Есть варианты с тем что верх и что вниз - у кого-то это снизу-вверх, но тождественно. И тут есть споры - можно ли сверху-вниз стучаться, минуя слои - из верхней логики сразу в слой данных, например. Если упираться и последовательно проксироваться через слои, то при росте количества функций выглядит весьма уродски.
        Транзакции тоже создают вопросы. ИМХО, должны быть на верхнем уровне (все последующие у меня принимают универсальный интерфейс), потому что наверху "оркестратор" решает: удачно закоммитился, то создаем сообщение об обновлении. Если транзакция ниже слоем, то возникает сложность вернуть в предыдущее состояние.


        1. stoi Автор
          06.11.2025 07:53

          Ну моя статья не про разработку структуры данных ). Вы пишете о зацикленности в структуре данных (моделях, таблицах БД). Это отдельная тема.
          Стучаться минуя слои - нехорошо, ИМХО. Лишние три строчки кода на вызов - не такая большая издержка чтобы ломать идеологию. Ну и как аналогия - клавиатура ПК не может обратиться к жесткому диску, минуя процессор ))
          Есть такая штука Теория разбитых окон. Одно окно разобьешь - со временем все повыбивают ))


          1. vkomp
            06.11.2025 07:53

            Уточню, что не структуры данных. А цикл импортов. Сущности великолепно себя чувствуют по отдельности - разные ветки и ок. А есть бизнес-слои "одного уровня", где они встречаются. И когда два слоя entity стучатся друг к другу - цикл. И не получается дублировать структуры, как в typescript - в го это разные структуры. Придумываются всякие DTO, которые идентичны оригиналу, но есть экспортируемые поля через методы (или дубликат структуры с преобразованием 1-к-1) - вот это костыль, имхо.
            Я порешал тему, подняв логику на обработчики - вообще 0 циклов. И хорошо читается код - например, всё про патч объекта ушло в http-обработчик. Вроде как не логично, но работать сильно проще. Оказалось, что запрос через очередь не будет пересекаться с запросом от сайта - разная логика и разные входящие данные. Потому за год не было конфликта в логике.


            1. stoi Автор
              06.11.2025 07:53

              К сожалению, я не понимаю о чем вы говорите ((
              "два слоя entity стучатся друг к другу - цикл" - какие слои entity? Зачем и как стучатся?... Извините.


              1. vkomp
                06.11.2025 07:53

                Entity/use_cases - это популярные слои. Походу я напутал. Use_cases решает "сложные" операции - типа "умные". И может вызывать entity и db. И entity - слой только про одну сущность. Типа здесь должно быть просто CRUD-операции, но внезапно разрастается из-за логики проксирования - нельзя в db минуя entity. И разделение на модули выше уровнем - я такое видел.
                И вот use_cases умеет всякие штуки, типа запрашивать данные из связанных сущностей. И один модуль запросто встретится с другим - функция из организаций вызывает список рабочих групп другого модуля. А рабочие группы вызывают полномочия участника в организации на переименование.
                Логично выделить такие "сложные" функции в отдельный слой, который может. И это получается супер слой, который чересчур сложен. И выше пишу - этот супер слой отлично распиливается в обработчики, но уже не тупые.


                1. stoi Автор
                  06.11.2025 07:53

                  То что описано в первом абзаце - и есть криво спроектированная архитектура, насколько я понимаю. То что используется в разных слоях/сервисах/пакетах, должно быть вынесено в отдельное место.
                  И если в use_cases есть несколько сервисов/пакетов, которые потенциально могу обращаться друг к дружке - это косяк, безусловно. Нужно выделить эту самую общую сущность из них.
                  Но это уже отдельная тема, ИМХО. Про более низкий уровень архитектуры. И да, она важная.
                  Сталкивался с таким. Когда в слое service (use_case) джун сделал тупо несколько CRUD-сервисов. Пока были обращения к одной таблице внутри метода - всё было ок. Типа записать/прочитать таблицу users... Ну а потом стало весело )))


                1. stoi Автор
                  06.11.2025 07:53

                  Логично выделить такие "сложные" функции в отдельный слой - мы с вами путаемся в терминологии. Это уже не "слой" в моём понимании, а некая групповая сущность в слое use_cases - "сервис", наверное. И да выстроить иерархию таких сервисов внутри слоя - не всегда просто.
                  Решение в лоб - не делить use_cases на сервисы вообще. Ну или делить условно - просто в разные файлы раскидать оставив одно пространство имен. Для небольших микросервисов - это самое оно.
                  Но когда проект большой, надо как-то уже разбивать это на отдельные сервисы иначе имена методов становятся длиннющими (нужно использовать префиксы) и код всё больше становится похож на лапшу ))


                1. stoi Автор
                  06.11.2025 07:53

                  Но ключевой тезис, который я тут озвучил - "Сервер не может быть одновременно клиентом и наоборот" он как раз и тут спасает. Один объект может дергать методы другого, но при этом тот другой ни в коем случае не должен дергать методы первого. А лучше - вообще не знать о его существовании. Типа вот я сервис - пользуйтесь мной кто угодно.
                  Многоуровневая иерархия, где каждый уровень является сервером для уровня выше и клиентом - для уровня ниже.
                  Если всегда стараться следовать этому принципу (и в слоях и в иерархии объектов внутри слоя) - проблем не будет.


            1. stoi Автор
              06.11.2025 07:53

              А есть бизнес-слои "одного уровня"
              Как это возможно?... Бизнес слой - это абстракция (папка service) и он один...


              1. vkomp
                06.11.2025 07:53

                Думаю, что зависит от назначения приложения. У меня сервер обслуживает сайт. И умеет всё с данными - это бизнес. А из сервисов там cron, рассылка и sse...


                1. stoi Автор
                  06.11.2025 07:53

                  Нет, я с вами не согласен. Слой use_cases это абстракция. И она одна в любом приложении. В случае с Go - папка/пакет. А уж внутри этого пакета может быть сколь-угодно сложная иерархия папок/пакетов, но они уже не называются слоями - это содержимое общего слоя use_cases (в моём случае я назвал его "service", но это не играет роли)


                  1. stoi Автор
                    06.11.2025 07:53

                    Потому я вас и не понимаю, видимо )) Под словом "слой" вы подразумеваете другое.


  1. kukymbr
    06.11.2025 07:53

    Мне кажется, кто тут ключ именно в

    не существует какой-то идеальной концепции структуры проекта, подходящей для большинства задач

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


    1. stoi Автор
      06.11.2025 07:53

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


  1. ichukayev
    06.11.2025 07:53

    Какое направление зависимостей, в итоге получается, между слоями, в предложенном подходе?


    1. stoi Автор
      06.11.2025 07:53

      Я добавил диаграмму в статью.


      1. ichukayev
        06.11.2025 07:53

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


        1. stoi Автор
          06.11.2025 07:53

          Я писал, что нейминг у меня спорный - не обращайте внимания. Контроллеры тут - про другое совсем. По поводу зависимости:
          Когда ваша программа должна издать звук, вы дергаете у интерфейса аудиокарты например метод Beep(). Но аудиокарта не может обратиться к программе. Так и тут. Программа - слой service (use case), аудиокарта - слой contoller. По аналогии с компом, в слое контроллер - контроллер HDD, контроллер аудиокарты, контроллер видеокарты и т. п. Это слой "исполнителей", которые выполняют простые команды - запиши строку в БД, запиши сообщение в очередь NATS. Ну как-то так...