JavaRush /Курси /Go SELF /DTO і домен — коли зберігати «як є»

DTO і домен — коли зберігати «як є»

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

1. Чому взагалі виникає тема DTO і домену

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

Але щойно застосунок стає хоча б трохи складнішим, з’являється неприємне відчуття: ви змінюєте формат JSON, наприклад перейменовуєте поле created_atcreatedAt, і раптом ламається логіка у 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 і домен в одному застосунку

Щоб не піти в абстракції, продовжимо наш навчальний застосунок про задачі — умовний «мінітрекер задач». Ми хочемо вміти:

  1. прочитати задачу з JSON,
  2. перевірити, що дані осмислені,
  3. працювати із задачею всередині програми,
  4. видати задачу назад у 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,
	}
}

Чому це добре:

  1. Нормалізація (TrimSpace) зосереджена в одному місці.
  2. Доменна модель не знає про JSON.
  3. Ми можемо змінювати зовнішній формат, майже не чіпаючи доменну логіку.

Мапінг: 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» або нульовий час раптом потрапила у звіт. Якщо вхідні дані неправильні — краще повернути помилку на межі, ніж тихо породити дивний стан усередині застосунку.

1
Опитування
JSON (база), рівень 45, лекція 4
Недоступний
JSON (база)
JSON (база)
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ