JavaRush /Курси /Go SELF /Шари: domain → app/usecases → adapters

Шари: domain → app/usecases → adapters

Go SELF
Рівень 28 , Лекція 0
Відкрита

1. Схема шарів і напрямок залежностей

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

Шари допомагають розв’язати цю дивну механіку. Вони дають просту ідею: код групується за роллю, а залежності спрямовуються ззовні всередину. І найважливіше: у Go компілятор бере участь у цій дисципліні безпосередньо — через імпорт. import — це не просто «підключити функції», а згода залежати від чужого API на рівні компіляції.

Мінімальна мапа шарів: domainapp/usecasesadapters

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

Домовимося про терміни — вони дуже практичні:

Шар Що в ньому живе Чого в ньому бути не повинно
domain (домен/модель) дані предметної області та правила: сутності, інваріанти введення/виведення, файлів, HTTP, fmt.Println «про всяк випадок»
app / usecases (сценарії) операції рівня застосунку: «створити завдання», «знайти завдання», «позначити як виконане» конкретних баз даних, конкретного CLI/HTTP, глобального стану
adapters (адаптери) конкретні реалізації: пам’ять, файл, база, CLI-обгортка, HTTP-обгортка бізнес-правил на кшталт «як має бути» (вони не тут)

Ключова думка: одні й ті самі сценарії (app/usecases) можна під’єднати і до CLI, і до HTTP, і до тестів, тому що сценарії спілкуються із зовнішнім світом через інтерфейси, а не через конкретні пакети реалізації. Сьогодні ми зосередимося на самій ідеї шарів і напрямах імпортів; механіку впровадження залежностей залишимо на мінімальному рівні, без зайвих ускладнень.

Напрямок залежностей: зовнішнє знає про внутрішнє

Зараз буде фраза, яку корисно тримати в голові як дорожній знак: залежності спрямовані від зовнішнього до внутрішнього. Тобто CLI/HTTP/сховища знають про app і domain, сценарії знають про domain, а domain взагалі ні про кого не знає — він як мудрий кіт: просто існує й дотримується своїх правил.

Чому це важливо? Бо інакше ви отримуєте спагеті-граф імпортів: один пакет імпортує другий, другий — третій, третій раптом імпортує перший, і компілятор каже вам суворе «ні». І компілятор має рацію: циклічні імпорти майже завжди означають, що ви переплутали відповідальність.

Зручно уявляти це так:

flowchart LR
    domain[domain: сутності та правила]
    app[app/usecases: сценарії]
    adapters[adapters: сховища/CLI/HTTP]

    adapters --> app
    app --> domain

Якщо у вас стрілка раптом пішла назад, наприклад domain імпортує adapters, — це не «ну буває», а сигнал: ви намагаєтеся засунути деталі інфраструктури туди, де мають бути тільки правила.

Імпорт як залежність на рівні компіляції

У Go імпорт — це не просто «дай мені функцію». Коли ви імпортуєте пакет, ви починаєте залежати від його експортованого API. І це не філософія, а прямий практичний контракт, який складно змінювати без наслідків.

Ми, звісно, не пишемо стандартну бібліотеку, але принцип той самий: що більше ви експортували і що більше пакетів це імпортують, то складніше змінювати код.

Звідси випливає важливий зв’язок із темою шарів: шари зменшують кількість «небезпечних» залежностей. Наприклад, якщо CLI-шар залежить від app, то зміна CLI не повинна змушувати переписувати домен. А якщо домен раптом почав залежати від CLI, вітаю, ви винайшли «бізнес-правила залежать від тексту підказки в терміналі». Це звучить кумедно, але в реальному коді такі речі з’являються непомітно.

3. import path і module path у власних пакетах

Зараз буде маленька термінологічна рамка — без команд і без глибокого занурення в Go Modules. Просто важливо зрозуміти, чому імпорт ваших пакетів виглядає «довгим».

У Go є рядок, який ви пишете в import "...". Це і є import path. У зовнішніх бібліотек він зазвичай схожий на URL: домен + шлях, наприклад "golang.org/x/oauth2". Такий стиль імпорту багато років використовується в екосистемі Go.

У ваших власних пакетів import path зазвичай починається з module path — того рядка, який указано в go.mod після слова module. Ми не виконуємо команди й не обговорюємо керування версіями, але термін зафіксуємо: module path — це префікс, від якого будуються імпорти ваших пакетів.

Уявімо, що module path проєкту такий:

example.com/tasker

Тоді імпорти ваших пакетів виглядатимуть приблизно так:

import "example.com/tasker/domain"
import "example.com/tasker/app"
import "example.com/tasker/adapters/memstore"

І це нормально. Так, довго. Зате однозначно: у великому проєкті не буде «двох різних пакетів domain із різних місць».

4. Приклад: мінізастосунок Tasker

Структура каталогів

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

Ось «скелет» проєкту — як ідея:

tasker/
  go.mod                  (module example.com/tasker)
  main.go
  domain/
    task.go
  app/
    add_task.go
  adapters/
    memstore/
      store.go

Важливий момент: така структура не є догмою. Це просто зручний спосіб зробити так, щоб за шляхом було видно роль. Ми могли б назвати app як usecases, могли б назвати domain як model, могли б назвати adapters як infra. Сьогодні фіксуємо ідею, а не єдине правильне найменування.

Шар domain: сутність Task і правило «заголовок не порожній»

Доменні типи — це те, що ви хочете мати навіть у світі без термінала, сервера, файлів і бази даних. Якщо завтра ваш CLI зникне, домен усе одно має описувати вашу предметну область. Тому в домені ми тримаємо структуру Task і мінімальне правило: заголовок не повинен бути порожнім.

Файл domain/task.go:

package domain

import "errors"

var ErrEmptyTitle = errors.New("порожній заголовок")

type Task struct {
	ID    int
	Title string
	Done  bool
}

func NewTask(id int, title string) (Task, error) {
	if title == "" {
		return Task{}, ErrEmptyTitle
	}
	return Task{ID: id, Title: title}, nil
}

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

Шар app/usecases: сценарій додавання завдання та інтерфейс залежності

Сценарій — це те, що застосунок робить, але без деталей «як саме він зберігає дані» та «через який інтерфейс користувач взаємодіє із системою». Тут ми описуємо операцію додавання завдання. І нам потрібна залежність: місце, де зберігати. Але ми не хочемо тягнути конкретну реалізацію, наприклад у пам’яті або у файлі, прямо в сценарій.

Тому сценарій оголошує інтерфейс — мінімальний контракт, який йому потрібен.

Файл app/add_task.go:

package app

import (
	"context"
	"fmt"

	"example.com/tasker/domain"
)

type TaskStore interface {
	NextID(ctx context.Context) (int, error)
	Save(ctx context.Context, t domain.Task) error
}

func AddTask(ctx context.Context, store TaskStore, title string) (domain.Task, error) {
	id, err := store.NextID(ctx)
	if err != nil {
		return domain.Task{}, fmt.Errorf("next id: %w", err)
	}

	t, err := domain.NewTask(id, title)
	if err != nil {
		return domain.Task{}, err
	}

	if err := store.Save(ctx, t); err != nil {
		return domain.Task{}, fmt.Errorf("save task: %w", err)
	}
	return t, nil
}

Тут ми робимо важливу архітектурну річ: app імпортує domain, але не імпортує жоден адаптер. Це дозволяє потім під’єднати будь-яку реалізацію TaskStore. І так, context.Context поки що виглядає як зайвий параметр, але це інвестиція: сценарій починає виглядати як нормальна операція застосунку.

Шар adapters: сховище в пам’яті під потрібний інтерфейс

Адаптер — це місце, де з’являються деталі. Наприклад, зберігання в пам’яті. Воно може бути неідеальним і навіть не вічним: після перезапуску все зникає. Зате воно просте. І головне: це не частина домену і не частина сценарію. Це просто один зі способів задовольнити інтерфейс.

Файл adapters/memstore/store.go:

package memstore

import (
	"context"

	"example.com/tasker/domain"
)

type Store struct {
	next int
	data map[int]domain.Task
}

func New() *Store {
	return &Store{next: 1, data: make(map[int]domain.Task)}
}

Продовжимо тими методами, які потрібні сценарію:

func (s *Store) NextID(ctx context.Context) (int, error) {
	id := s.next
	s.next++
	return id, nil
}

func (s *Store) Save(ctx context.Context, t domain.Task) error {
	s.data[t.ID] = t
	return nil
}

Зверніть увагу: пакет memstore імпортує domain, бо зберігає доменну сутність. Але він не імпортує app. Це теж частина напрямку залежностей: адаптер реалізує контракт, але контракт лежить вище — з боку споживача. Сьогодні достатньо зафіксувати це як ідею, без тонких суперечок про те, де має лежати інтерфейс.

main як місце, де шари зустрічаються

Коли ви вперше бачите шари, виникає запитання: а де все це зібрати? Відповідь приземлена: у main. Точка входу застосунку — це місце, де допустимо поєднувати деталі. Це не архітектурний гріх, а робота main: вибрати реалізації та запустити сценарії.

Файл main.go:

package main

import (
	"context"
	"fmt"

	"example.com/tasker/adapters/memstore"
	"example.com/tasker/app"
)

func main() {
	store := memstore.New()

	t, err := app.AddTask(context.Background(), store, "читати книгу про Go")
	if err != nil {
		fmt.Println("помилка:", err)
		return
	}

	fmt.Printf("додано: #%d %s\n", t.ID, t.Title) // додано: #1 читати книгу про Go
}

Тут ми побачили те, заради чого будували шари: main імпортує адаптер і app, а app взагалі не знає, що таке memstore. Якщо пізніше ви захочете зберігати завдання у файлі чи в базі, логіку сценарію не доведеться переписувати.

Як читати проєкт через імпорти

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

Корисна самоперевірка для нашого прикладу:

flowchart TD
    main[main: точка входу] --> appPkg[app: сценарії]
    main --> mem[memstore: сховище]
    appPkg --> domainPkg[domain: модель]
    mem --> domainPkg

Якщо ви побачили стрілку domain -> app або domain -> memstore, значить домен «протік» інфраструктурою. Якщо побачили app -> memstore, значить сценарій став залежати від конкретики і втратив сенс як переносна логіка. Якщо побачили цикл — компілятор вас зупинить, і це буде найкращий «подарунок» за день.

5. Типові помилки під час упровадження шарів

Помилка № 1: робити «шари заради шарів», не маючи реальної потреби.
Іноді люди начитаються про архітектуру й починають ділити на десять пакетів навіть програму на 50 рядків. У підсумку ви не отримуєте гнучкості, зате отримуєте складну навігацію. Шари корисні, коли в застосунку є щонайменше два різні зовнішні світи, наприклад сьогодні CLI, а завтра HTTP, або коли бізнес-логіка починає повторюватися й її хочеться тримати в одному місці.

Помилка № 2: домен починає робити введення/виведення.
Найчастіший спосіб зламати ідею шарів — додати в domain щось на кшталт fmt.Println("створили завдання") або читання з файла «щоб було зручно». Домен має бути нудним: структури, методи, перевірка правил, помилки. Щойно в домені з’являється I/O, він стає залежним від середовища та перестає бути переносним ядром.

Помилка № 3: сценарій імпортує конкретну реалізацію адаптера.
Виглядає невинно: «ну мені ж треба зберегти завдання, зараз просто імпортую memstore і все». Але саме тут сценарій перестає бути сценарієм і стає застосунком, назавжди приклеєним до пам’яті. Правильний шлях — залежати від інтерфейсу та отримувати реалізацію ззовні, навіть якщо це поки що виглядає трохи багатослівно.

Помилка № 4: плутанина між package name і import path.
Початківці іноді думають, що пакет обов’язково має називатися так само, як остання частина import path. Зазвичай так і роблять, але це не одне й те саме. Import path — це адреса, за якою пакет розташований, а ім’я пакета — це те, як ви до нього звертаєтеся в коді. Через це іноді з’являються дивні імпорти й аліаси. Не лякайтеся: це нормальна частина дорослішання проєкту.

Помилка № 5: спроба одразу розв’язати всі архітектурні питання.
Хочеться одразу: і домен ідеальний, і сценарії красиві, і адаптери універсальні, і тести, і DI‑контейнер, і щоб компілятор аплодував стоячи. На практиці корисніше рухатися маленькими кроками: спочатку відокремити домен, потім сценарії, потім адаптери. Шари — це не ціль, а спосіб тримати складність на повідку, доки вона не з’їла ваш мозок.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ