Чистая Архитектура
на Go
Цель архитектуры программного обеспечения - уменьшить трудозатраты на создание и поддержку системы.
Роберт Мартин
Мне приходилось сталкиваться с разными подходами построения архитектуры приложений за время работы в сфере разработки. Не так давно я познакомился с принципами Чистой Архитектуры от Дяди Боба, и успешно следую им в работе над моими проектами. В данной статье я хочу поделиться с вами подходом к структуризации проекта на Golang, следуя данной концепции. Надеюсь, этот материал будет полезен не только начинающим разработчикам, но и опытные специалисты смогут для себя подчеркнуть что-то новое.

Если вы еще не знакомы с данной архитектурой, для начала вам стоит прочитать статью, в которой описаны основные концепции. Обратите внимание на Правило Зависимостей (Dependency Rule). Это, наверное, ключевое правило при проектировании архитектуры современного приложения. Следование данному принципу поможет вам получить систему, части которой легко тестируются, независимы от фреймворков, БД и протокола коммуникации с внешним миром (HTTP, RPC, etc). А самой системе будет свойственны понятия разделения ответственности и слабая связность компонентов.

Такие слабосвязанные системы позволяют нам без особого труда их масштабировать, модифицировать и дополнять их функционал. Дают возможность менять какие либо инфраструктурные компоненты (например БД) или фреймворки, не задевая при этом других частей приложения.

Также, если вы не знакомы с таким понятиям как Внедрение Зависимостей (Dependency Injection), рекомендую для начала с ним ознакомиться чтобы лучше понять реализацию данной архитектуры в нашем проекте.
UPD: Данную статью я написал в 2019 году. С тех пор я познакомился с альтернативными подходами к структурированию проекта на Go.

В своем видеокурсе "Разработка REST API на Go" я также разбираю тему чистой архитектуры и демонстрирую альтернативную структуризацию приложения.
Демо Проект
Для изучения данного подхода на практике мы с вами рассмотрим пример создания простого RESTful API. Оно предоставляет пользователю возможность создавать, просматривать и удалять закладки. В приложении используется собственная Sign Up/Sign In система на основе JWT токенов.

Запускать наше приложение будем в докер контейнере, для баз данных я выбрал MongoDB, в качестве HTTP Web фреймворка будем использовать Gin.

Конечно, вы можете использовать другие фреймворки или БД, лично для меня эти инструменты помогают достаточно быстро поднять рабочие приложения.

С самим проектом вы можете ознакомится в данном репозитории.

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

Сущности:
  • Пользователь (User)
  • Закладка (Bookmark
Бизнес правила:
  • Пользователь создает закладку
  • Пользователь удаляет закладку
  • Пользователь просматривает все закладки
Тут важно понимать, что вне зависимости от платформы, будь то мобайл, веб или консольный интерфейс (cli), у нас всегда есть пользователи, которые умеют создавать, удалять и просматривать закладки. Мы не должны привязываться к конкретному UI или платформе, бизнес правила остаются теми же.
На верхнем уровне директории проекта мы имеем 6 пакетов: auth, bookmark, cmd, config, models, server. Что такое cmd и config думаю понятно сразу, давайте подробнее остановимся на остальных.
В auth у нас реализован Sign Up/Sign In. В пакете bookmark мы имеем логику работы с закладками. В models у нас хранятся сущности, а в server реализован HTTP сервер.

Поскольку наше приложение — монолит, мы имеем несколько независимых компонентов внутри проекта, которые разделены по зонам ответственности.

Давайте рассмотрим auth и bookmark. В данных пакетах инкапсулирована логика регистрации/авторизации и работы с закладками соответственно. При построении монолита, перед тем как вынести какую нибудь часть функционала в отдельный пакет на самом верхнем уровне, я задаю себе вопрос: "Можно ли реализовать данный функционал как микросервис?". Если ответ "Да", тогда это точно отдельный пакет.

Для каждой из этих систем нам необходимо иметь сущность (Model/Entity), хранилище (Repository), бизнес логику (Usecase) и также транспортный слой (Delivery), в данном случае это эндпоинты нашего API.

Структуры пакетов auth и bookmark идентичны. На верхнем уровне мы имеем файлы repository.go и usecase.go, в которых определены интерфейсы репозитория и объекта, реализующего бизнес логику, соответственно. Также здесь расположены пакеты с идентичными названиями, в которых находятся имплементации этих интерфейсов, и отдельный пакет delivery.

Что ж, меньше текста больше кода!

Система регистрации
Прежде чем начать разбирать подробнее систему регистрации, давайте ознакомимся с сущностью пользователя (User).

models/user.go
Обратите внимание на поле ID (string). В MongoDB наши обьекты будут иметь ID типа UUID, а используя, например, Postgres в качестве главного хранилища, мы бы использовали целочисленное значение. Используя string, мы не привязываемся к конкретному хранилищу, и всегда можем конвертировать uuid или int в строку.

Пожалуй начнем разбираться с кодом с системы регистрации. Для начала, давайте определим сценарий работы системы.

При регистрации, мы должны сохранить пользователя у нас в БД, в отдельной таблице (коллекции, выражаясь терминами MongoDB). К паролю нужно добавлять соль и хранить в хешированном виде. При авторизации нам нужно найти пользователя в БД по логину+паролю и сгенерировать токен авторизации. При последующих запросах к приложению, мы должны валидировать наличие и корректность данного токена.

Давайте опишем интерфейс нашего репозитория, основываясь на требованиях выше.
auth/repository.go
И также опишем интерфейс Usecase, который должен описывать сценарий бизнес-логики данного функционала.
auth/usecase.go
Итак, мы с вами только что определили основные сценарии работы системы регистрации/авторизации и описали абстракции для хранилища и бизнес логики. Давайте приступим к их реализации!
Репозиторий
Как вы можете увидеть, в пакете repository мы имеем еще 3 подпакета с названиями, которые говорят сами за себя.

У нас есть реализация интерфейса репозитория на Mongo, также с помощью in-memory кеша (localstorage), и мок (для unit-тестирования обьектов, которые депендятся на наш репозиторий). Подробнее тему unit-тестирования и моков мы рассмотрим далее.
auth/repository/mongo/user.go
Хочу обратить ваше внимание, что в имплементации для MongoDB мы описали структуру пользователя, в которой поле ID имеет нужный формат, а также мы имеем теги bson. Эта структура служит прослойкой для работы с нашей структурой models.User в монге. Для конвертации структур напишем следующие функции:
auth/repository/mongo/user.go



Ну а далее давайте создадим сам объект репозитория, реализующего методы нашего интерфейса.

Итак, в этом пакете мы имплементировали работу нашего репозитория, работая с MongoDB.

Время рассмотреть бизнес-логику в имплементации Usecase!
UseCase

В пакете usecase находится одноименный файл usecase.go, а также реализация мока и покрытие unit-тестами.

Давайте посмотрим на структуру, которая реализует наш интерфейс.
Как видите, поскольку мы должны работать с хранилищем для сохранения и получения пользователя, наша структура имеет поле репозитория. Однако, обратите внимание, что мы принимаем в качестве репозитория интерфейс auth.UserRepository, а не конкретный обьект, например *mongo.UserRepository, реализующий интерфейс. Здесь мы используем принцип Dependency Injection, который я упоминал в начале. В этом кроется вся сила Правила Зависимостей.
auth/usecase/usecase.go
Мы реализовываем бизнес логику Sign Up'a, а именно: хешируем пароль+соль, создаем обьект типа models.User, и сохраняем его в хранилище. При этом нам абсолютно не важно, как под капотом работает userRepo.

Слой Delivery и HTTP сервер
Итак, мы рассмотрели с вами описание моделей, абстракций слоев Repository и Usecase, а также их реализацию. При этом, мы не привязывались к протоколу коммуникации с внешним миром, мы даже не задумывались об этом. Давайте теперь рассмотрим эндпоинты нашего приложения, которые позволят пользователю зарегистрироваться и авторизоваться на нашем сервисе.

У нас есть 2 эндпоинта:
  1. POST /auth/sign-up (создание нового пользователя)
  2. POST /auth/sign-in (авторизация с уже существующим логином и паролем, получение токена авторизации)

В пакете delivery мы имеем подпакет http, который реализует транспортный слой модуля auth средствами http протокола.

Обратите внимание на структуру обработчика (Handler), который имеет метод-хендлер на каждый эндпоинт.
auth/delivery/http/handler.go
Единственное поле, которое имеет данная структура, это usecase типа auth.UseCase, т.е. интерфейс который мы описали на самом верхнем уровне пакета auth.

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

Ответ на этот вопрос довольно таки прост — unit-тестирование. При тестировании наших методов-хендлеров, мы с легкостью можем передать в конструктор NewHanlder мок, реализующий интерфейс UseCase и сконцентрировать свое внимание лишь на тестировании кода нашего хендлера.
auth/delivery/http/handler.go
В хендлере SignUp, все что мы делаем, это парсим тело запроса и вызываем метод SignUp нашего Usecase с последующей обработкой ошибок. Опять же, нам не важно как под капотом работает сайн ап, хеширует ли он пароли, добавляет ли соль, куда сохраняет пользователей. Это детали реализации, которые не важны нам на данном этапе. Давайте рассмотрим unit-тест, покрывающий данный хендлер.
auth/delivery/http/handler_test.go
Как вы можете увидеть, мы с легкостью тестируем код нашего хендлера в изоляции от остальных частей. А добились мы этого благодаря использованию мока в качестве usecase'a при Внедрении Зависимостей.

Итак, мы детально рассмотрели все слои нашего модуля auth, давайте теперь поднимем HTTP сервер, чтобы обслуживать эндпоинты, про которые мы говорили выше.

Здесь я предпочитаю создавать отдельный пакет server на верхнем уровне, в котором реализовывать структуру нашего приложения. Она должна инкапсулировать необходимые зависимости, такие как репозиторий, usecase, http-сервер и т.д.
server/app.go
Как вы можете увидеть, в конструкторе NewApp, мы создаем новый инстанс обьекта AuthUseCase, и в качестве аргумента передаем Mongo-репозиторий. А что если мы захотим в будущем перейти на PostgresQL? Мы реализуем новый подпакет postgres в auth/repository, по аналогии с mongo. А в конструкторе приложения поменяем инициализацию mongo-репозитория на postgres-репозиторий. И все. Чувствуете всю гибкость и слабую связность данного подхода?

Давайте напишем метод запуска нашего приложения.
server/app.go
Здесь мы создаем наш роутер, регистрируем эндпоинты, инициализируем http-сервер, начинаем слушать на указанном порту. Также мы добавили graceful-shutdown.

Что значит регистрируем наши эндпоинты? Давайте вернемся в слой delivery пакета auth и найдем там файлик register.go.

auth/delivery/http/register.go
Здесь мы принимаем в качестве аргументов роутер, и обьект, реализующий интерфейс Usecase. Создаем хендлер, передавая наш полученый обьект и инициализируем эндпоинты.

И давайте перейдем к самому главному, запуск нашего приложения! В пакете cmd/api в файлике main.go напишем следующий код.
cmd/api/main.go
Здесь мы инициализируем глобальный конфиг и инициализируем наше приложение. Дальше мы запускаем его с помощью Run() и слушаем ошибки. That's it!

Подведем итоги
Мы рассмотрели с вами основные методы построения приложений на Golang следуя принципам Чистой Архитектуры.

Увидели, как на практике выглядит Dependency Rule (Правило Зависимостей) и применили Dependency Injection (Внедрение Зависимостей) для его достижения.

Я специально упустил разбор модуля bookmark. Он реализован по аналогичному принципу как и auth, поэтому предлагаю вам ознакомится с ним самостоятельно на этом GitHub-репозитории.

Если вы только начинаете свое знакомство с Go, советую вам прочитать мою книгу, которая покрывает все основные концепции языка для уверенного старта разработки.