1. Чому правило «не експортуй зайвого» інколи не рятує
Якщо ви вже почали ділити проєкт на пакети, то досить швидко з’являється спокуса: «А давайте я імпортуватиму отой зручний хелпер — він же експортований і так гарно лежить». І начебто нічого страшного: код же компілюється. Але за кілька тижнів виявляється, що половина проєкту знає про деталі іншої половини, а будь-яка правка запускає ефект доміно.
Проблема в тому, що експортовані й неекспортовані елементи розв’язують лише питання доступу до імен, але не розв’язують питання доступу до самого пакета. Якщо пакет доступний для імпорту, то рано чи пізно хтось — навіть ви за три дні — потягне звідти «тимчасову штуку, поки швидко». А потім це «поки швидко» стає архітектурою.
І от тут з’являється internal/: це спосіб сказати інструментам Go: «цей пакет — внутрішній, імпортувати його ззовні не можна, навіть якщо дуже хочеться».
Що таке internal/ і яке правило перевіряє Go
Суть механізму проста й водночас дуже «по-Go»: жодних анотацій, конфігів і магічних ключових слів. Є лише структура каталогів і правило, яке перевіряє go tool.
Go підтримує «internal packages» починаючи з Go 1.4: якщо пакет лежить у директорії internal (або всередині неї), то його не можна імпортувати з коду, який знаходиться поза дозволеним піддеревом. Правило описано прямо в документації: пакет виду .../a/b/c/internal/d/e/f можна імпортувати лише кодом усередині дерева .../a/b/c.
Те саме пояснює довідка go help packages: «код у директорії internal імпортуємо лише з дерева, коренем якого є батько internal».
Пояснімо це не на абстрактних a/b/c, а на людському прикладі. Уявімо структуру:
example.com/myapp/
app/
internal/
validate/
cmd/
myapp/
domain/
Якщо у нас є пакет example.com/myapp/app/internal/validate, то імпортувати його мають право лише пакети всередині example.com/myapp/app/..., тому що батько internal — це app. Пакети з cmd/... або domain/... — уже «ззовні», їм не можна.
Це важливо: internal/ не робить пакет «приватним за іменами», він робить його приватним за місцем у дереві.
2. Мапа доступу: хто може імпортувати кого
Зараз буде мінітаблиця, щоб не доводилося тримати все в голові (вона й так зайнята тим, щоб не забути поставити , у import).
Припустімо, у нас є такий пакет: example.com/myapp/app/internal/validate
Тоді обмеження будуть приблизно такі:
| Пакет-імпортер | Чи можна імпортувати app/internal/validate? | Чому |
|---|---|---|
|
так | він усередині дерева app/... |
|
так | він усередині дерева app/... |
|
ні | cmd/... не всередині app/... |
|
ні | domain/... не всередині app/... |
Ця проста таблиця — уже половина успіху під час проєктування: перш ніж створювати internal, запитайте себе: «Хто має право це імпортувати?».
3. Де розміщувати internal/: усередині шару чи біля кореня проєкту
Коли ви вперше дізнаєтеся про internal/, виникає бажання зробити так:
internal/
everything/
Тобто сховати туди взагалі все, що «не хочеться показувати». Це не завжди погано, але часто призводить до дивного ефекту: ви створили «псевдомоноліт» усередині проєкту, де все дружить із усім, бо internal стоїть занадто високо.
Тут доречно розрізняти два сценарії.
Перший сценарій — внутрішня частина конкретного шару. Тоді internal живе всередині шару, наприклад app/internal/.... Це означає: «внутрішні речі доступні лише сценарному шару та його підпакетам». Це чудовий спосіб не дати CLI/HTTP-шару лізти в чужу внутрішню логіку.
Другий сценарій — внутрішня частина всього проєкту (модуля). Тоді internal кладуть близько до кореня проєкту: internal/.... У цьому випадку майже всі пакети всередині проєкту зможуть його імпортувати, але зовнішні проєкти — ні. Це зручно для спільних утиліт, які не є публічною бібліотекою.
Жоден із варіантів не є «правильнішим» за замовчуванням. Правильний — той, що відповідає межі відповідальності. І так, це той випадок, коли архітектура буквально проступає в структурі папок.
4. Ховаємо валідацію в app/internal/validate
Продовжімо наш умовний todo-проєкт. У нас уже є:
- domain.Task і конструктор domain.NewTask(...),
- сценарний шар app, який оркеструє створення та збереження,
- адаптер сховища (наприклад, пам’ять).
Нехай у пакеті app у нас є логіка перевірки заголовка. Спочатку вона могла бути просто функцією в app, але тепер ми зробимо крок до суворішої межі: валідація — це деталь реалізації сценаріїв, а не те, що має імпортувати будь-хто.
Було: допоміжна функція усередині app
Почнімо з простого варіанта:
// app/validate.go
package app
import "errors"
var ErrBadTitle = errors.New("некоректний заголовок")
func validateTitle(title string) error {
if title == "" {
return ErrBadTitle
}
return nil
}
Тут усе начебто нормально, але в цієї схеми є підводний камінь: щойно у вас з’являться підпакети app/..., хтось почне копіювати або тягнути ці хелпери туди-сюди, і скоро буде «валідація в трьох місцях, але по-різному».
Стало: переносимо в app/internal/validate
Тепер зробімо так, щоб лише сценарний шар міг користуватися цією утилітою.
// app/internal/validate/title.go
package validate
import "errors"
var ErrBadTitle = errors.New("некоректний заголовок")
func Title(s string) error {
if s == "" {
return ErrBadTitle
}
return nil
}
Зверніть увагу: пакет називається validate, а не internal — internal це ім’я папки, а не ім’я пакета. Ім’я пакета має бути нормальним, щоб імпорт виглядав читабельно.
Використовуємо всередині app
Тепер у app ми імпортуємо внутрішній пакет:
// app/add_task.go
package app
import (
"context"
"example.com/myapp/app/internal/validate"
)
func AddTask(ctx context.Context, store TaskStore, title string) error {
if err := validate.Title(title); err != nil {
return err
}
return nil
}
Тут приклад короткий і не робить реального збереження (ми його вже писали раніше), але сенс видно: validate став внутрішньою деталлю app.
5. Перевіряємо обмеження: use of internal package not allowed
Зараз буде момент, який багато хто запам’ятовує найкраще: ви не домовляєтеся з командою, ви домовляєтеся з компілятором (ну гаразд, із go tool, але звучить менш епічно).
Уявімо, що в cmd/myapp/main.go хтось вирішив повторно використати валідацію, бо «ну а що такого».
// cmd/myapp/main.go
package main
import (
"example.com/myapp/app/internal/validate"
)
func main() {
_ = validate.Title("hello")
}
І от тут go вам чесно скаже: не можна. Це рівно те, чого ми й добивалися: CLI не має лізти в внутрішню частину сценарного шару.
Формулювання правила в офіційній довідці go help packages якраз про це: імпорт пакета з internal допустимий лише всередині дерева батька internal.
6. Корисні нюанси й коли internal/ доречний
Кілька internal/ в одному проєкті — це нормально
Коли ви вперше використовуєте internal/, здається, що він має бути один. На практиці їх може бути кілька, і це дуже зручно: кожна папка internal створює свою межу видимості.
Наприклад, така структура цілком має сенс:
example.com/myapp/
internal/
buildinfo/ (внутрішня частина всього проєкту)
app/
internal/
validate/ (внутрішня частина app-шару)
adapters/
memstore/
internal/
debug/ (внутрішня частина конкретного адаптера)
Логіка тут така: internal/buildinfo доступний усім пакетам проєкту, але не зовнішнім. app/internal/validate доступний лише всередині app/.... А adapters/memstore/internal/debug доступний лише всередині adapters/memstore/....
І це дуже приємна штука: ви можете тримати проєкт охайним, не перетворюючи корінь на «звалище утиліт», але й не розкриваючи зайвого.
Де internal/ особливо доречний
Зараз важливо не «зробити internal заради internal», а застосовувати його там, де він справді знижує зв’язність і захищає архітектуру.
Хороший кандидат — внутрішні реалізації, які часто змінюються. Наприклад, ви хочете мати в app кілька маленьких помічників: нормалізацію тексту, парсинг якихось вхідних рядків, підготовку повідомлень, просту валідацію. Якщо ці речі імпортуватиме зовнішній шар, ви перестанете вільно їх змінювати.
Ще один кандидат — речі, які виглядають надто спокусливо для повторного використання, але не повинні повторно використовуватися. Типовий приклад: який-небудь internal/sqlutil або internal/httpjson. На перших порах здається класним тягнути це всюди. Потім виявляється, що ви випадково зробили «спільний шар», від якого тепер залежать усі, і будь-яка зміна потребує синхронного виправлення половини проєкту.
А от поганий кандидат — справжній контракт. Якщо інший шар має користуватися чимось, то це має бути в публічному API: або в domain, або в app, або в окремому пакеті, призначеному для використання. internal не має бути способом «сховати те, що потрібно всім», інакше ви постійно боротиметеся з власними межами.
internal/ — це про імпорт, а не про експорт
Якщо ви звикли думати про публічне й приватне через великі та малі літери, то internal/ спочатку сприймається дивно: «А чому я можу написати Validate.Title, але не можу імпортувати пакет?».
Тому що механізм працює на рівні можливості імпорту, а не на рівні імен усередині пакета. Це прямо підкреслюється в описі механізму: go перевіряє шлях імпорту, і якщо в ньому є елемент internal, то перевіряє, чи знаходиться код-імпортер усередині потрібного піддерева.
Це означає, що всередині internal ви можете мати експортовані ідентифікатори — і це нормально. Вони просто експортовані лише для «своїх», тобто для тих, хто має право імпортувати цей пакет.
7. Типові помилки під час роботи з internal/
Помилка № 1: покласти internal/ занадто високо й отримати «спільне звалище».
Якщо ви створюєте internal у корені проєкту й починаєте складати туди все підряд, то формально ви «сховали від зовнішнього світу», але всередині проєкту зробили гігантський спільний пакетний суп. У якийсь момент майже кожен пакет почне імпортувати internal/..., і він стане прихованим монолітом. Лікується це не забороною, а дизайном: тримайте internal ближче до того шару, чиї внутрішні речі ви захищаєте.
Помилка № 2: покласти internal/ занадто низько й випадково заборонити «своїм» користуватися потрібним кодом.
Іноді роблять app/usecase/internal/..., а потім дивуються, чому app/service не може імпортувати. Причина проста: межа рахується за батьком конкретної папки internal. Якщо вам потрібно, щоб увесь app/... бачив внутрішні речі, то internal має лежати на рівні app/internal/..., а не глибше.
Помилка № 3: використовувати internal/ як заміну нормального контракту.
Якщо пакет потрібен кільком шарам, то він не «внутрішній», а спільний. Ховати його в internal — це як сховати спільний чайник у шафу й видавати ключ за розкладом: технічно можна, але команда почне ненавидіти і шафу, і чай. Краще підняти контракт у правильний шар (часто це domain або публічний пакет app).
Помилка № 4: намагатися «обійти» заборону копіюванням коду або дивними шляхами імпорту.
Заборона існує не для того, щоб ускладнити вам життя, а щоб показати: ви намагаєтеся протягнути залежність через неправильну межу. Якщо дуже хочеться імпортувати app/internal/... із cmd/..., зазвичай це сигнал: або в cmd не має бути цієї логіки, або ця логіка має бути оформлена як публічна частина app (через функцію чи метод), або винесена в інший пакет за відповідальністю.
Помилка № 5: забути, що помилка буде на рівні імпорту, а не «в рантаймі».
internal/ хороший тим, що ламає збірку одразу. Але новачки інколи сприймають це як «ой, Go дивний». Насправді він просто чесно застосовує правило з документації: імпорт .../internal/... дозволений лише з певного піддерева.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ