DTO (Data Transfer Object): Panduan Lengkap dalam Arsitektur Backend
11 min read

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:#000
graph 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:#000

Dengan 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.

AspekDTOEntity
TujuanTransfer data antar boundaryRepresentasi tabel database
Business logicTidak adaIdealnya tidak ada
StabilitasRelatif stabil (kontrak API)Bisa berubah mengikuti schema
Digunakan diBoundary antar layerRepository dan database
ContohCreateUserRequest, UserResponsemodel.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:#000

Prinsip 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:#000

Handler

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:#000

Mengapa Adapter Dibutuhkan

Tanpa AdapterDengan 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:#000

Adapter vs Mapper vs Assembler

Tiga istilah ini sering membingungkan karena konteks penggunaannya berbeda. Secara fungsi, ketiganya melakukan hal yang sama — transformasi data antar representasi.

IstilahAsal KonteksPenggunaan Umum
AdapterClean ArchitecturePaling umum di Go dan general backend
MapperEnterprise Patterns (Fowler)Populer di Java/C# ecosystem
AssemblerDomain-Driven DesignKlasik 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:#000
graph 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:#000

Repository 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:#000
graph 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:#000

Entity 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.

OperasiKebutuhanContoh DTO
CreateSemua field requiredCreateUserRequest
UpdateField optional (pointer)UpdateUserRequest
ListPagination, filter, sortListUserRequest
ResponseField yang aman untuk clientUserResponse

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:#000

Contoh 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.