DTO (Data Transfer Object): A Complete Guide in Backend Architecture
11 min read

DTO (Data Transfer Object): A Complete Guide in Backend Architecture

DTO (Data Transfer Object) is one of the most important — and most misunderstood — concepts in modern backend development. Many codebases become hard to maintain, fragile, and leaky across layers not because of complex business logic, but because data flows without a clear contract across system boundaries. DTO solves this problem by becoming an explicit contract defining the shape of data at every transfer point. This article dissects DTOs in depth — from the basic concept, their types, their position in each layer, to the anti-patterns to avoid — with real implementation examples using Golang and the repository pattern.

The Big Picture — Why DTOs Are Needed

Imagine a backend without DTOs: the HTTP handler directly uses database models, the repository knows the details of HTTP requests, and every database schema change immediately breaks the API contract. This isn’t a hypothesis — it’s the reality in many production codebases.

graph LR
    subgraph "❌ Without DTO"
        Client1["Client"] --> Handler1["Handler"]
        Handler1 -->|"model.User"| Service1["Service"]
        Service1 -->|"model.User"| Repo1["Repository"]
        Repo1 --> DB1["Database"]
    end

    style Client1 fill:#f5f5f4,stroke:#78716c,color:#000
    style Handler1 fill:#fecaca,stroke:#dc2626,color:#000
    style Service1 fill:#fecaca,stroke:#dc2626,color:#000
    style Repo1 fill:#fecaca,stroke:#dc2626,color:#000
    style DB1 fill:#e0e7ff,stroke:#4f46e5,color:#000
graph LR
    subgraph "✅ With DTO"
        Client2["Client"] -->|"RequestDTO"| Handler2["Handler"]
        Handler2 -->|"RequestDTO"| Service2["Service"]
        Service2 -->|"Entity"| Repo2["Repository"]
        Repo2 --> DB2["Database"]
        Service2 -->|"ResponseDTO"| Handler2
    end

    style Client2 fill:#f5f5f4,stroke:#78716c,color:#000
    style Handler2 fill:#bbf7d0,stroke:#16a34a,color:#000
    style Service2 fill:#bfdbfe,stroke:#2563eb,color:#000
    style Repo2 fill:#bfdbfe,stroke:#2563eb,color:#000
    style DB2 fill:#e0e7ff,stroke:#4f46e5,color:#000

With DTOs, every layer has a clear data contract. Database schema changes don’t break the API. Response format changes don’t force repository refactors. Every boundary is protected.


What Is a DTO — The Right Definition

A DTO (Data Transfer Object) is an object used specifically to carry data between layers or system boundaries, without containing business logic. A DTO isn’t an entity, isn’t a database model, and isn’t a domain object. This distinction is critical and often ignored.

AspectDTOEntity
PurposeTransfer data between boundariesRepresent a database table
Business logicNoneIdeally none
StabilityRelatively stable (API contract)Can change following the schema
Used atBoundaries between layersRepository and database
ExampleCreateUserRequest, UserResponsemodel.User

The characteristics distinguishing a DTO from other components: simple structure (struct or class), no behavior (methods that mutate state), and serving as a data contract — defining what’s allowed in and what’s allowed out of every layer.


Layers in the Backend — Where DTOs Live

Before discussing DTO types, it’s important to understand the position of each layer in backend architecture and at which boundaries DTOs operate.

graph TB
    Client["Client<br/>(Browser, Mobile, Other Service)"]
    Handler["Handler Layer<br/>(HTTP / gRPC)"]
    Service["Service Layer<br/>(Business Logic)"]
    Repo["Repository Layer<br/>(Data Access)"]
    DB["Database"]

    Client -->|"Request DTO"| Handler
    Handler -->|"Request DTO"| Service
    Service -->|"Entity"| Repo
    Repo -->|"Entity / Query Result DTO"| Service
    Service -->|"Response DTO"| Handler
    Handler -->|"Response DTO"| Client

    style Client fill:#f5f5f4,stroke:#78716c,color:#000
    style Handler fill:#fef3c7,stroke:#d97706,color:#000
    style Service fill:#bfdbfe,stroke:#2563eb,color:#000
    style Repo fill:#e0e7ff,stroke:#4f46e5,color:#000
    style DB fill:#bbf7d0,stroke:#16a34a,color:#000

The main principle: DTOs always sit at boundaries between layers, not in the middle of logic. Handlers must not know the database structure. Repositories must not know the HTTP request format. DTOs become the bridge preserving this isolation.


Types of DTOs

Request DTO — Input from the Client

A Request DTO represents the data the client sends to the backend. This DTO defines what’s allowed to be sent, while also being the place for input validation before data enters business logic.

Request DTOs are used in the Handler (for parsing) and the Service (as parameters). The Repository must not receive a Request DTO — this is a common anti-pattern.

Create Request

type CreateUserRequest struct {
    Name  string `json:"name" validate:"required"`
    Email string `json:"email" validate:"required,email"`
}

Update Request

type UpdateUserRequest struct {
    Name  *string `json:"name,omitempty"`
    Email *string `json:"email,omitempty"`
}

Note the use of pointers in the Update DTO. This is important to distinguish three conditions: nil means the field isn’t updated, "" (empty string) means update to an empty string, and "value" means update to a new value. Without pointers, you can’t distinguish “not sending the field” from “sending an empty field.”

List / Query Request

type ListUserRequest struct {
    Page   int     `query:"page"`
    Limit  int     `query:"limit"`
    SortBy string  `query:"sort_by"`
    Order  string  `query:"order"`

    Status *string `query:"status"`
    Q      *string `query:"q"`
}

A List Request DTO is an API contract, not a database contract. The SortBy field here can differ from the column name in the database — and indeed it should. The mapping from API fields to database columns happens in the Service or Repository.

Query Result DTO — Raw Query Results

This is the DTO type most often forgotten, even though it’s very important in real applications. Once you start writing raw SQL with JOINs, aggregations, or computed fields, a plain entity is no longer sufficient.

A Query Result DTO is used to hold query results whose fields aren’t 1:1 with any table. This DTO lives at the boundary between the Repository (as output) and the Service (as input).

type UserStatsRow struct {
    UserID      uint   `db:"user_id"`
    Name        string `db:"name"`
    TotalOrders int    `db:"total_orders"`
}

Example of a repository using a Query Result DTO:

func (r *userRepository) FindUserStats(ctx context.Context) ([]UserStatsRow, error) {
    var rows []UserStatsRow

    query := `
        SELECT u.id AS user_id, u.name, COUNT(o.id) AS total_orders
        FROM users u
        LEFT JOIN orders o ON o.user_id = u.id
        GROUP BY u.id, u.name
    `

    err := r.db.WithContext(ctx).Raw(query).Scan(&rows).Error
    return rows, err
}

Forcing raw query results into an entity is an anti-pattern. An entity represents one table, while JOIN and aggregation results can span many tables with computed fields that don’t exist in any schema.

Response DTO — Output to the Client

A Response DTO represents the data the backend sends to the client. Its main functions: filtering sensitive fields (password hashes, internal IDs), formatting data per API needs, and maintaining backward compatibility when entities change.

Response DTOs are used in the Service (for construction) and the Handler (for serialization). An Entity must not be sent directly as a response — this leaks internal details.

type UserResponse struct {
    ID    uint   `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}
type UserStatsResponse struct {
    UserID      uint   `json:"user_id"`
    Name        string `json:"name"`
    TotalOrders int    `json:"total_orders"`
}

The Complete Flow — Create User

To see how all DTOs work together, here’s the complete Create User flow from handler to response.

graph LR
    Client["Client"] -->|"JSON Body"| Handler["Handler<br/>Parse + Validate"]
    Handler -->|"CreateUserRequest"| Service["Service<br/>Business Logic"]
    Service -->|"model.User"| Repo["Repository<br/>Create"]
    Repo --> DB["Database"]
    Repo -->|"model.User"| Service
    Service -->|"UserResponse"| Handler
    Handler -->|"JSON Response"| Client

    style Client fill:#f5f5f4,stroke:#78716c,color:#000
    style Handler fill:#fef3c7,stroke:#d97706,color:#000
    style Service fill:#bfdbfe,stroke:#2563eb,color:#000
    style Repo fill:#e0e7ff,stroke:#4f46e5,color:#000
    style DB fill:#bbf7d0,stroke:#16a34a,color:#000

Handler

var req dto.CreateUserRequest
if err := c.BodyParser(&req); err != nil {
    return err
}

resp, err := userService.Create(ctx, req)

The handler is only responsible for parsing the JSON body into a Request DTO and calling the Service. No business logic here.

Service

func (s *userService) Create(ctx context.Context, req dto.CreateUserRequest) (*dto.UserResponse, error) {
    user := model.User{
        Name:  req.Name,
        Email: req.Email,
    }

    if err := s.repo.Create(ctx, &user); err != nil {
        return nil, err
    }

    return &dto.UserResponse{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
    }, nil
}

The Service receives a Request DTO, converts it into an Entity, calls the Repository, then converts the Entity into a Response DTO. This is the only place where DTO ↔ Entity mapping happens.


DTO Adapters — Clean Mapping at Scale

As system complexity grows, the mapping process between DTO ↔ Entity ↔ Query Result becomes more frequent and more complicated. If all mapping is done inline in the Service, the Service becomes fat and hard to maintain. This is where the Adapter (or Mapper) comes in.

graph LR
    DTO["DTO"] --> Adapter["Adapter<br/>(Mapping Only)"]
    Adapter --> Entity["Entity / Domain"]

    style DTO fill:#fef3c7,stroke:#d97706,color:#000
    style Adapter fill:#bfdbfe,stroke:#2563eb,color:#000
    style Entity fill:#bbf7d0,stroke:#16a34a,color:#000

Why Adapters Are Needed

Without AdapterWith Adapter
✅ Simple for small projects✅ Service focuses on business logic
❌ Service becomes fat✅ Consistent, centralized mapping
❌ Mapping scattered and duplicated✅ Easy to add response variations
❌ Hard to refactor or reuse✅ Easy to reuse across different endpoints

An Adapter is highly recommended if: DTOs and Entities start being non-1:1, there are many response variations for the same entity, raw queries get more complex, or domain logic has grown. On small projects with 2-3 endpoints, mapping directly in the Service is still reasonable.

Example Adapter

type UserAdapter struct{}

func (a UserAdapter) ToEntity(req dto.CreateUserRequest) model.User {
    return model.User{
        Name:  req.Name,
        Email: req.Email,
    }
}

func (a UserAdapter) ToResponse(user model.User) dto.UserResponse {
    return dto.UserResponse{
        ID:    user.ID,
        Name:  user.Name,
        Email: user.Email,
    }
}

func (a UserAdapter) StatsToResponse(row dto.UserStatsRow) dto.UserStatsResponse {
    return dto.UserStatsResponse{
        UserID:      row.UserID,
        Name:        row.Name,
        TotalOrders: row.TotalOrders,
    }
}

Using the Adapter in the Service

func (s *userService) Create(ctx context.Context, req dto.CreateUserRequest) (*dto.UserResponse, error) {
    user := s.adapter.ToEntity(req)

    if err := s.repo.Create(ctx, &user); err != nil {
        return nil, err
    }

    resp := s.adapter.ToResponse(user)
    return &resp, nil
}

The Service is now clean — it only contains business logic. All data transformations are handled by the Adapter. Keep in mind that an Adapter is only for data transformation. If an Adapter starts containing business logic, calling repositories, or doing validation — that’s an anti-pattern.

The Adapter’s Position in the Layers

graph TB
    Handler["Handler"] -->|"Request DTO"| Adapter["Adapter"]
    Adapter -->|"Entity"| Service["Service"]
    Service --> Repo["Repository"]
    Repo -->|"Entity / Query Result DTO"| Adapter2["Adapter"]
    Adapter2 -->|"Response DTO"| Handler

    style Handler fill:#fef3c7,stroke:#d97706,color:#000
    style Adapter fill:#bfdbfe,stroke:#2563eb,color:#000
    style Service fill:#bbf7d0,stroke:#16a34a,color:#000
    style Repo fill:#e0e7ff,stroke:#4f46e5,color:#000
    style Adapter2 fill:#bfdbfe,stroke:#2563eb,color:#000

Adapter vs Mapper vs Assembler

These three terms are often confusing because their usage contexts differ. Functionally, all three do the same thing — transforming data between representations.

TermOrigin ContextCommon Usage
AdapterClean ArchitectureMost common in Go and general backend
MapperEnterprise Patterns (Fowler)Popular in Java/C# ecosystems
AssemblerDomain-Driven DesignClassic DDD, rarely used in modern codebases

Choose one consistent term for your codebase — don’t mix all three.


Anti-Patterns to Avoid

Here are mistakes that repeatedly appear in production codebases and how to avoid them.

Repository Receiving a Request DTO

graph LR
    subgraph "❌ Anti-Pattern: Repository Receives Request DTO"
        H1["Handler"] -->|"CreateUserRequest"| S1["Service"]
        S1 -->|"CreateUserRequest"| R1["Repository"]
    end

    style H1 fill:#f5f5f4,stroke:#78716c,color:#000
    style S1 fill:#fef3c7,stroke:#d97706,color:#000
    style R1 fill:#fecaca,stroke:#dc2626,color:#000
graph LR
    subgraph "✅ Correct: Repository Receives an Entity"
        H2["Handler"] -->|"CreateUserRequest"| S2["Service"]
        S2 -->|"model.User"| R2["Repository"]
    end

    style H2 fill:#f5f5f4,stroke:#78716c,color:#000
    style S2 fill:#bfdbfe,stroke:#2563eb,color:#000
    style R2 fill:#bbf7d0,stroke:#16a34a,color:#000

A Repository must not know about HTTP. If a repository receives a Request DTO, an API format change forces refactoring all the way down to the data layer. The Service is responsible for converting Request DTOs into Entities before calling the Repository.

Handler Returning an Entity Directly

graph LR
    subgraph "❌ Anti-Pattern: Entity Directly to the Client"
        R3["Repository"] -->|"model.User<br/>(including password hash)"| H3["Handler"]
        H3 -->|"model.User"| C3["Client"]
    end

    style R3 fill:#e0e7ff,stroke:#4f46e5,color:#000
    style H3 fill:#fecaca,stroke:#dc2626,color:#000
    style C3 fill:#f5f5f4,stroke:#78716c,color:#000
graph LR
    subgraph "✅ Correct: Response DTO to the Client"
        R4["Repository"] -->|"model.User"| S4["Service"]
        S4 -->|"UserResponse<br/>(without password hash)"| H4["Handler"]
        H4 -->|"UserResponse"| C4["Client"]
    end

    style R4 fill:#e0e7ff,stroke:#4f46e5,color:#000
    style S4 fill:#bfdbfe,stroke:#2563eb,color:#000
    style H4 fill:#bbf7d0,stroke:#16a34a,color:#000
    style C4 fill:#f5f5f4,stroke:#78716c,color:#000

Entities often contain sensitive fields — password hashes, internal statuses, soft-delete flags. If an entity is sent directly as a response, internal data leaks to the client. A Response DTO ensures only the fields that should be visible are sent.

One DTO for All Operations

Using one DTO for Create, Update, and List looks “efficient” at first, but becomes a big problem as the system grows. Create needs required fields, Update needs optional fields (pointers), List needs pagination and filters. All three have fundamentally different contracts.

OperationNeedsExample DTO
CreateAll fields requiredCreateUserRequest
UpdateOptional fields (pointers)UpdateUserRequest
ListPagination, filter, sortListUserRequest
ResponseClient-safe fieldsUserResponse

Scanning Raw Queries Directly into an Entity

When JOIN or aggregation query results are forced into an entity, mismatched fields get silently ignored or cause runtime errors. Always create a Query Result DTO whose fields exactly match the SELECTed columns.


Decision Guide — When to Use Which DTO

graph TD
    Start["Data needs to move<br/>between layers"] --> Q1{"From where to where?"}

    Q1 -->|"Client to Backend"| ReqDTO["Use a Request DTO"]
    Q1 -->|"Backend to Client"| RespDTO["Use a Response DTO"]
    Q1 -->|"Repository to Service<br/>(raw query)"| Q2{"Query result 1:1<br/>with a table?"}

    Q2 -->|"Yes"| UseEntity["Use an Entity"]
    Q2 -->|"No (JOIN, agg)"| QrDTO["Use a Query Result DTO"]

    style ReqDTO fill:#fef3c7,stroke:#d97706,color:#000
    style RespDTO fill:#bbf7d0,stroke:#16a34a,color:#000
    style QrDTO fill:#bfdbfe,stroke:#2563eb,color:#000
    style UseEntity fill:#e0e7ff,stroke:#4f46e5,color:#000

Example Folder Structure

For a Go project with the repository pattern, here’s a folder structure that places DTOs clearly:

project/
  dto/
    user_request.go       // CreateUserRequest, UpdateUserRequest, ListUserRequest
    user_response.go      // UserResponse, UserStatsResponse
    user_query.go         // UserStatsRow (Query Result DTO)
  model/
    user.go               // model.User (Entity / DB model)
  adapter/
    user_adapter.go       // UserAdapter (mapping DTO <-> Entity)
  repository/
    user_repository.go    // UserRepository interface + implementation
  service/
    user_service.go       // UserService (business logic)
  handler/
    user_handler.go       // HTTP handler

Separate DTO files by function (_request, _response, _query), not per operation. This makes searching and navigation much easier as the project grows.


The Most Common Architecture Mistakes

Using map[string]interface{} as a DTO substitute. This eliminates all the benefits of type safety, autocompletion, and validation. DTOs may feel like “more code”, but the upfront investment saves hundreds of hours of debugging later.

Adding business logic methods to DTOs. A DTO with CalculateDiscount() or IsActive() methods is no longer a DTO — it’s a domain object. A DTO only carries data, period.

Using the same DTO for internal service-to-service communication and external APIs. Internal and external needs differ — internal may expose more fields, external must be minimal. Create separate DTOs for each boundary.

Naming DTOs without operation context. UserDTO explains nothing. CreateUserRequest, UserResponse, UserStatsRow — each is immediately clear about its purpose.

Not creating Query Result DTOs for raw queries. This causes JOIN and aggregation results to be forced into mismatched entities — fields silently lost, type mismatches, and bugs that are hard to trace.


Summary

  • DTO — an object specifically for transferring data between boundaries, without business logic. Not an entity, not a domain object.
  • Request DTO — the input contract from the client. Used in the Handler and Service. Repositories must not receive it.
  • Response DTO — the output contract to the client. Filters sensitive fields and maintains backward compatibility.
  • Query Result DTO — holds raw query results (JOINs, aggregations). Don’t force them into entities.
  • Adapter/Mapper — a dedicated component for DTO ↔ Entity mapping. Data transformation only, no business logic.
  • One operation, one DTO — Create, Update, List, and Response each need their own DTO. Don’t merge them.
  • Repositories don’t know HTTP — Repositories receive Entities, not Request DTOs. The Service is responsible for mapping.
  • Investing in DTOs saves time — type safety, automatic validation, and clear boundaries prevent far more expensive bugs in production.

Portfolio