1. Дві відповідальності: «як передаємо» vs «що це означає»
Якщо ви тільки починаєте, дуже легко потрапити в пастку думки: «Дані є дані». Але в реальному застосунку дані живуть у двох світах: у внутрішньому — тобто у вашому коді й правилах предметної області — та у зовнішньому, де є JSON, клієнти, інтеграції й мережа. Ці світи схожі, але не однакові. Як мінімум тому, що зовнішній світ любить рядки та null, а внутрішній — інваріанти й зрозумілі типи.
Зафіксуймо терміни:
Domain model (доменна модель) — структури й типи, які описують сенс предметної області та її правила. Наприклад, «задача має мати непорожній заголовок», «ID задачі — додатне число», «задачу можна виконати».
DTO (Data Transfer Object) — структури для передавання даних через межу застосунку. У нашому HTTP‑модулі межа — це JSON‑контракт API: які поля і як називаються, які з них обов’язкові, а які — опціональні.
Корисна схема, яку варто тримати в голові:
flowchart LR
A["JSON-запит"] --> B["DTO (запит)"]
B --> C["Домен (команда/модель)"]
C --> D["Доменний результат"]
D --> E["DTO (відповідь)"]
E --> F["JSON-відповідь"]
Звучить нудно, але саме такий «нудний» дизайн раптово робить ваш проєкт живучим.
2. Чому «одна структура на все» спокуслива і чим це закінчується
Будьмо відверті: ідея «одна структура на все» здається геніальною. Пишемо type Task struct { ... json:"..." }, робимо json.Marshal/json.Unmarshal — і життя прекрасне. А ще можна зберігати цю ж структуру в пам’яті, віддавати клієнту й показувати користувачеві… універсальний швейцарський ніж!
Проблема в тому, що це швейцарський ніж без леза, зате з вісьмома штопорами.
Змішування домену та DTO зазвичай призводить до трьох видів болю.
Перший біль — домен починає залежати від зовнішнього контракту. Ви раптово не можете перейменувати поле в Go, бо «клієнти очікують стару назву в JSON». Або не можете змінити тип, бо «в JSON приходять рядки».
Другий біль — доменні правила розмиваються. З’являються поля на кшталт Title string без перевірки, ID int з нулем, Done bool без зрозумілого переходу стану — і бізнес-логіка розповзається по коду.
Третій біль — ви не можете еволюціонувати API. Треба додати нове поле у відповідь, але воно не має стати частиною домену. Або навпаки: у домені з’явилося нове внутрішнє поле, але його не можна віддавати назовні. Коли все в одній структурі, починаються танці з json:"-", вказівниками, omitempty і «давайте просто не заповнювати».
І от у цей момент структура перетворюється на «валізу без ручки»: і нести незручно, і викинути страшно, бо на ній зав’язана половина коду.
3. Доменна модель: правила, інваріанти й «нормальні типи»
Доменна модель має бути місцем, де живе сенс. Тут ми хочемо, щоб стан був валідним, а зміни стану — контрольованими. І дуже бажано, щоб домен не знав про JSON‑теги, HTTP і «яким словом поле називається у клієнта». Домену байдуже, як його запакують у мережу. Домен — про те, що це таке.
Візьмімо наш навчальний домен tasks. Почнемо з простого: TaskID, Task і мінімальних правил.
package main
import (
"errors"
"strings"
)
type TaskID int
type Task struct {
ID TaskID
Title string
Done bool
}
func NewTask(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, errors.New("title не може бути порожнім")
}
return Task{Title: title, Done: false}, nil
}
Зверніть увагу на важливу дрібницю: домен не приймає «як є», а очищує й перевіряє. Це й є інваріанти: ми створюємо лише валідні задачі.
Тепер додамо «доменну дію»: позначення задачі як виконаної. Навіть якщо це всього лише Done = true, корисно виразити це методом, бо згодом можуть з’явитися правила (наприклад, не можна виконати задачу без Title, не можна «виконати знову», треба записувати час тощо).
package main
import "errors"
func (t *Task) MarkDone() error {
if t.Done {
return errors.New("задачу вже виконано")
}
t.Done = true
return nil
}
Так, зараз це виглядає як надмірний формалізм для одного bool. Але саме так ви захищаєтеся від майбутніх вимог, не перетворюючи проєкт на «if‑пасту».
4. DTO: зовнішній контракт і «правила вулиці»
DTO — це як перекладач. Він говорить мовою зовнішнього світу: JSON, рядки, omitempty, інколи null, інколи «поле може бути відсутнім». Він не зобов’язаний бути красивим із погляду домену — він зобов’язаний бути стабільним і зрозумілим клієнту.
Уявімо мінімальний API‑контракт (на рівні структур), який ми вже почали фіксувати раніше:
- створення задачі: клієнт надсилає { "title": "купити молоко" }
- відповідь задачі: { "id": 1, "title": "купити молоко", "done": false }
Зробімо DTO:
package main
type CreateTaskRequest struct {
Title string `json:"title"`
}
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
Важлива думка: DTO підпорядковується контракту, а не доменній красі. Наприклад, домен може захотіти TaskID як окремий тип, а DTO майже напевно віддаватиме id як звичайне число, бо JSON не знає про ваш type TaskID int.
Тепер додамо приклад «опційності». Часто в API є поля, які можуть бути відсутні. У домені це може бути суворо заборонено, а в DTO — дозволено. Наприклад, у відповіді ми хочемо віддавати description, але це поле опційне.
package main
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
Description *string `json:"description,omitempty"`
}
Чому *string, а не string? Бо string має zero value "", і ви не відрізните «порожній рядок було передано» від «поля не було». А DTO часто має розрізняти такі речі. Домен, навпаки, частіше намагається жити без «трійкової логіки» («є/немає/порожньо»), інакше мозок розробника починає диміти.
5. Явний мапінг і приклад
Коли ви розділили домен і DTO, з’являється питання: «Окей, а як конвертувати?»
Відповідь у стилі Go — явний мапінг.
Не через рефлексію, не через «універсальний мапер», не через 17 анотацій. Просто окремі функції. Вони короткі, читабельні й чудово знаходяться по проєкту.
Почнімо з домен → DTO (те, що віддаємо клієнту):
package main
func ToTaskDTO(t Task) TaskDTO {
return TaskDTO{
ID: int(t.ID),
Title: t.Title,
Done: t.Done,
}
}
Тепер DTO запиту → доменна команда. Тут важливий момент: запит на створення — це ще не Task. У задачі може бути ID, який призначить сховище, а може бути ще купа внутрішніх полів. Тому корисно мати окремий тип «вхід у домен».
package main
import "strings"
type CreateTaskInput struct {
Title string
}
func ToCreateTaskInput(req CreateTaskRequest) CreateTaskInput {
return CreateTaskInput{
Title: strings.TrimSpace(req.Title),
}
}
А вже домен приймає CreateTaskInput і вирішує, що з ним робити. Наприклад:
package main
func CreateTask(input CreateTaskInput) (Task, error) {
return NewTask(input.Title)
}
Так, це майже «проксі» до NewTask. Але зате у вас чітко розділені ролі: DTO — транспорт, CreateTaskInput — межа домену, Task — доменна сутність.
Міні‑приклад: створення задачі без сервера
Ми не запускаємо HTTP‑сервер прямо зараз, але можемо зібрати маленьку «симуляцію» межі: ніби JSON уже розпарсили в DTO, а потім прогнали через домен і отримали DTO відповіді.
package main
import (
"fmt"
)
func simulateCreate(req CreateTaskRequest) (TaskDTO, error) {
input := ToCreateTaskInput(req)
task, err := CreateTask(input)
if err != nil {
return TaskDTO{}, err
}
// Уявімо, що сховище призначило ID=1
task.ID = TaskID(1)
return ToTaskDTO(task), nil
}
func main() {
dto, err := simulateCreate(CreateTaskRequest{Title: " купити молоко "})
if err != nil {
fmt.Println("помилка:", err)
return
}
fmt.Printf("%+v\n", dto) // {ID:1 Title:купити молоко Done:false}
}
Що важливо в цьому прикладі: ми легко тестуємо домен і мапінг без HTTP. А коли з’явиться справжній транспорт (HTTP), він просто буде подавати CreateTaskRequest і отримувати TaskDTO.
6. Валідація: як не посварити шари
Валідація — це місце, де починаються філософські суперечки. Хтось каже: «Валідую на вході в HTTP», хтось — «Валідую лише в домені». Реальність зазвичай така: перевіряти можна в двох місцях, але з різних причин.
Транспортний шар (DTO) перевіряє те, що стосується формату й контракту: «поле є», «тип правильний», «рядок не надто довгий» — інколи — «JSON нормально розпарсився». Домен перевіряє сенс: «заголовок не порожній», «не можна виконати вже виконану задачу», «не можна видалити те, чого не існує» — це вже ближче до доменних помилок.
Покажемо простий доменний тип помилки валідації, який зручно використовувати далі на межі — наприклад, щоб заповнити fields у структурі відповіді з помилкою.
package main
type ValidationError struct {
Fields map[string]string
}
func (e ValidationError) Error() string {
// Коротко й безпечно: текст помилки — не контракт, контракт — структура.
return "валідація не пройдена"
}
Тепер домен може повертати таку помилку:
package main
import "strings"
func NewTask(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, ValidationError{
Fields: map[string]string{"title": "must not be empty"},
}
}
return Task{Title: title, Done: false}, nil
}
Чому це корисно? Бо на межі ви зможете перетворити це на зрозумілу відповідь клієнту. І головне: вам не потрібно парсити текст помилки, а це вже окремий вид покарання. Помилки в Go — це значення, і вони цілком можуть нести контекст. Ця ідея — помилки як значення плюс помилки-типи — дуже характерна для Go і підтримується стандартними практиками.
7. Коли розділяти обов’язково, а коли можна спростити
У цьому місці в початківців часто виникає страх: «Якщо DTO і домен різні — це ж більше коду! Значить, я все роблю занадто складно!»
Так, коду більше. Але він з’являється там, де виникає реальна складність: на межі. А якщо межі майже немає — можна спростити.
Нижче — не список «заповідей», а радше відчуття, яке приходить із практикою.
Якщо у вас маленька програма «прочитати JSON → вивести JSON», без правил, без сховища, без розвитку контракту, то DTO і домен часто збігаються. Там домену майже немає: у вас просто дані, і структура «на все» не вб’є проєкт, бо проєкту нічого вбивати.
Але щойно з’являється хоча б одна з речей: правила предметної області, кілька різних способів подання даних, кілька клієнтів, версії API, внутрішні поля, які не можна віддавати назовні, або просто бажання писати тестований код без HTTP — розділення починає окуповуватися дуже швидко.
Щоб не залишати це «на відчуттях», ось коротка табличка‑шпаргалка:
| Ситуація | Можна однією структурою? | Чому |
|---|---|---|
| Швидкий прототип, одна дія, немає сховища | Іноді так | Майже немає доменних правил, контракт не живе довго |
| Є правила («title не порожній», «done не можна відкотити») | Краще розділяти | Домену потрібно захищати інваріанти незалежно від JSON |
| В API з’являються опціональні поля (omitempty, null) | Майже завжди розділяти | DTO починає жити «в трійковій логіці», а домену це заважає |
| Є внутрішні поля (наприклад, InternalNote, аудит, тех. прапорці) | Обов’язково розділяти | Витоки внутрішніх даних назовні — класичний баг |
| Хочемо версіонувати API (v1, v2) | Обов’язково розділяти | Домен спільний, а DTO різних версій — різні |
8. Типові помилки під час розділення DTO і домену
Помилка №1: доменна модель із json‑тегами «про всяк випадок».
Це починається невинно: «ну мені ж зручно». А потім раптово домен не можна використати ніде, крім HTTP, бо він увесь просочений транспортними рішеннями. У підсумку будь-яка зміна зовнішнього контракту починає ламати внутрішні структури й тести, які взагалі-то про бізнес-логіку, а не про JSON.
Помилка №2: DTO починає «думати» і тримати правила предметної області.
Іноді розробник пише методи на CreateTaskRequest на кшталт func (r CreateTaskRequest) ValidateBusinessRules() error. Це виглядає логічно, але ламає межу: бізнес-правила мають жити в домені, інакше вони розмазуються по різних входах (HTTP, CLI, імпорт із файлу) і неминуче розходяться.
Помилка №3: конвертери роблять «тихі виправлення» даних без явного рішення.
Наприклад, у мапінгу ви берете Title, обрізаєте пробіли, обмежуєте довжину до 128, замінюєте таби на пробіли — і все це мовчки. Іноді нормалізувати введення корисно, але важливо не перетворювати мапінг на «стиралку сенсу». Якщо дані виправляються, це має бути усвідомлено і бажано в одному місці — часто в домені, — інакше ви потім не зрозумієте, чому «користувач надіслав одне, а збереглося інше».
Помилка №4: спроба зробити «універсальний мапер» через reflection занадто рано.
На папері це економить рядки, на практиці — краде розуміння. У новачків це особливо болісно: помилки мапінгу стають неочевидними, IDE гірше допомагає, а налагодження перетворюється на археологію. Явні функції на 5–8 рядків майже завжди легше читати й підтримувати.
Помилка №5: доменна помилка перетворюється на «текстовий протокол».
Коли нагорі починають робити if strings.Contains(err.Error(), "not found") { ... }, ви фактично будуєте контракт на тексті, який узагалі не зобов’язаний бути стабільним. У Go прийнято розпізнавати класи помилок за значеннями й типами (sentinel/typed errors, errors.Is/As).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ