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:#000graph 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:#000With 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.
| Aspect | DTO | Entity |
|---|---|---|
| Purpose | Transfer data between boundaries | Represent a database table |
| Business logic | None | Ideally none |
| Stability | Relatively stable (API contract) | Can change following the schema |
| Used at | Boundaries between layers | Repository and database |
| Example | CreateUserRequest, UserResponse | model.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:#000The 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:#000Handler
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:#000Why Adapters Are Needed
| Without Adapter | With 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:#000Adapter 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.
| Term | Origin Context | Common Usage |
|---|---|---|
| Adapter | Clean Architecture | Most common in Go and general backend |
| Mapper | Enterprise Patterns (Fowler) | Popular in Java/C# ecosystems |
| Assembler | Domain-Driven Design | Classic 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:#000graph 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:#000A 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:#000graph 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:#000Entities 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.
| Operation | Needs | Example DTO |
|---|---|---|
| Create | All fields required | CreateUserRequest |
| Update | Optional fields (pointers) | UpdateUserRequest |
| List | Pagination, filter, sort | ListUserRequest |
| Response | Client-safe fields | UserResponse |
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:#000Example 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.