JavaRush /Courses /Go SELF /DTO vs domain — when to store “as is”

DTO vs domain — when to store “as is”

Go SELF
Level 45, Lesson 4
Available

1. Why the DTO vs domain topic comes up at all

If you’re just starting out, it seems logical: “Well, there’s a struct. I’ll parse JSON into it, work with it inside the program, and write it back to JSON.” And yes, for the first couple of days you can do that, and you probably should — your brain is already busy with Go syntax, not architecture.

But as soon as the application becomes even a little more complex, an unpleasant feeling appears: you change the JSON format (for example, you renamed the field created_at → createdAt), and suddenly your logic breaks in 15 places. Or you want to store time in the domain as time.Time, but in the external contract it comes as a “date without time” (a string 2026-01-16) — and then it becomes “everything is strings everywhere” because “it’s simpler”.

So, DTO vs domain is about responsibility boundaries. DTO is responsible for “what it looks like on the outside,” and the domain model is responsible for “what it means on the inside.”

Terms: DTO, domain model, and mapping

So it doesn’t feel like “we’re being sold enterprise for enterprise’s sake,” let’s call things by simple names.

DTO (Data Transfer Object) is a data structure that exists for data exchange: JSON, files, networks, APIs, import/export. It often contains json:"..." tags, often allows omitempty, and sometimes stores fields in a “format-convenient” representation (strings, *time.Time, optional fields).

Domain model is a data structure that exists for application logic. It aims to be strict and clear: correct types (time.Time, int, custom types), invariants (“a task must have a title”), predictable values.

Mapping is an explicit conversion DTO ↔ domain. It’s not an “extra step,” but a checkpoint where you can: normalize strings, parse time, check required fields, hide internal fields, add computed values.

In the end you get a very human pipeline:

flowchart TD
    A["JSON bytes"] --> B["DTO (as received)"]
    B --> C["Validate() (semantic check)"]
    C --> D["Domain (convenient for work)"]
    D --> E["Application logic"]
    E --> F["DTO (as exposed externally)"]
    F --> G["JSON bytes"]

2. One structure or DTO+domain: how to choose

When it’s fine to use a single structure

Here’s an important part: separating DTOs and the domain is not a religion. If you’re writing a small utility, a learning project with 1–2 files, or the JSON format is fully under your control and exactly matches how you want to store data internally, then a single structure can be a great solution.

A typical “one structure is enough” example: you store tasks in a JSON file, the format is fixed, the fields are simple (id, title, done), and you’re okay with the structure containing JSON tags. That’s normal, especially while you’re learning.

But it’s useful to understand why in real code this approach often “breaks”: the structure suddenly gets fields “for JSON,” “for UI,” “for internal logic,” “for compatibility,” and it turns into a “Swiss Army knife” that can do everything — but cutting bread with it is already scary.

When it’s better to separate DTOs and the domain

When the project starts to grow, separation becomes not “beauty,” but a way not to lose control. Below are situations where separation is usually justified; let’s put it in a table to make it easier on the eyes.

Symptom What happens if you use “one structure for everything” Why DTO+domain helps
External field names aren’t what you want in code You read Task.CreatedAt, but in JSON it’s created_at, and the tags spread everywhere The DTO holds the tags; the domain has clean names
JSON types are “inconvenient” Dates as strings, numbers as strings, null in unexpected places The DTO stores “as received,” the domain stores “as you work with it”
There are required rules (invariants) json.Unmarshal will fill the struct, but won’t guarantee meaning (“empty title”) Domain constructors/validation guarantee the rules
You need to hide internal fields You accidentally expose SecretToken externally because you forgot json:"-" The response DTO simply doesn’t include that field
Different representations for different operations For create you need one thing, for responses another, for storage a third DTOs are made for specific operations: CreateTaskDTO, TaskDTO
The format may change Any JSON change = changes in business logic You change the DTO and the mapping; the domain is barely touched

And this is where the main principle is born: domain types should depend on meaning, not on serialization.

4. Example: tasks — DTO and domain in one application

To avoid drifting into abstraction, let’s continue our learning app about tasks (a hypothetical “mini task tracker”). We want to be able to:

  1. read a task from JSON,
  2. check that the data is meaningful,
  3. work with the task inside the program,
  4. output the task back to JSON.

Domain model: convenient for the logic

Domain structs are best made without tags. Let them express meaning.

package domain

import "time"

type Task struct {
	ID        int
	Title     string
	Done      bool
	CreatedAt time.Time
	DoneAt    *time.Time
}

Here you can already see the rule: DoneAt is optional, so it’s *time.Time. If the task is not completed, DoneAt == nil. This is not a “Go quirk,” but a pretty honest reflection of reality.

Now let’s add a method that “marks the task as done” so that invariants don’t break.

package domain

import "time"

func (t *Task) MarkDone(now time.Time) {
	t.Done = true
	t.DoneAt = &now
}

Notice the style: domain logic lives next to the domain struct. And it’s very nice that it doesn’t care whether the task will later be serialized to JSON, YAML, or carved into a stone tablet.

DTO: what the JSON message looks like

Now imagine that our external contract looks like this:

{
  "id": 10,
  "title": "Buy milk",
  "done": false,
  "created_at": "2026-01-16T10:00:00Z",
  "done_at": null
}

We can create a DTO that matches this format. Here time.Time is perfectly acceptable, because encoding/json can encode/decode time.Time as a string (usually in 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"`
}

There’s an important nuance here: omitempty means “don’t include the field during encoding” if it’s empty. If DoneAt == nil, then when you json.Marshal the field done_at will disappear from the JSON.

Why people often create different DTOs for “create” vs “response”

In practice, input data (“create a task”) and output data (“here is the task”) rarely match. If you send JSON for creation, it usually doesn’t include id or created_at: the application generates them.

So for input we create a separate DTO:

package dto

type CreateTaskDTO struct {
	Title string `json:"title"`
}

It looks boring, but boring is good: boring code is easier to maintain than a “universal mega-struct.”

DTO validation: check meaning before mapping

In the previous lecture we already separated decode from validate. Here we’ll just apply that as a habit. A DTO can validate itself.

package dto

import (
	"errors"
	"strings"
)

func (d CreateTaskDTO) Validate() error {
	if strings.TrimSpace(d.Title) == "" {
		return errors.New("title is required")
	}
	return nil
}

There’s nothing “architectural” here. It’s a normal human check: the title must not be empty or consist of spaces (spaces also really want to be tasks, but not today).

Mapping: DTO → domain

Now let’s make an explicit conversion. To create a task, the domain needs: Title, CreatedAt, and ID is “0” for now (or it will be set later). Let’s assume in our app the storage layer assigns the ID, and here we only create the object.

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,
	}
}

Why this is good:

  1. Normalization (TrimSpace) is concentrated in one place.
  2. The domain model doesn’t know about JSON.
  3. We can change the external format while barely touching domain logic.

Mapping: domain → DTO for responses

When we need to output a task as JSON, it’s better to build the DTO explicitly.

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,
	}
}

Again: boring, transparent, readable. Perfect.

Glue it all into a flow: Unmarshal → Validate → ToDomain → Marshal

Let’s assemble a mini example in main so it’s clear how this lives in real code.

package main

import (
	"encoding/json"
	"fmt"
	"time"

	"example.com/taskapp/dto"
)

func main() {
	data := []byte(`{"title":"  Buy milk  "}`)

	var in dto.CreateTaskDTO
	if err := json.Unmarshal(data, &in); err != nil {
		fmt.Println("bad json:", err)
		return
	}

	if err := in.Validate(); err != nil {
		fmt.Println("bad data:", err)
		return
	}

	task := in.ToDomain(time.Now().UTC())
	out := dto.FromDomainTask(task)

	b, _ := json.MarshalIndent(out, "", "  ")
	fmt.Println(string(b))
}

It’s important to notice one subtlety here: json.Unmarshal can return different kinds of errors, and sometimes it’s useful to distinguish between “broken JSON” and “bad data.” For example, the encoding/json package returns an error of type *json.SyntaxError for a syntax problem, and you can detect it via errors.As.

5. Boundaries: tags, storing “as is,” and project structure

Where to keep tags and how not to turn a project into “tags rule the world”

A very typical beginner problem: “I’ll add tags right into domain.Task because it’s easier.” And then you suddenly discover that the domain model is “forced” to have fields and names for JSON, even though the logic doesn’t need them.

A practical rule that usually leads to a calm life: tags live in DTOs, and domain structs live without tags. Then the domain can be used in different contexts: not only JSON, but also, for example, in tests, in memory, in other formats.

If your project already uses packages (and you covered them earlier), a structure like this looks natural:

taskapp/
  domain/
    task.go
  dto/
    task_dto.go
  main.go

And this already looks like a “grown-up” organization, but without unnecessary complexity.

When “storing as is” is still useful

Sometimes a DTO “as is” is your storage model. For example, you’re building import/export and you want to store in a file exactly what you expose externally, without extra computations and without invariants (or with minimal ones).

Then the DTO can be used as the “storage format,” and the domain as the “working format.” You read the DTO, validate it, map it into the domain, work with it, and then map it back into a DTO for saving.

The point is that “as is” is useful at the boundary where preserving the contract matters. But inside the application it’s almost always better to have types that reflect meaning and protect you from dumb states.

6. Common mistakes

Mistake No. 1: one structure for everything, and it grows like a snowball.
At first it seems convenient: one Task, plus tags in it, plus omitempty, plus “future” fields. But very quickly the struct becomes a trash can: you’re afraid to delete a field, afraid to rename, afraid to change types, because “what if JSON breaks.” Separating DTOs and the domain brings back control: the DTO changes with the contract, the domain changes with the logic.

Mistake No. 2: the domain model stores “format” types (time as a string, numbers as strings) just because that’s how they came in JSON.
This solution usually looks like “I’ll deal with it later,” but later never comes. As a result, you compare dates as strings, sort numbers as text, and catch bugs like “100 is less than 9.” It’s better to parse/convert input data at the boundary and store normal types inside (time.Time, int).

Mistake No. 3: using omitempty as validation.
omitempty is about encoding (Marshal), not about meaning. A field may disappear from JSON, but that doesn’t make it “allowed to be empty” from the logic point of view. If title is required, Validate checks that — not omitempty.

Mistake No. 4: mapping gets smeared across the codebase.
Today you parsed CreatedAt in one place, tomorrow in another, the day after tomorrow you forgot TrimSpace, and tasks start living their own lives. Mapping should be an explicit checkpoint: ToDomain() / FromDomain() or separate converter functions, but in one clear place.

Mistake No. 5: trying to “fix data silently” and losing diagnostics.
For example, a bad date format comes in, and you decide “fine, let it be time.Time{}.” Then you end up searching for why the date “1970-01-01” (or the zero time) suddenly got into a report. If input data is invalid, it’s better to return an error at the boundary than to silently create a weird state inside the app.

1
Task
Go SELF, level 45, lesson 4
Locked
Two data layers
Two data layers
1
Task
Go SELF, level 45, lesson 4
Locked
Book draft
Book draft
1
Task
Go SELF, level 45, lesson 4
Locked
Task API response
Task API response
1
Task
Go SELF, level 45, lesson 4
Locked
Update Status
Update Status
1
Survey/quiz
JSON (base), 45 level, 4 lesson
Unavailable
JSON (base)
JSON (base)
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION