Перейти до змісту

Напрямок залежностей

Який пакет може імпортувати який — це те архітектурне рішення, яке важко скасувати. Go робить частину цього механічною: цикли імпортів — це помилка компіляції — але решта є правилом, яке доводиться тримати свідомо.

web  →  services  ←  storage
         (contracts)

І веб-шар, і шар зберігання залежать від контрактів. Контракти не залежать ні від чого.

Спрямовуйте залежності на абстракції

Наївне шарування змушує обробник імпортувати пакет сховища напряму. Це прив'язує ваш HTTP-шар до database/sql, і кожен тест обробника потребує базу даних.

Інвертуючи це: середній пакет оголошує інтерфейси й доменні типи, і обидві сторони залежать від нього.

// пакет services — контракт. Жодних імпортів бази даних узагалі.
type UserStore interface {
    ByID(ctx context.Context, id int64) (User, error)
}

type User struct {
    ID   int64
    Name string
}

var ErrUserNotFound = errors.New("user not found")
// пакет storage — реалізація
type userStore struct{ db *sql.DB }

func (s userStore) ByID(ctx context.Context, id int64) (services.User, error) { ... }
// пакет web — споживач
type Handler struct{ users services.UserStore }

web ніколи не імпортує storage. Тільки код старту, який уже знає все, з'єднує одне з іншим.

Де живе інтерфейс

Звичайна порада Go — "оголошуйте інтерфейс там, де він споживається", і для одноразового співучасника це правильно — маленький неекспортований інтерфейс поруч з обробником, що його використовує.

Центральний пакет контрактів — це варіант, що масштабується: одне місце, яке перелічує кожну операцію зберігання, наявну в застосунку, тож межа видима, а не розкидана. Компроміс у тому, що пакет росте, і інтерфейс у ньому може накопичити методи, які не потрібні жодному окремому споживачу.

Обидва варіанти законні. Що незаконне — оголошувати інтерфейс у пакеті реалізації — це спрямовує залежність не в той бік і зводить нанівець усю вправу.

Тримайте дескриптор бази даних подалі від верхніх шарів

Конкретне правило, що з цього випливає: *sql.DB має з'являтися рівно у двох місцях — у коді, що його відкриває, і в коді, що виконує запити.

Якщо обробник може назвати *sql.DB, він може виконати запит, і рано чи пізно виконає. Тоді сховище перестає бути єдиним шляхом до даних, і на питання "де записується ця таблиця" відповіді немає.

Те саме стосується пакета контрактів. Якщо services імпортує database/sql, щоб описати тип повернення, абстракція протекла, і кожен споживач успадковує залежність.

Помилки теж перетинають межу

Контракт — це не лише сигнатури методів. Просочування sql.ErrNoRows угору прив'язує виклики до рушія зберігання так само надійно, як і просочування дескриптора. Перекладайте на межі, як показує стаття про патерн репозиторію:

if errors.Is(err, sql.ErrNoRows) {
    return services.User{}, fmt.Errorf("id %d: %w", id, services.ErrUserNotFound)
}

Сигнальна помилка (sentinel error) належить пакету контрактів, тож виклики залежать від контракту і на щасливому, і на нещасливому шляху.

Напрямок важливіший за назви

Папки під назвами handlers, services і repositories — це не архітектура. Пакет services, що імпортує storage, має ту саму зв'язність, що й обробник, який робить це напряму, лише з додатковою непрямістю.

Питайте натомість:

  1. Якби я видалив пакет сховища, чи скомпілювалась би решта проти контрактів? Мала б.
  2. Чи можу я протестувати бізнес-логіку без жодної бази даних? Маєте змогти.
  3. Чи щось вище сховища імпортує database/sql? Не має.

Три відповіді так/ні кращі за будь-яку домовленість про папки.

Цикли — це симптом, не хвороба

Цикл імпортів — це спосіб Go сказати, що два пакети насправді є одним, або що щось спільне потребує винесення. Виправлення, у порядку переваги:

  1. Інвертуйте через інтерфейс. Якщо A потребує функцію з B, а B потребує тип з A, хай A оголосить інтерфейс, який йому потрібен, а B йому задовольнить. Тепер залежність вказує лише в один бік.
  2. Винесіть спільну річ у маленький третій пакет без власних залежностей — тип, ключ, сигнальну помилку.
  3. Об'єднайте їх, якщо це справді одна турбота, розділена з косметичних причин.

Чого не варто робити — додавати interface{} чи колбек лише для того, щоб обійти компілятор. Цикл — це інформація; прийміть її.

Забезпечення правила

Проза в README не переживає зіткнення з дедлайном. Правила тут — жодного *sql.DB вище сховища, жодних імпортів бази даних у пакеті контрактів — механічно перевірні, і лінтер може провалити збірку на них. Це конфігурація стороннього інструменту, тож вона живе поруч з іншими зовнішніми бібліотеками. Рішення про дизайн — ця стаття; забезпечення — конфігураційний файл.

З досвіду Python: та сама ідея інверсії залежностей, мінус абстрактні базові класи й реєстрація. Компілятор забезпечує ациклічність, чого Python не робить, тож цикл — це жорстка помилка, а не тонкий баг у порядку імпортів.

Швидка довідка

Питання Відповідь
де живуть інтерфейси на боці споживача або в центральному пакеті контрактів
де вони не мають жити у пакеті реалізації
що може називати *sql.DB код, що його відкриває, і сховища
що імпортує пакет контрактів не database/sql, не сховища
помилки зберігання перекладати на доменні сигнальні помилки на межі
що означає цикл імпортів інвертувати через інтерфейс або винести маленький пакет
справжня перевірка чи компілюється й тестується бізнес-логіка без бази даних

Джерела