DTO (Data Transfer Object) dalam Arsitektur Backend
DTO (Data Transfer Object) adalah salah satu konsep paling penting—dan paling sering disalahpahami—dalam pengembangan backend modern. Banyak kode menjadi sulit dirawat, rapuh, dan bocor antar layer bukan karena business logic yang kompleks, tapi karena DTO digunakan secara sembarangan.
Artikel ini akan membahas secara mendetail dan praktis:
- Apa itu DTO dan kenapa dibutuhkan
- Jenis-jenis DTO (Request, Query Result, Response)
- DTO digunakan di layer mana
- Contoh implementasi nyata (CRUD + raw query)
- Anti-pattern yang sering terjadi
Fokus artikel ini adalah backend service (REST/gRPC), dengan contoh utama menggunakan Golang dan repository pattern.
Apa Itu DTO?
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.
Karakteristik DTO:
- Struktur sederhana (struct / class)
- Tidak punya behavior
- Digunakan sebagai kontrak data
Kenapa DTO Penting?
Tanpa DTO:
- Handler langsung pakai model database
- Repository tahu detail HTTP
- Perubahan schema DB memecahkan API
Dengan DTO:
- Boundary antar layer jelas
- API stabil walau DB berubah
- Lebih aman dan maintainable
Layer dalam Backend (Konteks DTO)
Struktur umum:
Handler (HTTP / gRPC)
↓
Service (Business Logic)
↓
Repository (Data Access)
↓
Database
DTO selalu berada di boundary antar layer, bukan di tengah-tengah logic.
Jenis-Jenis DTO
Request DTO
Request DTO merepresentasikan input dari client.
Tujuan
- Mendefinisikan apa yang BOLEH dikirim client
- Validasi input
- Melindungi domain dari data liar
Digunakan di Layer
- Handler
- Service (sebagai parameter)
Contoh: Create Request DTO
type CreateUserRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
}
Contoh: Update Request DTO
type UpdateUserRequest struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
}
Pointer digunakan agar bisa membedakan:
nil→ tidak diupdate""→ update ke string kosong
List / Query Request DTO
DTO ini digunakan untuk:
- Filtering
- Sorting
- Pagination
Digunakan di Layer
- Handler
- Service
Contoh: List Request DTO
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"`
}
DTO ini adalah kontrak API, bukan kontrak database.
Query Result DTO (Raw Query DTO)
Ini adalah DTO yang sering terlupakan, padahal sangat penting.
Query Result DTO digunakan untuk:
- Menampung hasil
raw SQL - Join kompleks
- Aggregation (COUNT, SUM, GROUP BY)
Kenapa Tidak Pakai Entity?
- Field tidak 1:1 dengan tabel
- Bisa gabungan banyak tabel
- Bisa computed field
Digunakan di Layer
- Repository (output)
- Service (input)
Contoh: Query Result DTO
type UserStatsRow struct {
UserID uint `db:"user_id"`
Name string `db:"name"`
TotalOrders int `db:"total_orders"`
}
Contoh Repository dengan Raw Query
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
}
⚠️ Jangan memaksakan hasil raw query masuk ke entity.
Response DTO
Response DTO merepresentasikan data yang dikirim ke client.
Tujuan
- Menyaring field sensitif
- Format data untuk API
- Menjaga backward compatibility
Digunakan di Layer
- Service
- Handler
Contoh: Response DTO
type UserResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
Contoh Flow Lengkap (Create User)
Handler
var req dto.CreateUserRequest
if err := c.BodyParser(&req); err != nil {
return err
}
user, err := userService.Create(ctx, req)
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
}
DTO vs Entity (Ringkasan Penting)
| Aspek | DTO | Entity |
|---|---|---|
| Tujuan | Transfer data | Representasi DB |
| Logic | ❌ Tidak ada | ❌ (idealnya) |
| Stabil | Relatif stabil | Bisa berubah |
| Digunakan | Boundary | Repository |
Anti-Pattern yang Harus Dihindari
❌ Repository menerima Request DTO
❌ Handler mengembalikan Entity langsung
❌ Satu DTO untuk Create, Update, dan List
❌ Raw query langsung scan ke entity
DTO Adapter (Mapper) untuk Scalability
Seiring bertambahnya kompleksitas sistem, proses mapping antara DTO ↔ Entity ↔ Query Result akan semakin sering dan semakin kompleks. Di sinilah Adapter (atau Mapper) berperan penting.
Apa Itu DTO Adapter?
DTO Adapter adalah komponen khusus yang bertugas:
- Mengubah Request DTO → Entity / Command
- Mengubah Entity / Query Result → Response DTO
Adapter tidak berisi business logic, hanya transformasi data.
DTO ↔ Adapter ↔ Domain / Entity
Kenapa Adapter Dibutuhkan untuk Scalability?
Tanpa adapter:
- Mapping tersebar di service
- Service menjadi gemuk
- Sulit refactor atau reuse
Dengan adapter:
- Service fokus ke business logic
- Mapping konsisten & terpusat
- Mudah menambah variasi response
| Tanpa Adapter | Dengan Adapter |
|---|---|
| Service gemuk | Service tipis |
| Mapping duplikatif | Mapping terpusat |
| Sulit reuse | Mudah reuse |
Kapan Adapter Mulai Wajib Digunakan?
Adapter sangat direkomendasikan jika:
- DTO dan Entity mulai tidak 1:1
- Banyak variasi response
- Raw query makin kompleks
- Domain logic berkembang
Pada project kecil, mapping langsung di service masih wajar.
Contoh Adapter: Request DTO → Entity
type UserAdapter struct{}
func (a UserAdapter) ToEntity(req dto.CreateUserRequest) model.User {
return model.User{
Name: req.Name,
Email: req.Email,
}
}
Contoh Adapter: Entity → Response DTO
func (a UserAdapter) ToResponse(user model.User) dto.UserResponse {
return dto.UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
}
}
Contoh Adapter: Query Result DTO → Response DTO
func (a UserAdapter) StatsToResponse(row 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 tetap bersih, fokus, dan scalable.
Adapter vs Mapper vs Assembler
Secara konsep:
- Adapter → istilah umum (clean architecture)
- Mapper → istilah populer di backend
- Assembler → istilah klasik DDD
Secara fungsi, tujuannya sama.
Anti-Pattern Adapter ❌
- Adapter mengandung business logic
- Adapter memanggil repository
- Adapter melakukan validasi
Adapter hanya untuk transformasi data.
Posisi Adapter dalam Layer
Handler
↓
DTO
↓
Adapter ← HERE
↓
Service
↓
Repository
Penutup
- DTO adalah kontrak antar layer
- Setiap jenis operasi butuh DTO yang berbeda
- Request, Query Result, dan Response DTO punya tanggung jawab masing-masing
- Repository tidak tahu HTTP
- Service adalah tempat mapping DTO ↔ Entity
DTO yang dirancang dengan baik membuat sistem lebih aman, scalable, dan mudah dirawat.