JavaRush /Courses /Go SELF /DTO vs domain model — when to separate

DTO vs domain model — when to separate

Go SELF
Level 56, Lesson 3
Available

1. Two responsibilities: “how we transmit” vs “what it means”

If you’re just starting out, it’s very tempting to think: “Data is data.” But in a real application, data lives in two worlds: the internal one (your code, domain rules) and the external one (JSON, clients, integrations, the network). These worlds are similar, but not the same. At least because the external world loves strings and null, while the internal world loves invariants and meaningful types.

Let’s lock in the terms:

Domain model — structures and types that describe the meaning of the domain and its rules. For example: “a task must have a non-empty title,” “a task ID is a positive number,” “a task can be completed.”

DTO (Data Transfer Object) — structures for transferring data across the application boundary. In our HTTP module, the boundary is the JSON API contract: what fields exist and what they are called, which are required, which are optional.

A useful diagram to keep in mind:

flowchart LR
    A["JSON request"] --> B["DTO (request)"]
    B --> C["Domain (command/model)"]
    C --> D["Domain result"]
    D --> E["DTO (response)"]
    E --> F["JSON response"]

It sounds boring, but this is exactly the kind of “boring” design that suddenly makes your project resilient.

2. Why “one struct for everything” is tempting — and how it ends

Let’s be honest: the idea “one struct for everything” feels brilliant. We write type Task struct { ... json:"..." }, do json.Marshal/json.Unmarshal — and life is great. And you can also store that same struct in memory, return it to the client, and show it to the user… a universal Swiss army knife!

The problem is that this is a Swiss army knife with no blade, but eight corkscrews.

Mixing the domain and DTO usually leads to three kinds of pain.

The first pain: the domain starts depending on the external contract. Suddenly you can’t rename a field in Go because “clients expect the old name in JSON.” Or you can’t change a type because “JSON sends strings.”

The second pain: domain rules get diluted. Fields appear like Title string with no validation, ID int with zero, Done bool with no clear state transition — and business logic spreads across the codebase.

The third pain: you can’t evolve the API. You need to add a new field to the response, but it must not become part of the domain. Or the other way around: the domain gets a new internal field, but you must not expose it externally. When everything is in one struct, you start dancing with json:"-", pointers, omitempty and “let’s just not populate it.”

And at that point the struct turns into “a suitcase without a handle”: hard to carry, and scary to throw away, because half the code is tied to it.

3. Domain model: rules, invariants, and “normal types”

The domain model should be the place where meaning lives. Here we want the state to be valid, and state changes to be controlled. And it’s highly desirable for the domain to not know about JSON tags, HTTP, and “what the client calls this field.” The domain doesn’t care how it gets packaged for the network. The domain is about what it is.

Let’s take our learning domain tasks. We’ll start simple: TaskID, Task and minimal rules.

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 must not be empty")
	}
	return Task{Title: title, Done: false}, nil
}

Notice an important detail: the domain does not accept input “as-is,” it cleans and validates it. That’s what invariants are: we only create valid tasks.

Now let’s add a “domain action”: marking a task as done. Even if it’s just Done = true, it’s useful to express it as a method, because later rules may appear (for example, you can’t complete a task without a Title, you can’t “complete it again,” you need to record time, etc.).

package main

import "errors"

func (t *Task) MarkDone() error {
	if t.Done {
		return errors.New("task already done")
	}
	t.Done = true
	return nil
}

Yes, right now it looks “too formal for a single bool.” But this is exactly how you protect yourself from future requirements without turning the project into “if paste.”

4. DTO: the external contract and “street rules”

A DTO is like a translator. It speaks the external world’s language: JSON, strings, omitempty, sometimes null, sometimes “the field may be missing.” It doesn’t have to be beautiful from the domain’s point of view — it must be stable and clear to the client.

Let’s imagine a minimal API contract (at the struct level) that we already started defining earlier:

  • create a task: the client sends { "title": "buy milk" }
  • task response: { "id": 1, "title": "buy milk", "done": false }

Let’s create DTOs:

package main

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

type TaskDTO struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

Key idea: DTOs obey the contract, not domain elegance. For example, the domain may want TaskID as a separate type, while the DTO will almost certainly return id as a regular number, because JSON doesn’t know about your type TaskID int.

Now let’s add an example of “optionality.” APIs often have fields that may be missing. In the domain this may be strictly forbidden, while in the DTO it’s acceptable. For example, in the response we want to return description, but it’s optional.

package main

type TaskDTO struct {
	ID          int     `json:"id"`
	Title       string  `json:"title"`
	Done        bool    `json:"done"`
	Description *string `json:"description,omitempty"`
}

Why *string instead of string? Because string has the zero value "", and you can’t distinguish “an empty string was provided” from “the field was not present.” A DTO often must distinguish those cases. The domain, on the other hand, more often tries to live without “three-valued logic” (“present/absent/empty”), otherwise the developer’s brain starts overheating.

5. Explicit mapping and an example

Once you separate the domain and DTOs, a question appears: “Okay, so how do we convert between them?”

The Go-style answer: explicitly.

Not via reflection, not via a “universal mapper,” not via 17 annotations. Just functions. They are short, readable, and easy to find across the project.

Let’s start with domain → DTO (what we return to the client):

package main

func ToTaskDTO(t Task) TaskDTO {
	return TaskDTO{
		ID:    int(t.ID),
		Title: t.Title,
		Done:  t.Done,
	}
}

Now DTO request → domain input. Here’s an important point: a create request is not yet a Task. A task may have an ID assigned by storage, and it may have many internal fields. So it’s useful to have a separate type for the “input to the domain.”

package main

import "strings"

type CreateTaskInput struct {
	Title string
}

func ToCreateTaskInput(req CreateTaskRequest) CreateTaskInput {
	return CreateTaskInput{
		Title: strings.TrimSpace(req.Title),
	}
}

And then the domain accepts CreateTaskInput and decides what to do with it. For example:

package main

func CreateTask(input CreateTaskInput) (Task, error) {
	return NewTask(input.Title)
}

Yes, this is almost a “proxy” to NewTask. But now you have clearly separated roles: DTO is transport, CreateTaskInput is the domain boundary, Task is a domain entity.

Mini-example: creating a task without a server

We’re not running an HTTP server right now, but we can build a small “boundary simulation”: as if JSON was already parsed into a DTO, then passed through the domain, and we got a response DTO.

package main

import (
	"fmt"
)

func simulateCreate(req CreateTaskRequest) (TaskDTO, error) {
	input := ToCreateTaskInput(req)

	task, err := CreateTask(input)
	if err != nil {
		return TaskDTO{}, err
	}

	// Imagine storage assigned ID=1
	task.ID = TaskID(1)

	return ToTaskDTO(task), nil
}

func main() {
	dto, err := simulateCreate(CreateTaskRequest{Title: "  buy milk  "})
	if err != nil {
		fmt.Println("error:", err)
		return
	}

	fmt.Printf("%+v\n", dto) // {ID:1 Title:buy milk Done:false}
}

What’s important in this example: we can easily test the domain and mapping without HTTP. And when real transport (HTTP) appears, it will simply “feed” CreateTaskRequest and “consume” TaskDTO.

6. Validation: how not to make layers fight

Validation is where philosophical debates begin. Some say “validate at the HTTP input,” others say “validate only in the domain.” Reality is usually this: you can validate in two places, but for different reasons.

The transport layer (DTO) validates what belongs to format and contract: “field exists,” “type is correct,” “string isn’t too long” (sometimes), “JSON parsed fine.” The domain validates meaning: “title isn’t empty,” “can’t complete an already completed task,” “can’t delete something that doesn’t exist” (that’s closer to domain errors).

Let’s show a simple domain validation error type that is convenient to use later at the boundary (for example, to fill fields in an error envelope).

package main

type ValidationError struct {
	Fields map[string]string
}

func (e ValidationError) Error() string {
	// Short and safe: error text is not a contract; the contract is the structure.
	return "validation failed"
}

Now the domain can return such an error:

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
}

Why is this useful? Because at the top (at the boundary) you can turn it into a clear response for the client. And most importantly: you don’t need to parse error text (which is its own kind of punishment). Errors in Go are values, and they can carry context. This idea (errors as values + typed errors) is very characteristic of Go and supported by standard practices.

7. When separation is mandatory — and when you can simplify

At this point beginners often get scared: “If DTO and domain are different, that’s more code! So I’m making it too complicated!”

Yes, it’s more code. But it appears exactly where real complexity appears: the boundary. And if there’s almost no boundary, you can simplify.

Below is not a list of “commandments,” but more of a feel that comes with practice.

If you have a small program “read JSON → output JSON,” with no rules, no storage, no contract evolution, then DTO and domain often coincide. There’s almost no domain: you just have data, and an “all-in-one” struct won’t kill the project because there’s nothing to kill.

But as soon as you have at least one of these: domain rules, multiple representations of data, multiple clients, API versions, internal fields you can’t expose, or simply a desire to write testable code without HTTP — separation starts paying off very quickly.

To avoid leaving this as “just a feeling,” here’s a short cheat-sheet table:

Situation Can you use one struct? Why
Quick prototype, one action, no storage Sometimes yes Almost no domain rules; the contract won’t live long
There are rules (“title not empty,” “done can’t be rolled back”) Better to separate The domain must protect invariants independently of JSON
Optional fields appear in the API (omitempty, null) Almost always separate The DTO starts living in “three-valued logic,” which gets in the domain’s way
There are internal fields (e.g., InternalNote, audit, tech flags) Must separate Leaking internal data outside is a classic bug
We want to version the API (v1, v2) Must separate The domain is shared; DTOs differ across versions

8. Common mistakes when separating DTOs and the domain

Mistake #1: a domain model with json tags “just in case.”
It starts innocently: “well, it’s convenient for me.” And then suddenly the domain can’t be used anywhere except HTTP, because it’s fully soaked with transport decisions. As a result, any change in the external contract starts breaking internal structs and tests that are supposed to be about business logic, not JSON.

Mistake #2: the DTO starts “thinking” and holding domain rules.
Sometimes a developer writes methods on CreateTaskRequest like func (r CreateTaskRequest) ValidateBusinessRules() error. It looks reasonable, but it breaks the boundary: business rules must live in the domain, otherwise they get smeared across different inputs (HTTP, CLI, file import) and inevitably drift apart.

Mistake #3: converters do “silent fixes” to data without an explicit decision.
For example, in mapping you take Title, trim spaces, cut length to 128, replace tabs with spaces, and do all of it silently. Sometimes normalizing input is useful, but it’s important not to turn mapping into a “meaning eraser.” If data is being “fixed,” it should be intentional and preferably in one place (often in the domain), otherwise you won’t understand later why “the user sent one thing, but another got saved.”

Mistake #4: trying to build a “universal mapper” via reflection too early.
On paper it saves lines; in practice it steals understanding. For beginners it’s especially painful: mapping errors become non-obvious, the IDE helps less, and debugging turns into archaeology. Explicit 5–8 line functions are almost always easier to read and maintain.

Mistake #5: turning a domain error into a “text protocol.”
When people start doing if strings.Contains(err.Error(), "not found") { ... }, you’re effectively building a contract on top of text that is not required to be stable. In Go, it’s customary to recognize classes of errors by values and types (sentinel/typed errors, errors.Is/As).

1
Task
Go SELF, level 56, lesson 3
Locked
Order in JSON
Order in JSON
1
Task
Go SELF, level 56, lesson 3
Locked
Title normalization
Title normalization
1
Task
Go SELF, level 56, lesson 3
Locked
User validation
User validation
1
Task
Go SELF, level 56, lesson 3
Locked
Request versions
Request versions
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION