SOLID объединяет пять правил: единственная ответственность, открытость/закрытость, подстановка Лисков, разделение интерфейсов и инверсия зависимостей. В Go они относятся к пакетам, типам и маленьким интерфейсам, а не к иерархиям классов, и неявные интерфейсы делают большинство из них дешёвыми.
S, единственная ответственность (single responsibility): у типа или пакета одна причина для изменений. OrderService, который ещё и рендерит счета в PDF и шлёт письма, меняется по просьбам трёх разных команд; вынесите это в отдельные типы.
O, открытость/закрытость (open/closed): новое поведение добавляют, не правя работающий код: новым типом, который реализует существующий интерфейс, или обёрткой-middleware, а не ещё одним case в switch по полю с видом объекта.
L, подстановка Лисков (Liskov substitution): любая реализация должна работать везде, где принимают интерфейс: никакого ErrNotSupported из метода, на который рассчитывают вызывающие, никакой паники там, где остальные возвращают ошибку.
I, разделение интерфейсов (interface segregation): клиент не должен зависеть от методов, которые не вызывает. В Go так выглядит интерфейс на один-три метода, объявленный потребителем: io.Reader, а не Storage на 14 методов.
D, инверсия зависимостей (dependency inversion): бизнес-код зависит от абстракции, которой владеет сам, а адаптер базы данных её реализует: сервис объявляет type orderRepo interface{ ... }, а main передаёт в него тип для Postgres.
GoMiddle
Архитектура и паттерны в Go
SOLID в Go, чистая и гексагональная архитектура, строительные блоки DDD, CQRS, паттерны в стиле Go, внедрение зависимостей
Закрыто вопросов
0 / 17
- 0Повторить
- 2Часто спрашивают
Вопросы
DRY (don't repeat yourself) требует, чтобы каждое знание жило в одном месте, KISS (keep it simple) велит выбирать самое простое работающее решение, а YAGNI (you aren't gonna need it) запрещает строить под воображаемые потребности. Слияние дублей вредит, когда копии лишь похожи внешне.
DRY касается знания, а не текста: ставка налога или правило для пароля, записанные в двух хендлерах, со временем разойдутся. Два цикла, которые случайно выглядят одинаково, не являются одним знанием.
Неверная абстракция: две похожие функции, слитые в одну с параметром mode bool, связывают вызывающих, которые меняются по разным причинам. Разверните её обратно в копии и дайте им различаться.
«Немного копирования лучше, чем немного зависимости» (одна из Go Proverbs): скопируйте хелпер на десять строк, а не тяните ради него библиотеку или общий пакет common.
KISS в Go: функция раньше интерфейса, обычный цикл for раньше generic-хелпера, стандартная библиотека раньше фреймворка.
YAGNI: никаких интерфейсов с единственной реализацией «на будущее», систем плагинов и флагов конфигурации, которые никто не выставляет. Интерфейсы реализуются неявно, так что добавить интерфейс, когда появится вторая реализация, займёт минуты.
Связанность (coupling) показывает, насколько пакет зависит от деталей других; связность (cohesion) показывает, насколько код внутри одного пакета относится к одному делу. Нужны слабая связанность и сильная связность: тогда изменение остаётся внутри одного пакета, а у каждого пакета одна задача.
Связанность видна по импортам: пакет хендлеров, который импортирует database/sql, модели ORM и три доменных пакета, ломается при изменении любого из них. Тот же симптом: изменение одной фичи затрагивает пять пакетов.
Широкая экспортируемая поверхность: экспортируемые поля и хелперы позволяют другим пакетам зависеть от деталей. Держите имена неэкспортируемыми, пока они не понадобятся снаружи, и границу будет охранять компилятор.
Слабая связность видна по имени: у utils, common или helpers нет одной задачи, поэтому такой пакет собирает несвязанный код, и его импортируют все.
Как исправить: группируйте код по доменам (order, billing), пусть потребитель объявляет маленький интерфейс вместо импорта конкретного типа, а код, который меняется вместе, живёт в одном пакете.
Быстрая проверка: go list -f '{{join .Imports "\n"}}' ./internal/order печатает, от чего пакет зависит на самом деле.
Нарушен принцип подстановки Лисков: код, который работает с Cache, ожидает, что Set сохранит значение, а ReadOnlyCache компилируется, но нарушает это обещание в рантайме. Исправление даёт разделение интерфейсов: отделите чтение от записи, чтобы тип только для чтения не заявлял о методе Set.
Почему компилятор это пропускает: Go проверяет только имена и сигнатуры методов. Контракт (после успешного Set метод Get находит значение) живёт в документации и в головах вызывающих.
Как это ломается: сервис со сквозной записью (write-through) вызывает Set после каждой загрузки из базы и каждый раз получает ErrReadOnly, так что он либо засоряет логи, либо роняет запросы. Затем вызывающие начинают писать if _, ok := c.(*ReadOnlyCache); ok, классический признак нарушения LSP.
Исправление: интерфейс Getter только с Get и Cache, который встраивает Getter и добавляет Set. Код, который только читает, принимает Getter, а ReadOnlyCache реализует один Get.
Теперь проверяет компилятор: если передать *ReadOnlyCache туда, где нужен Cache, получится ошибка сборки, а не строчка в продакшен-логе. Стандартная библиотека так же разделяет io.Reader, io.Writer и io.ReadWriter.
LSP касается и поведения: тот же смысл ошибок, никаких более строгих требований ко входу (Get, отвергающий ключи, которые принимают остальные), никаких паник там, где остальные возвращают ошибку.
Инверсия зависимостей задаёт правило: бизнес-код зависит от интерфейса, которым владеет сам. Внедрение зависимостей описывает приём: компонент получает зависимости снаружи. Инверсия управления шире: вашим кодом управляет кто-то другой. main, собранный вручную, делает DI без контейнера.
Инверсия зависимостей (буква D в SOLID): без неё order импортирует postgres. С ней order объявляет type Repository interface{ ... }, а postgres импортирует order, чтобы его реализовать: стрелка импорта идёт от детали к политике, против направления вызовов.
Внедрение зависимостей: NewService(repo Repository) вместо вызова postgres.NewRepo() внутри сервиса. Внедрение конкретного *postgres.Repo тоже остаётся DI, но ничего не инвертирует.
Инверсия управления: библиотека вызывает ваш код. http.Server вызывает ваш ServeHTTP, sort.Sort вызывает ваш Less, go test вызывает ваш TestX. DI служит одной из её форм: компонент больше не управляет тем, как создаются его зависимости.
**main, собранный вручную: единственное место, которое знает все конкретные типы, создаёт каждый и передаёт дальше. Это DI, и оно даёт DIP, пока каждый интерфейс объявляет пакет, который им пользуется.
DI-контейнер** (fx или Spring в Java) применяет IoC к самой сборке и решает, что создавать и в каком порядке. К DIP он ничего не добавляет, а лишь автоматизирует main.
Передавайте зависимости явно (параметрами конструктора, которые сохраняются в полях структуры) и собирайте весь граф вручную в main. Большинству сервисов фреймворк не нужен, а ручную сборку компилятор и так проверяет.
Внедрение через конструктор: NewOrderService(repo orderRepo). Сервис объявляет маленькие интерфейсы для того, что ему нужно, а тесты передают фейки.
Без глобальных переменных: var db уровня пакета, заданная в init(), прячет зависимость из всех сигнатур и заставляет параллельные тесты делить одно значение.
Корень композиции: main или вызываемая им функция run открывает базу, собирает репозитории, сервисы и хендлеры и запускает сервер. Сотню строк простых вызовов читать легче, чем любой контейнер.
**google/wire генерирует тот же код сборки из функций-провайдеров на этапе сборки: без рефлексии, и недостающая зависимость даёт ошибку сборки. Репозиторий архивирован в 2025 году, так что инструмент заморожен и не развивается.
uber-go/fx** строит граф в рантайме через рефлексию и добавляет хуки запуска и остановки. Ошибки сборки графа видны при старте, а не при компиляции; это окупается, когда у многих сервисов общий каркас приложения.
Большинство паттернов остаются, но сжимаются до возможности языка: Strategy становится интерфейсом или функцией, Decorator превращается в middleware, Builder в функциональные опции, Singleton в sync.OnceValue, Iterator в iter.Seq. Паттернам, построенным на наследовании, в Go делать нечего.
Factory: конструктор NewT; конструктор, возвращающий интерфейс, нужен, когда конкретный тип зависит от входных данных: net.Dial возвращает net.Conn, под которым в зависимости от сети лежит *net.TCPConn или *net.UnixConn.
Builder: функциональные опции или структура конфигурации. Цепочки методов встречаются в основном в построителях запросов вроде squirrel: sq.Select("id").From("orders").
Singleton: sync.OnceValue для ленивого, переменная var уровня пакета для создаваемого сразу, а чаще ни то ни другое: значение, созданное в main и переданное вниз, легко подменить в тесте.
Decorator и цепочка обязанностей (chain of responsibility): middleware вида func(http.Handler) http.Handler и gRPC-интерсепторы.
Adapter: http.HandlerFunc превращает обычную функцию в http.Handler; небольшая структура подгоняет сторонний клиент под ваш интерфейс.
Observer: канал или слайс колбэков для синхронных хуков, а между сервисами его роль играет брокер сообщений.
Исчезли: Template Method и абстрактные базовые классы требуют наследования, поэтому вместо них передают функцию или интерфейс; Visitor обычно превращается в type switch.
На собеседовании добавьте паттерн, который вы реально применяли, и зачем, например репозиторий за интерфейсом, чтобы тесты сервиса шли на фейке.
Стратегия представляет собой значение, которым вызывающий выбирает поведение: интерфейс с одним методом или просто функция. Функция подходит для одного вызова без состояния; интерфейс нужен, когда у реализаций есть состояние или настройки, нужно имя в логах или может появиться второй метод.
Функция легче: slices.SortFunc(xs, cmp) и strings.Map(f, s) принимают функцию, поэтому вызывающий пишет замыкание прямо на месте и обходится без именованного типа. Тест передаёт замыкание в одну строку вместо фейка.
Интерфейс подходит стратегиям с состоянием: CouponPricer хранит таблицу купонов, а реализация, которая ходит в сервис цен, хранит клиент и таймаут.
Как предложить оба варианта: объявите type PricerFunc func(Order) int64 с методом Discount, который вызывает саму функцию. Тогда обычная функция реализует Pricer, ровно так же, как http.HandlerFunc реализует http.Handler.
Почему метод у функционального типа работает: методы можно объявить у любого именованного типа, в том числе у функционального; метод просто вызывает своего получателя.
Выбор в рантайме: соберите map[string]Pricer в main и ищите стратегию по имени. Новая стратегия тогда означает новый тип и запись в map, а не ещё один case в бизнес-коде (принцип открытости/закрытости).
Декоратор встраивает UserRepo, поэтому UpdateEmail продвигается из внутреннего репозитория: запись уходит прямо в базу и не трогает кэш. Get отдаёт закэшированного User, пока не истечёт его TTL.
Почему: встраивание интерфейса переадресует все методы, которые структура не объявила сама. Автор переопределил Get и забыл, что записи тоже касаются кэша; у компилятора нет причин жаловаться.
Почему тесты не поймали: они проверяли попадание и промах в Get, но ни разу не перечитывали пользователя через декоратор после изменения.
Исправление: реализуйте UpdateEmail в декораторе: запишите в базу, затем удалите запись из кэша. Удаляйте, а не перезаписывайте, чтобы следующий Get загрузил закоммиченную строку, и удаляйте даже при ошибке, потому что запись, отвалившаяся по таймауту, могла всё же закоммититься.
Лучше именованное поле: с next UserRepo компилятор требует все методы, поэтому Delete, добавленный в UserRepo в следующем квартале, сломает сборку здесь, пока кто-то не решит, что он делает с кэшем. Встраивание уместно, когда переадресовать остальные методы без изменений всегда правильно, как в обёртке над http.ResponseWriter, которая только запоминает статус-код.
Остаётся узкая гонка: Get, прочитавший старую строку прямо перед обновлением, может вернуть её в кэш уже после удаления; TTL ограничивает, сколько проживёт такая копия.
Обе помещают бизнес-правила в центр, а всё техническое (HTTP, SQL, Kafka, сторонние API) на край и подчиняются одному правилу: зависимости в исходном коде направлены только внутрь. Ядро объявляет порты (интерфейсы), адаптеры их реализуют, а main соединяет одно с другим.
Гексагональная: ядро предлагает входящие порты (driving ports: о чём может попросить внешний мир, PlaceOrder) и объявляет исходящие порты (driven ports: что нужно ему самому, OrderRepository, PaymentGateway). HTTP-хендлеры и Kafka-консьюмеры управляют ядром; Postgres и платёжный клиент выступают управляемыми адаптерами.
Clean Architecture: та же идея, нарисованная кольцами: сущности, сценарии использования (use cases), адаптеры интерфейсов, фреймворки. Названия колец важны меньше, чем правило.
Правило зависимостей в Go: internal/order не импортирует ни database/sql, ни net/http, ни сгенерированные типы protobuf или ORM; internal/postgres импортирует order, но никогда наоборот. Список импортов ядра показывает это за секунды.
Что это даёт: сценарии тестируются на фейках в памяти за миллисекунды, а gRPC API рядом с REST или новое хранилище затрагивают только адаптеры.
Когда это перебор: CRUD-сервис, у которого домен совпадает с таблицами. Перекладывать каждое поле из DTO запроса в доменную структуру, затем в строку и обратно там бессмысленно.
Это не шаблон папок: пакеты entity/, usecase/ и repository/, скопированные из Java, размазывают одну фичу по всему дереву. Делите пакеты по фичам, держите SQL и HTTP вне ядра фичи и добавляйте порты, когда появляется второй адаптер или настоящие бизнес-правила.
Он протекает, когда в его сигнатурах есть типы или понятия хранилища: тогда каждый вызывающий зависит от базы через интерфейс, который должен был её спрятать. Репозиторий принимает и возвращает доменные типы, сообщает доменные ошибки и предлагает запросы, названные по смыслу.
Типы хранилища: *sql.Rows, pgx.Row, поля sql.NullString или модель GORM со встроенным gorm.Model заставляют сервис импортировать драйвер и самому разбираться со строками и NULL, а каждому фейку приходится подделывать и их.
SQL в аргументах: Find(ctx, where string, args ...any) переносит запрос в сервис. Назовите намерение, ListUnpaidBefore(ctx, cutoff, ...), а SQL оставьте в адаптере.
Ошибки драйвера: возврат sql.ErrNoRows или *pgconn.PgError заставляет вызывающих сравнивать ошибки драйвера. Переводите их в адаптере в ErrNotFound, ErrDuplicate и подобные.
Механика хранилища: параметр *sql.Tx снова выдаёт базу, поэтому работу с транзакциями спрячьте за одним хелпером. Для пагинации принимайте непрозрачный курсор: тогда адаптер перейдёт с OFFSET на keyset-пагинацию, не трогая вызывающих.
Обобщённый CRUD: Repository[T] с Update(ctx, T) выставляет наружу таблицу, а не домен: любой вызывающий может записать любое поле в обход правил, которые охраняет агрегат.
Проверка: можно ли реализовать интерфейс на map в памяти за 20 строк, не импортируя пакет базы данных? Если нет, он протекает.
Объявите порт в своих терминах (payment.Gateway с вашим типом Charge и ErrDeclined) и реализуйте его в одном пакете-адаптере, единственном, который импортирует SDK и переводит его типы, статусы и ошибки. Этот адаптер и есть антикоррупционный слой: модель провайдера на нём заканчивается.
Что иначе протекает: stripe.PaymentIntent в сигнатуре сервиса, строки статусов провайдера, которые сравнивают в бизнес-коде, проверки на *types.NoSuchKey из SDK для S3 в хендлерах. Тогда каждая мажорная версия SDK затрагивает всю кодовую базу.
Переводите всё, что пересекает границу: суммы (центы или десятичные строки), коды валют, статусы ("requires_action" становится вашим NeedsAuth) и ошибки: код отказа становится payment.ErrDeclined, ответ 5xx или таймаут становится ошибкой, которую можно повторить.
Храните id провайдера, а не его объекты: его id платежа сохраняют непрозрачной строкой для возвратов и сверки.
Что это даёт: второй провайдер или переезд означает новый адаптер; домен тестируется на фейковом Gateway, адаптер на песочнице провайдера или на httptest.Server; причуды провайдера (ключи идемпотентности, ретраи, форматы вебхуков) живут в одном пакете.
Чего это стоит: код маппинга и соблазн «всего один раз» протащить тип SDK насквозь. Когда домену нужна возможность, которая есть только у этого провайдера, добавьте её в порт явно.
У сущности есть идентичность, которая переживает изменения (заказ и его id), объект-значение определяется только значениями и никогда не меняется (Money), а агрегат объединяет их в группу, которую меняют только через корень, хранителя инвариантов. В Go их охраняют неэкспортируемые поля и методы.
Сущность (entity): сравнивается по id, а не по полям: два заказа с одинаковыми позициями всё равно остаются двумя заказами.
Объект-значение (value object): маленькая сравнимая структура: == сравнивает её поле за полем, а получатели по значению заставляют операции вроде Add возвращать новое значение вместо изменения старого.
Агрегат (aggregate): Order со своими позициями. Методы корня соблюдают правила вроде «никаких позиций после оплаты» и «одна валюта на заказ»; никто снаружи не держит указатель внутрь позиций, а одна транзакция меняет один агрегат.
Экспортируемые поля всё ломают: любой пакет может написать o.Paid = true или дописать что-то в o.Lines. Делайте поля неэкспортируемыми, выставляйте поведение (AddLine, Pay) и геттеры, а слайсы возвращайте копиями через slices.Clone.
Создание: New собирает корректный заказ. Внешний код всё равно может написать order.Order{}, поэтому методы должны быть безопасны и на нулевом значении.
Цена: адаптер Postgres в другом пакете не может сканировать в неэкспортируемые поля, поэтому домен экспортирует конструктор загрузки, который восстанавливает Order из сохранённых значений; его часто называют Rehydrate или FromSnapshot.
Возьмите интерфейс с неэкспортируемым методом-маркером: объявить этот метод может только свой пакет, и набор закрыт. Каждый вариант задаётся структурой с данными, код ветвится через type switch, а линтер (gochecksumtype или exhaustive для enum на iota) валит сборку на пропущенном случае.
Почему маркер закрывает набор: другой пакет не может объявить метод, совпадающий с неэкспортируемым isMethod, поэтому вариантами оказываются те типы, которые написали вы, у каждого свои поля вместо одной структуры с шестью необязательными. Единственная лазейка: структура в другом пакете, которая встраивает Card и получает продвинутый isMethod; её ловит последний return.
Полноту язык не проверяет: компилятор не следит, что type switch покрывает все варианты. Пометьте интерфейс //sumtype:decl, и gochecksumtype в golangci-lint сообщит о switch, где вариант пропущен; для констант на iota то же самое делает exhaustive для операторов switch.
Осторожно с default: ветка default удовлетворяет gochecksumtype, если только она не паникует, и прячет пропущенный вариант. Не пишите её, а то, что осталось (только nil-интерфейс), обработайте после switch.
Или перенесите поведение в варианты: вопросу, на который должен ответить каждый вариант (например, Fee()), место в интерфейсе, и тогда компилятор проверит каждый вариант. Type switch подходит для логики вне типов: рендеринга или маппинга в protobuf.
У состояний заказа данных нет, поэтому хватает enum на iota плюс exhaustive. Интерфейсным вариантам в JSON нужны поле-дискриминатор ("type": "card") и написанный вручную UnmarshalJSON.
Ограниченный контекст задаёт границу, внутри которой у модели и её терминов один смысл: Product в каталоге (описание, фото) не равен Product на складе (вес, полка) или в биллинге (цена, налог). Каждому контексту достаются свой пакет или сервис, свои типы и свои данные.
Как найти границы
На шов указывает одно и то же слово, которое значит разное для разных команд, или разные правила для одного и того же. Структура команд обычно указывает на те же линии (закон Конвея).
Хорошая граница держит большинство изменений внутри: новое правило ценообразования не трогает код склада.
Два контекста, которые меняются вместе и вызывают друг друга на каждом запросе, на деле являются одним контекстом, разделённым слишком рано.
В кодовой базе на Go
Начните с модульного монолита: internal/catalog, internal/inventory, internal/billing, у каждого свои типы и свои таблицы или схема. Общий пакет models означает одну модель под видом нескольких.
Контексты общаются через узкий API (экспортируемый тип сервиса или события) и никогда через чужие таблицы или внутренние структуры. depguard в golangci-lint может запретить импорты, которые пересекают границу.
Каждый контекст хранит свою копию того, что ему нужно: биллинг сохраняет цену, которую списал, а не ссылку на строку каталога, которая завтра может измениться.
Между контекстами
Потребитель переводит события чужого контекста в свою модель; это та же антикоррупционная идея, что и со сторонним SDK. Общее ядро (shared kernel: несколько типов, которыми владеют обе стороны, например Money) допустимо, но превращает каждое изменение в переговоры, поэтому держите его крошечным.
При таких границах вынос контекста в отдельный сервис позже сводится к смене транспорта. Сервис, охватывающий два контекста, или один контекст, размазанный по трём сервисам: так начинается распределённый монолит.
CQRS (command query responsibility segregation) отделяет код, который меняет состояние, от кода, который его читает: команды идут через доменную модель, запросы читают модель под экран. Отдельная модель чтения окупается, когда чтение и запись сильно различаются, и обходится в eventual consistency.
Два уровня
В коде: обработчики команд загружают агрегат и соблюдают правила; функции запросов обходят его и выполняют простой SELECT в DTO. База та же, согласованность не страдает, и часто сервису на Go больше ничего не нужно.
В хранилище: денормализованная таблица, поисковый индекс (OpenSearch, Elasticsearch) или представление в Redis, которые обновляются из событий или CDC после коммита записи.
Когда модель чтения окупается
Чтений на порядки больше, чем записей, и им нужны джойны между агрегатами, полнотекстовый поиск или заранее посчитанные итоги; иначе запрос дашборда конкурирует с записью за одни и те же таблицы.
Модель записи богата инвариантами, и прогонять через неё экраны списков значит загружать целые агрегаты ради трёх полей.
Чем приходится платить
Задержка: пользователь сохранил, а в списке изменения ещё нет. Верните новое состояние из команды, читайте с primary для этого пользователя несколько секунд или покажите элемент как «обрабатывается».
Проекции: они должны быть идемпотентными, применять события по порядку для каждого агрегата (проверяя номер версии) и пересобираться с нуля после бага или смены схемы.
Эксплуатация: две модели, больше инфраструктуры, а задержку проекций нужно мониторить и алертить. Для простого CRUD это перебор.
В event sourcing источником истины служит журнал доменных событий, куда только дописывают, а текущее состояние получают его воспроизведением. С состоянием и outbox истиной остаётся строка, а события лишь уведомляют о её изменениях: старые события можно выбросить или переделать без потери данных.
Как это работает
Каждый агрегат образует поток (stream): Placed, LineAdded, Paid. Загрузка сворачивает поток в состояние; сохранение дописывает новые события со следующей версией, а уникальный ключ (stream_id, version) заставляет конкурентного писателя упасть: оптимистичный контроль конкурентного доступа без блокировок.
Модели чтения строятся как проекции тех же событий, поэтому event sourcing почти всегда идёт вместе с CQRS.
Он даёт аудит по построению, состояние на любой момент в прошлом и новые проекции, построенные из истории.
По сравнению с состоянием и outbox
С outbox таблица свободно меняет форму, запросы пишутся на обычном SQL, и ничего не пересобирается из событий.
В event sourcing события и есть база: чтобы запрашивать текущее состояние, нужна проекция, а неверное событие не исправить через UPDATE.
Что делает его трудным
Эволюция схемы: события хранятся вечно, поэтому каждая старая версия должна загружаться через апкастеры (upcasters), которые переводят события v1 при чтении, или через версионированные типы событий.
Длинные потоки: воспроизводить 100 000 событий на каждую загрузку медленно; снапшоты каждые N событий ускоряют загрузку и добавляют ещё одну вещь, которую нужно держать корректной.
Ошибки и удаление: неверное событие исправляют компенсирующим событием. Удалить персональные данные из неизменяемого журнала позволяет crypto-shredding: данные шифруют ключом на пользователя и удаляют ключ.
Проекции: пересборка одной может занять часы, а каждый потребитель должен быть идемпотентным и соблюдать порядок внутри потока.