DTO (Data Transfer Object): Panduan Lengkap dalam Arsitektur Backend
DTO (Data Transfer Object) adalah salah satu konsep paling penting — dan paling sering disalahpahami — dalam pengembangan backend modern. Banyak codebase menjadi sulit dirawat, rapuh, dan bocor antar layer bukan karena business logic yang kompleks, tapi karena data mengalir tanpa kontrak yang jelas antar boundary sistem. DTO menyelesaikan masalah ini dengan menjadi kontrak eksplisit yang mendefinisikan bentuk data di setiap titik perpindahan. Artikel ini membedah DTO secara mendalam — mulai dari konsep dasar, jenis-jenisnya, posisi di setiap layer, hingga anti-pattern yang harus dihindari — dengan contoh implementasi nyata menggunakan Golang dan repository pattern.
Gambaran Besar — Mengapa DTO Dibutuhkan
Bayangkan sebuah backend tanpa DTO: handler HTTP langsung menggunakan model database, repository tahu detail request HTTP, dan setiap perubahan schema database langsung memecahkan API contract. Ini bukan hipotesis — ini adalah realita di banyak codebase production.
graph LR
subgraph "❌ Tanpa 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 "✅ Dengan 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:#000Dengan DTO, setiap layer punya kontrak data yang jelas. Perubahan schema database tidak memecahkan API. Perubahan format response tidak memaksa refactor repository. Setiap boundary terlindungi.
Apa Itu DTO — Definisi yang Tepat
DTO (Data Transfer Object) adalah objek yang digunakan khusus untuk membawa data antar layer atau antar boundary sistem, tanpa mengandung business logic. DTO bukan entity, bukan model database, dan bukan domain object. Perbedaan ini kritis dan sering diabaikan.
| Aspek | DTO | Entity |
|---|---|---|
| Tujuan | Transfer data antar boundary | Representasi tabel database |
| Business logic | Tidak ada | Idealnya tidak ada |
| Stabilitas | Relatif stabil (kontrak API) | Bisa berubah mengikuti schema |
| Digunakan di | Boundary antar layer | Repository dan database |
| Contoh | CreateUserRequest, UserResponse | model.User |
Karakteristik DTO yang membedakannya dari komponen lain: struktur sederhana (struct atau class), tidak punya behavior (method yang mengubah state), dan berfungsi sebagai kontrak data — mendefinisikan apa yang boleh masuk dan apa yang boleh keluar dari setiap layer.
Layer dalam Backend — Di Mana DTO Hidup
Sebelum membahas jenis-jenis DTO, penting memahami posisi setiap layer dalam arsitektur backend dan di boundary mana DTO beroperasi.
graph TB
Client["Client<br/>(Browser, Mobile, Service lain)"]
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:#000Prinsip utamanya: DTO selalu berada di boundary antar layer, bukan di tengah-tengah logic. Handler tidak boleh tahu struktur database. Repository tidak boleh tahu format HTTP request. DTO menjadi jembatan yang menjaga isolasi ini.
Jenis-Jenis DTO
Request DTO — Input dari Client
Request DTO merepresentasikan data yang dikirim client ke backend. DTO ini mendefinisikan apa yang boleh dikirim, sekaligus menjadi tempat validasi input sebelum data masuk ke business logic.
Request DTO digunakan di Handler (untuk parsing) dan Service (sebagai parameter). Repository tidak boleh menerima Request DTO — ini adalah anti-pattern yang sering terjadi.
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"`
}
Perhatikan penggunaan pointer pada Update DTO. Ini penting untuk membedakan tiga kondisi: nil berarti field tidak di-update, "" (string kosong) berarti update ke string kosong, dan "value" berarti update ke value baru. Tanpa pointer, kamu tidak bisa membedakan “tidak mengirim field” dengan “mengirim field kosong.”
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"`
}
List Request DTO adalah kontrak API, bukan kontrak database. Field SortBy di sini bisa saja berbeda dengan nama kolom di database — dan memang seharusnya begitu. Mapping dari API field ke database column terjadi di Service atau Repository.
Query Result DTO — Hasil Raw Query
Ini adalah jenis DTO yang paling sering terlupakan, padahal sangat penting dalam aplikasi nyata. Begitu kamu mulai menulis raw SQL dengan JOIN, aggregation, atau computed field, entity biasa tidak lagi cukup.
Query Result DTO digunakan untuk menampung hasil query yang field-nya tidak 1:1 dengan tabel manapun. DTO ini hidup di boundary antara Repository (sebagai output) dan Service (sebagai input).
type UserStatsRow struct {
UserID uint `db:"user_id"`
Name string `db:"name"`
TotalOrders int `db:"total_orders"`
}
Contoh repository yang menggunakan 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
}
Memaksakan hasil raw query masuk ke entity adalah anti-pattern. Entity merepresentasikan satu tabel, sedangkan hasil JOIN dan aggregation bisa mencakup banyak tabel dengan computed field yang tidak ada di schema manapun.
Response DTO — Output ke Client
Response DTO merepresentasikan data yang dikirim backend ke client. Fungsi utamanya: menyaring field sensitif (password hash, internal ID), memformat data sesuai kebutuhan API, dan menjaga backward compatibility saat entity berubah.
Response DTO digunakan di Service (untuk konstruksi) dan Handler (untuk serialisasi). Entity tidak boleh langsung dikirim sebagai response — ini membocorkan detail internal.
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"`
}
Alur Lengkap — Create User
Untuk melihat bagaimana semua DTO bekerja bersama, berikut alur lengkap operasi Create User dari handler hingga 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)
Handler hanya bertanggung jawab parsing JSON body ke Request DTO dan memanggil Service. Tidak ada business logic di sini.
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
}
Service menerima Request DTO, mengubahnya menjadi Entity, memanggil Repository, lalu mengubah Entity menjadi Response DTO. Ini adalah satu-satunya tempat di mana mapping DTO ↔ Entity terjadi.
DTO Adapter — Scaling Mapping yang Bersih
Seiring bertambahnya kompleksitas sistem, proses mapping antara DTO ↔ Entity ↔ Query Result akan semakin sering dan semakin rumit. Jika semua mapping dilakukan inline di Service, Service menjadi gemuk dan sulit di-maintain. Di sinilah Adapter (atau Mapper) berperan.
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:#000Mengapa Adapter Dibutuhkan
| Tanpa Adapter | Dengan Adapter |
|---|---|
| ✅ Simple untuk project kecil | ✅ Service fokus ke business logic |
| ❌ Service menjadi gemuk | ✅ Mapping konsisten dan terpusat |
| ❌ Mapping tersebar dan duplikatif | ✅ Mudah menambah variasi response |
| ❌ Sulit refactor atau reuse | ✅ Mudah reuse di endpoint berbeda |
Adapter sangat direkomendasikan jika: DTO dan Entity mulai tidak 1:1, ada banyak variasi response untuk entity yang sama, raw query makin kompleks, atau domain logic sudah berkembang. Pada project kecil dengan 2-3 endpoint, mapping langsung di Service masih wajar.
Contoh 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,
}
}
Pemakaian Adapter di 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
}
Service sekarang bersih — hanya berisi business logic. Semua transformasi data ditangani Adapter. Perlu diingat bahwa Adapter hanya untuk transformasi data. Jika Adapter mulai mengandung business logic, memanggil repository, atau melakukan validasi — itu anti-pattern.
Posisi Adapter dalam Layer
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
Tiga istilah ini sering membingungkan karena konteks penggunaannya berbeda. Secara fungsi, ketiganya melakukan hal yang sama — transformasi data antar representasi.
| Istilah | Asal Konteks | Penggunaan Umum |
|---|---|---|
| Adapter | Clean Architecture | Paling umum di Go dan general backend |
| Mapper | Enterprise Patterns (Fowler) | Populer di Java/C# ecosystem |
| Assembler | Domain-Driven Design | Klasik DDD, jarang dipakai di codebase modern |
Pilih istilah yang konsisten di codebase kamu — jangan campur ketiganya.
Anti-Pattern yang Harus Dihindari
Berikut kesalahan yang berulang kali muncul di codebase production dan cara menghindarinya.
Repository Menerima Request DTO
graph LR
subgraph "❌ Anti-Pattern: Repository Terima 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 "✅ Benar: Repository Terima 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:#000Repository tidak boleh tahu tentang HTTP. Jika repository menerima Request DTO, perubahan format API memaksa refactor sampai ke data layer. Service yang bertanggung jawab mengubah Request DTO menjadi Entity sebelum memanggil Repository.
Handler Mengembalikan Entity Langsung
graph LR
subgraph "❌ Anti-Pattern: Entity Langsung ke Client"
R3["Repository"] -->|"model.User<br/>(termasuk 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 "✅ Benar: Response DTO ke Client"
R4["Repository"] -->|"model.User"| S4["Service"]
S4 -->|"UserResponse<br/>(tanpa 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:#000Entity sering berisi field sensitif — password hash, internal status, soft-delete flag. Jika entity langsung dikirim sebagai response, data internal bocor ke client. Response DTO memastikan hanya field yang seharusnya terlihat yang dikirim.
Satu DTO untuk Semua Operasi
Menggunakan satu DTO untuk Create, Update, dan List terlihat “efisien” di awal, tapi akan menjadi masalah besar seiring berkembangnya sistem. Create butuh field required, Update butuh field optional (pointer), List butuh pagination dan filter. Ketiganya punya kontrak yang fundamentally berbeda.
| Operasi | Kebutuhan | Contoh DTO |
|---|---|---|
| Create | Semua field required | CreateUserRequest |
| Update | Field optional (pointer) | UpdateUserRequest |
| List | Pagination, filter, sort | ListUserRequest |
| Response | Field yang aman untuk client | UserResponse |
Raw Query Langsung Scan ke Entity
Saat hasil query JOIN atau aggregation dipaksakan masuk ke entity, field yang tidak cocok akan diam-diam diabaikan atau menyebabkan error runtime. Selalu buat Query Result DTO yang field-nya persis sesuai dengan kolom yang di-SELECT.
Decision Guide — Kapan Pakai DTO Apa
graph TD
Start["Data perlu berpindah<br/>antar layer"] --> Q1{"Dari mana ke mana?"}
Q1 -->|"Client ke Backend"| ReqDTO["Gunakan Request DTO"]
Q1 -->|"Backend ke Client"| RespDTO["Gunakan Response DTO"]
Q1 -->|"Repository ke Service<br/>(raw query)"| Q2{"Hasil query 1:1<br/>dengan tabel?"}
Q2 -->|"Ya"| UseEntity["Gunakan Entity"]
Q2 -->|"Tidak (JOIN, agg)"| QrDTO["Gunakan 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:#000Contoh Struktur Folder
Untuk project Go dengan repository pattern, berikut struktur folder yang menempatkan DTO dengan jelas:
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
Pisahkan file DTO berdasarkan fungsi (_request, _response, _query), bukan per operasi. Ini membuat pencarian dan navigasi jauh lebih mudah saat project berkembang.
Kesalahan Arsitektur yang Paling Sering Terjadi
Menggunakan map[string]interface{} sebagai pengganti DTO. Ini menghilangkan semua keuntungan type safety, autocompletion, dan validasi. DTO mungkin terasa “lebih banyak kode”, tapi investasi di awal menghemat ratusan jam debugging di kemudian hari.
Menambahkan method business logic ke DTO. DTO yang punya method CalculateDiscount() atau IsActive() sudah bukan DTO lagi — itu domain object. DTO hanya membawa data, titik.
Menggunakan DTO yang sama untuk internal service-to-service communication dan external API. Kebutuhan internal dan external berbeda — internal boleh expose lebih banyak field, external harus minimal. Buat DTO terpisah untuk setiap boundary.
Menamai DTO tanpa konteks operasi. UserDTO tidak menjelaskan apa-apa. CreateUserRequest, UserResponse, UserStatsRow — masing-masing langsung jelas tujuannya.
Tidak membuat Query Result DTO untuk raw query. Ini menyebabkan hasil JOIN dan aggregation dipaksakan masuk ke entity yang tidak cocok — field diam-diam hilang, type mismatch, dan bug yang sulit dilacak.
Ringkasan
- DTO — objek khusus untuk transfer data antar boundary, tanpa business logic. Bukan entity, bukan domain object.
- Request DTO — kontrak input dari client. Digunakan di Handler dan Service. Repository tidak boleh menerimanya.
- Response DTO — kontrak output ke client. Menyaring field sensitif dan menjaga backward compatibility.
- Query Result DTO — menampung hasil raw query (JOIN, aggregation). Jangan paksakan ke entity.
- Adapter/Mapper — komponen khusus untuk mapping DTO ↔ Entity. Hanya transformasi data, tanpa business logic.
- Satu operasi, satu DTO — Create, Update, List, dan Response masing-masing butuh DTO sendiri. Jangan digabung.
- Repository tidak tahu HTTP — Repository menerima Entity, bukan Request DTO. Service yang bertanggung jawab melakukan mapping.
- Investasi di DTO menghemat waktu — type safety, validasi otomatis, dan boundary yang jelas mencegah bug yang jauh lebih mahal di production.