1. Чому взагалі виникає тема DTO і домену
Якщо ви тільки починаєте, усе здається логічним: «Ну ж є struct. Я в неї і JSON розпарсю, і всередині програми з нею попрацюю, і назад у JSON запишу». І так, перші кілька днів так робити можна і навіть треба — мозок і так зайнятий синтаксисом Go, а не архітектурою.
Але щойно застосунок стає хоча б трохи складнішим, з’являється неприємне відчуття: ви змінюєте формат JSON, наприклад перейменовуєте поле created_at → createdAt, і раптом ламається логіка у 15 місцях. Або ви хочете в домені зберігати час як time.Time, а у зовнішньому контракті він приходить «датою без часу» (рядком 2026-01-16) — і починається історія «усе рядками всюди», бо «так простіше».
Отже, DTO vs domain — це про межі відповідальності. DTO відповідає за те, як це виглядає назовні, а доменна модель — за те, що це означає всередині.
Терміни: DTO, доменна модель і мапінг
Щоб не виникало відчуття, ніби вам продають корпоративну складність заради самої складності, давайте назвемо речі простими словами.
DTO (Data Transfer Object) — це структура даних, яка існує заради обміну: JSON, файл, мережа, API, імпорт/експорт. Вона часто містить теги json:"...", часто допускає omitempty, іноді зберігає поля в «зручному для формату» вигляді: рядками, *time.Time, необов’язковими полями.
Domain model (доменна модель) — це структура даних, яка існує заради логіки застосунку. Вона прагне бути строгою й зрозумілою: правильні типи (time.Time, int, власні типи), інваріанти («у задачі має бути заголовок»), передбачувані значення.
Мапінг — це явне перетворення DTO ↔ домен. Це не зайвий крок, а контрольна точка, де ви можете нормалізувати рядки, розпарсити час, перевірити обов’язковість полів, сховати внутрішні поля, додати обчислювані значення.
У підсумку маємо цілком людяний конвеєр:
flowchart TD
A["байти JSON"] --> B["DTO (як надійшло)"]
B --> C["Validate() (перевірка змісту)"]
C --> D["Domain (як зручно)"]
D --> E["Логіка застосунку"]
E --> F["DTO (як віддати назовні)"]
F --> G["байти JSON"]
2. Одна структура чи DTO+domain: як обрати
Коли нормально використовувати одну структуру
Тут важлива річ: розділення DTO і домену — не релігія. Якщо ви пишете невелику утиліту, навчальний проєкт на 1–2 файли або JSON-формат повністю під вашим контролем і точно збігається з тим, як ви хочете зберігати дані всередині, то одна структура може бути чудовим рішенням.
Типовий приклад, коли вистачає однієї структури: ви зберігаєте задачі в JSON-файлі, формат фіксований, поля прості (id, title, done), і ви готові жити з тим, що структура містить JSON-теги. Це нормально, особливо поки ви навчаєтеся.
Але корисно розуміти, чому в реальному коді такий підхід часто ламається: у структури раптом з’являються поля «для JSON», «для UI», «для внутрішньої логіки», «для сумісності», і вона перетворюється на «швейцарський ніж», яким можна все, але різати хліб уже страшно.
Коли DTO і домен краще розділяти
Коли проєкт починає рости, розділення стає не красою, а способом не втратити керування. Нижче — ситуації, де розділення зазвичай виправдане. Оформімо це таблицею, щоб очам було легше.
| Симптом | Що відбувається, якщо «одна структура на все» | Чому DTO+domain допомагає |
|---|---|---|
| Зовнішні імена полів не такі, як хочеться в коді | Ви читаєте Task.CreatedAt, а в JSON це created_at, і теги розповзаються всюди | DTO тримає теги, домен — чисті імена |
| У JSON типи незручні | Дати рядками, числа як рядки, null у неочікуваних місцях | DTO зберігає «як прийшло», домен — «як працювати» |
| Є обов’язкові правила (інваріанти) | json.Unmarshal заповнить структуру, але не гарантує зміст («порожній title») | Доменні конструктори та валідація гарантують правила |
| Треба приховувати внутрішні поля | Ви випадково віддаєте SecretToken назовні, бо забули json:"-" | DTO для відповіді просто не містить цього поля |
| Різні подання для різних операцій | Для створення потрібно одне, для відповіді — інше, для зберігання — третє | DTO робляться під конкретні операції: CreateTaskDTO, TaskDTO |
| Формат може змінитися | Будь-яка зміна JSON = правки в бізнес-логіці | Змінюється DTO і мапінг, домен майже не чіпаєте |
І ось тут народжується головний принцип: доменні типи мають залежати від змісту, а не від серіалізації.
3. Приклад: задачі — DTO і домен в одному застосунку
Щоб не піти в абстракції, продовжимо наш навчальний застосунок про задачі — умовний «мінітрекер задач». Ми хочемо вміти:
- прочитати задачу з JSON,
- перевірити, що дані осмислені,
- працювати із задачею всередині програми,
- видати задачу назад у JSON.
Доменна модель: як зручно логіці
Доменні структури краще робити без тегів. Нехай вони просто виражають зміст.
package domain
import "time"
type Task struct {
ID int
Title string
Done bool
CreatedAt time.Time
DoneAt *time.Time
}
Тут уже видно правило: DoneAt — необов’язковий, тому *time.Time. Якщо задача не завершена — DoneAt == nil. Це не примха Go, а доволі чесне відображення реальності.
Тепер додамо метод, який робить задачу виконаною так, щоб інваріанти не ламалися.
package domain
import "time"
func (t *Task) MarkDone(now time.Time) {
t.Done = true
t.DoneAt = &now
}
Зверніть увагу на стиль: доменна логіка живе поруч із доменною структурою. І дуже приємно, що їй байдуже, чи буде задача потім серіалізована в JSON, YAML або висічена на кам’яній табличці.
DTO: як виглядає JSON-повідомлення
Тепер уявімо, що зовнішній контракт у нас такий:
{
"id": 10,
"title": "Купити молоко",
"done": false,
"created_at": "2026-01-16T10:00:00Z",
"done_at": null
}
Ми можемо зробити DTO, яке відповідає цьому формату. Тут time.Time цілком допустимий, бо encoding/json уміє кодувати й декодувати time.Time як рядок (зазвичай у RFC3339).
package dto
import "time"
type TaskDTO struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
DoneAt *time.Time `json:"done_at,omitempty"`
}
Тут важливий нюанс: omitempty означає «не включати поле під час кодування», якщо воно порожнє. Якщо DoneAt == nil, то під час json.Marshal поле done_at зникне з JSON.
Чому часто роблять різні DTO для «створення» і для «відповіді»
На практиці вхідні дані — «створити задачу» — і вихідні — «ось задача» — рідко збігаються. Якщо вам надсилають JSON для створення, там зазвичай немає id і created_at: їх генерує застосунок.
Тому для входу робимо окремий DTO:
package dto
type CreateTaskDTO struct {
Title string `json:"title"`
}
Виглядає нудно, але нудьга — це добре: нудний код простіше підтримувати, ніж «універсальну мегаструктуру».
Валідація DTO: перевіряємо зміст до мапінгу
У попередній лекції ми вже розділяли decode і validate. Тут просто застосуємо це як звичку. DTO теж може мати валідацію.
package dto
import (
"errors"
"strings"
)
func (d CreateTaskDTO) Validate() error {
if strings.TrimSpace(d.Title) == "" {
return errors.New("поле title є обов’язковим")
}
return nil
}
Тут немає нічого «архітектурного». Це звичайна людська перевірка: заголовок не повинен бути порожнім або складатися з пробілів (пробіли теж дуже хочуть бути задачею, але не сьогодні).
Мапінг: DTO → domain
Тепер зробимо явне перетворення. Для створення задачі домену потрібні: Title, CreatedAt, а ID поки «0» (або його виставлять пізніше). Припустімо, у нашому застосунку ID видає шар зберігання, а тут ми лише створюємо об’єкт.
package dto
import (
"strings"
"time"
"example.com/taskapp/domain"
)
func (d CreateTaskDTO) ToDomain(now time.Time) domain.Task {
return domain.Task{
Title: strings.TrimSpace(d.Title),
CreatedAt: now,
}
}
Чому це добре:
- Нормалізація (TrimSpace) зосереджена в одному місці.
- Доменна модель не знає про JSON.
- Ми можемо змінювати зовнішній формат, майже не чіпаючи доменну логіку.
Мапінг: domain → DTO для відповіді
Коли нам потрібно вивести задачу в JSON, краще явно зібрати DTO.
package dto
import "example.com/taskapp/domain"
func FromDomainTask(t domain.Task) TaskDTO {
return TaskDTO{
ID: t.ID,
Title: t.Title,
Done: t.Done,
CreatedAt: t.CreatedAt,
DoneAt: t.DoneAt,
}
}
Знову ж таки: нудно, прозоро, читабельно. Ідеально.
Зводимо все в потік: Unmarshal → Validate → ToDomain → Marshal
Зберемо мініприклад у main, щоб стало зрозуміло, як це живе в реальному коді.
package main
import (
"encoding/json"
"fmt"
"time"
"example.com/taskapp/dto"
)
func main() {
data := []byte(`{"title":" Купити молоко "}`)
var in dto.CreateTaskDTO
if err := json.Unmarshal(data, &in); err != nil {
fmt.Println("помилка JSON:", err)
return
}
if err := in.Validate(); err != nil {
fmt.Println("некоректні дані:", err)
return
}
task := in.ToDomain(time.Now().UTC())
out := dto.FromDomainTask(task)
b, _ := json.MarshalIndent(out, "", " ")
fmt.Println(string(b))
}
Тут важливо помітити одну тонкість: json.Unmarshal може повертати різні види помилок, і іноді корисно розрізняти «JSON битий» чи «дані погані». Наприклад, пакет encoding/json повертає помилку типу *json.SyntaxError у разі синтаксичної проблеми, і її можна розпізнати через errors.As.
4. Межі: теги, зберігання «як є» та структура проєкту
Де тримати теги і як не перетворити проєкт на «теги керують світом»
Дуже типова проблема новачка: «Я додам теги прямо в domain.Task, адже так простіше». А потім ви раптом виявляєте, що доменна модель змушена мати поля й назви заради JSON, хоча логіці вони не потрібні.
Практичне правило, яке зазвичай приводить до спокійного життя: теги живуть у DTO, а доменні структури живуть без тегів. Тоді домен можна використовувати в різних контекстах: не лише в JSON, а й, наприклад, у тестах, у пам’яті, в інших форматах.
Якщо ваш проєкт уже знає пакети, а ви їх проходили раніше, то органічно виглядає структура на кшталт:
taskapp/
domain/
task.go
dto/
task_dto.go
main.go
І це вже схоже на дорослу організацію, але без зайвої складності.
Коли «зберігати як є» усе-таки корисно
Іноді DTO «як є» — це і є ваша модель зберігання. Наприклад, ви пишете імпорт/експорт і хочете зберігати у файлі рівно те, що віддаєте назовні, без додаткових обчислень і без інваріантів або з мінімальними.
Тоді DTO можна використовувати як формат зберігання, а домен — як формат роботи. Ви читаєте DTO, валідируєте, мапите в домен, працюєте, а потім знову мапите в DTO для збереження.
Сенс у тому, що «як є» корисно зберігати на межі, де важливо не втратити контракт. Але всередині застосунку майже завжди вигідніше мати типи, які відображають зміст і захищають від небезпечних станів.
5. Типові помилки
Помилка № 1: одна структура на все, і вона росте як снігова куля.
Спочатку це здається зручним: один Task, і в нього ж теги, і omitempty, і поля «на майбутнє». Але дуже швидко структура стає сміттєвим відром: ви боїтеся видалити поле, боїтеся перейменувати, боїтеся змінювати типи, бо «а раптом JSON зламається». Розділення DTO і домену повертає контроль: DTO змінюється під контракт, домен — під логіку.
Помилка № 2: доменна модель зберігає «форматні» типи (час рядком, числа рядком) лише тому, що так прийшло в JSON.
Таке рішення зазвичай виглядає як «я потім розберуся», але потім не настає. У результаті ви порівнюєте дати як рядки, сортуєте числа як текст і ловите баги рівня «100 менше за 9». Правильніше розпарсити або перетворити дані на вході й усередині зберігати нормальні типи (time.Time, int).
Помилка № 3: omitempty використовують як валідацію.
omitempty — це про кодування (Marshal), а не про зміст. Поле може зникати з JSON, але це не робить його «дозволено порожнім» із точки зору логіки. Якщо title обов’язковий — це перевіряє Validate, а не omitempty.
Помилка № 4: мапінг розмазується по коду.
Сьогодні ви розпарсили CreatedAt в одному місці, завтра в іншому, післязавтра забули TrimSpace, і задачі починають жити своїм життям. Мапінг має бути явною точкою контролю: ToDomain() / FromDomain() або окремі функції-конвертери, але в одному зрозумілому місці.
Помилка № 5: намагаються «підправити дані мовчки» і втрачають діагностику.
Наприклад, прийшов кривий формат дати, а ви вирішили: «Ну хай буде time.Time{}». Потім ви шукаєте, чому дата «1970-01-01» або нульовий час раптом потрапила у звіт. Якщо вхідні дані неправильні — краще повернути помилку на межі, ніж тихо породити дивний стан усередині застосунку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ