Domain-Driven Architecture #

Ada sebuah paradoks yang sering terjadi dalam pengembangan software: sistem yang secara teknis bagus — kode bersih, test coverage tinggi, performa excellent — tapi tetap gagal melayani bisnis dengan baik. Tim engineering berbicara tentang tabel dan kolom, sementara tim bisnis berbicara tentang pelanggan dan transaksi. Setiap perubahan kebutuhan bisnis membutuhkan waktu lama untuk dipahami engineer, dan setiap keputusan teknis sulit dijelaskan ke stakeholder. Ini bukan masalah kualitas kode — ini masalah kesenjangan antara model software dan realitas bisnis. Domain-Driven Architecture (DDA) hadir untuk menjembatani kesenjangan itu: dengan menempatkan domain bisnis sebagai pusat arsitektur, membangun bahasa yang sama antara developer dan stakeholder, dan memastikan bahwa setiap aturan bisnis terwakili secara eksplisit dalam kode.

Apa itu Domain-Driven Architecture? #

Domain-Driven Architecture adalah pendekatan arsitektur yang berasal dari prinsip Domain-Driven Design (DDD) karya Eric Evans. Ide intinya sederhana tapi implikasinya dalam: struktur aplikasi harus mencerminkan model bisnis, bukan struktur database atau framework.

flowchart TD
    subgraph DDA["Domain-Driven Architecture"]
        PR["Presentation Layer\nAPI, UI, Controller"]
        AP["Application Layer\nUse Case, Orchestration"]
        DO["Domain Layer\nEntity, Value Object, Aggregate\nDomain Service, Domain Event"]
        IN["Infrastructure Layer\nDB, Message Broker, External API"]
    end

    PR -->|"depends on"| AP
    AP -->|"depends on"| DO
    IN -->|"implements interfaces dari"| DO

    style DO fill:#2d6a4f,color:#fff

Tiga pilar yang mendefinisikan DDA:

Pilar Penjelasan
Domain sebagai pusat Business rule dan model bisnis ada di layer domain, bukan di database atau service layer
Ubiquitous Language Satu bahasa yang sama dipakai oleh developer, domain expert, dan stakeholder dalam kode dan diskusi
Bounded Context Sistem dipecah berdasarkan batas konteks bisnis, bukan berdasarkan layer teknikal

Ubiquitous Language: Bahasa yang Menyatukan #

Ubiquitous Language bukan hanya soal penamaan variabel yang baik — ini adalah komitmen bahwa kode harus berbicara dalam bahasa yang sama dengan domain bisnis. Jika domain expert menyebut “persetujuan pinjaman”, kodenya harus ApproveLoan(), bukan updateStatus("approved").

// ✗ ANTI-PATTERN: bahasa teknikal yang tidak merepresentasikan domain
func (s *LoanService) UpdateLoanRecord(id string, status int, reviewerID string) error {
    return s.db.Exec("UPDATE loans SET status = $1, reviewer = $2 WHERE id = $3",
        status, reviewerID, id)
}

// ✓ BENAR: Ubiquitous Language — kode berbicara bahasa bisnis
func (l *Loan) ApproveBy(reviewer *Reviewer) error {
    if l.status != LoanStatusPendingReview {
        return ErrLoanNotPendingReview
    }
    if !reviewer.HasAuthority(l.amount) {
        return ErrInsufficientReviewerAuthority
    }
    l.status = LoanStatusApproved
    l.approvedBy = reviewer.ID()
    l.approvedAt = time.Now()
    l.recordEvent(LoanApprovedEvent{LoanID: l.id, ReviewerID: reviewer.ID()})
    return nil
}

Ubiquitous Language juga berarti bahwa nama yang digunakan dalam code review, dokumentasi, tiket Jira, dan diskusi harian harus sama. Ini mengurangi biaya terjemahan kognitif yang terjadi setiap kali developer membaca spesifikasi bisnis.


Building Blocks DDD #

DDA menggunakan sekumpulan building block yang masing-masing punya tanggung jawab spesifik dalam merepresentasikan domain.

Entity #

Entity adalah objek yang punya identitas unik — dua entity dengan ID yang sama adalah objek yang sama, meskipun semua atributnya berbeda. Entity adalah tempat utama di mana aturan bisnis didefinisikan.

package domain

import (
	"errors"
	"time"
)

// Loan adalah entity dengan identitas unik
type Loan struct {
	id          LoanID
	applicantID ApplicantID
	amount      Money
	status      LoanStatus
	term        LoanTerm
	appliedAt   time.Time
	approvedBy  *ReviewerID
	approvedAt  *time.Time
	events      []DomainEvent
}

// LoanID adalah strongly-typed ID — mencegah bug akibat string yang salah dipakai
type LoanID string
type ApplicantID string
type ReviewerID string

type LoanStatus string
const (
	LoanStatusDraft         LoanStatus = "draft"
	LoanStatusPendingReview LoanStatus = "pending_review"
	LoanStatusApproved      LoanStatus = "approved"
	LoanStatusRejected      LoanStatus = "rejected"
	LoanStatusDisbursed     LoanStatus = "disbursed"
)

// NewLoan adalah factory function yang menegakkan invariant pembuatan
func NewLoan(applicantID ApplicantID, amount Money, term LoanTerm) (*Loan, error) {
	if amount.IsZeroOrNegative() {
		return nil, errors.New("jumlah pinjaman harus lebih dari nol")
	}
	if !term.IsValid() {
		return nil, errors.New("tenor pinjaman tidak valid")
	}

	loan := &Loan{
		id:          LoanID(generateID()),
		applicantID: applicantID,
		amount:      amount,
		status:      LoanStatusDraft,
		term:        term,
		appliedAt:   time.Now(),
	}
	loan.recordEvent(LoanCreatedEvent{LoanID: loan.id})
	return loan, nil
}

// Submit mengajukan pinjaman untuk di-review — menegakkan state machine bisnis
func (l *Loan) Submit() error {
	if l.status != LoanStatusDraft {
		return errors.New("hanya pinjaman draft yang bisa diajukan")
	}
	l.status = LoanStatusPendingReview
	l.recordEvent(LoanSubmittedEvent{LoanID: l.id})
	return nil
}

// Approve menyetujui pinjaman — mengandung aturan bisnis persetujuan
func (l *Loan) ApproveBy(reviewerID ReviewerID, maxAuthority Money) error {
	if l.status != LoanStatusPendingReview {
		return errors.New("hanya pinjaman pending review yang bisa disetujui")
	}
	if l.amount.GreaterThan(maxAuthority) {
		return errors.New("jumlah pinjaman melebihi wewenang reviewer")
	}
	l.status = LoanStatusApproved
	l.approvedBy = &reviewerID
	now := time.Now()
	l.approvedAt = &now
	l.recordEvent(LoanApprovedEvent{LoanID: l.id, ReviewerID: reviewerID})
	return nil
}

// Reject menolak pinjaman dengan alasan
func (l *Loan) Reject(reviewerID ReviewerID, reason string) error {
	if l.status != LoanStatusPendingReview {
		return errors.New("hanya pinjaman pending review yang bisa ditolak")
	}
	if reason == "" {
		return errors.New("alasan penolakan harus diisi")
	}
	l.status = LoanStatusRejected
	l.recordEvent(LoanRejectedEvent{LoanID: l.id, ReviewerID: reviewerID, Reason: reason})
	return nil
}

// recordEvent menambahkan domain event ke antrian
func (l *Loan) recordEvent(event DomainEvent) {
	l.events = append(l.events, event)
}

// PullEvents mengambil dan mengosongkan antrian domain events
func (l *Loan) PullEvents() []DomainEvent {
	events := l.events
	l.events = nil
	return events
}

// Getter methods
func (l *Loan) ID() LoanID         { return l.id }
func (l *Loan) Status() LoanStatus { return l.status }
func (l *Loan) Amount() Money      { return l.amount }

Value Object #

Value Object tidak punya identitas — dua Value Object dengan nilai yang sama adalah objek yang sama. Mereka selalu immutable: “perubahan” dilakukan dengan membuat Value Object baru.

// Money adalah Value Object — tidak punya ID, dibandingkan berdasarkan nilai
type Money struct {
	amount   int64  // dalam satuan terkecil (sen)
	currency string
}

func NewMoney(amount int64, currency string) (Money, error) {
	if currency == "" {
		return Money{}, errors.New("currency tidak boleh kosong")
	}
	return Money{amount: amount, currency: currency}, nil
}

func (m Money) Amount() int64    { return m.amount }
func (m Money) Currency() string { return m.currency }

// Operasi menghasilkan Money baru — immutable
func (m Money) Add(other Money) (Money, error) {
	if m.currency != other.currency {
		return Money{}, errors.New("tidak bisa menjumlahkan mata uang berbeda")
	}
	return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

func (m Money) IsZeroOrNegative() bool { return m.amount <= 0 }
func (m Money) GreaterThan(other Money) bool {
	return m.currency == other.currency && m.amount > other.amount
}
func (m Money) Equals(other Money) bool {
	return m.amount == other.amount && m.currency == other.currency
}

// LoanTerm adalah Value Object untuk tenor pinjaman
type LoanTerm struct {
	months int
}

func NewLoanTerm(months int) (LoanTerm, error) {
	validTerms := map[int]bool{3: true, 6: true, 12: true, 24: true, 36: true}
	if !validTerms[months] {
		return LoanTerm{}, errors.New("tenor hanya boleh 3, 6, 12, 24, atau 36 bulan")
	}
	return LoanTerm{months: months}, nil
}

func (t LoanTerm) Months() int  { return t.months }
func (t LoanTerm) IsValid() bool { return t.months > 0 }

Aggregate #

Aggregate adalah cluster entity dan value object yang diperlakukan sebagai satu unit untuk perubahan data. Aggregate Root adalah entity utama yang mengontrol akses ke semua anggota aggregate — perubahan hanya bisa terjadi melalui Aggregate Root.

// Order adalah Aggregate Root yang mengontrol OrderItem
type Order struct {
	id         OrderID
	customerID CustomerID
	items      []OrderItem // OrderItem hanya bisa diakses lewat Order
	status     OrderStatus
	coupon     *Coupon    // Value Object opsional
	events     []DomainEvent
}

// OrderItem adalah entity di dalam aggregate, tapi tidak punya repository sendiri
type OrderItem struct {
	productID ProductID
	quantity  int
	unitPrice Money
}

// AddItem hanya bisa dipanggil lewat Order (Aggregate Root)
// ✓ Ini menjaga invariant: total item tidak boleh lebih dari 50
func (o *Order) AddItem(productID ProductID, quantity int, unitPrice Money) error {
	if o.status != OrderStatusDraft {
		return errors.New("item hanya bisa ditambahkan ke order draft")
	}
	if len(o.items) >= 50 {
		return errors.New("order tidak boleh memiliki lebih dari 50 item")
	}
	if quantity <= 0 {
		return errors.New("quantity harus lebih dari 0")
	}

	// Cek jika produk sudah ada, tambahkan quantity-nya
	for i, item := range o.items {
		if item.productID == productID {
			o.items[i].quantity += quantity
			return nil
		}
	}

	o.items = append(o.items, OrderItem{
		productID: productID,
		quantity:  quantity,
		unitPrice: unitPrice,
	})
	return nil
}

// Total dihitung dari item-item yang ada — logika bisnis ada di entity
func (o *Order) Total() Money {
	total := Money{currency: "IDR"}
	for _, item := range o.items {
		total.amount += item.unitPrice.amount * int64(item.quantity)
	}
	if o.coupon != nil {
		total = o.coupon.Apply(total)
	}
	return total
}

Domain Service #

Domain Service digunakan untuk logika bisnis yang melibatkan lebih dari satu aggregate atau tidak cocok ditempatkan di entity manapun:

// LoanEligibilityService adalah Domain Service
// Logika ini melibatkan Applicant dan credit history — tidak cocok di satu entity
type LoanEligibilityService struct{}

func (s *LoanEligibilityService) CheckEligibility(
	applicant *Applicant,
	creditHistory *CreditHistory,
	requestedAmount Money,
) (*EligibilityResult, error) {
	// Aturan bisnis yang melibatkan banyak konsep domain
	if applicant.Age() < 21 {
		return &EligibilityResult{Eligible: false, Reason: "usia minimum 21 tahun"}, nil
	}
	if creditHistory.HasDefaultInLast2Years() {
		return &EligibilityResult{Eligible: false, Reason: "ada riwayat kredit macet"}, nil
	}
	maxLoanable := creditHistory.MaxLoanableAmount()
	if requestedAmount.GreaterThan(maxLoanable) {
		return &EligibilityResult{
			Eligible: false,
			Reason:   fmt.Sprintf("jumlah melebihi batas %s", maxLoanable),
		}, nil
	}
	return &EligibilityResult{Eligible: true}, nil
}

Domain Event #

Domain Event merepresentasikan sesuatu yang terjadi di domain bisnis — bukan sesuatu yang kamu lakukan, tapi sesuatu yang telah terjadi:

// DomainEvent adalah interface untuk semua domain event
type DomainEvent interface {
	EventName() string
	OccurredAt() time.Time
}

// LoanApprovedEvent terjadi ketika pinjaman disetujui
type LoanApprovedEvent struct {
	LoanID     LoanID
	ReviewerID ReviewerID
	occurredAt time.Time
}

func (e LoanApprovedEvent) EventName() string    { return "loan.approved" }
func (e LoanApprovedEvent) OccurredAt() time.Time { return e.occurredAt }

// LoanRejectedEvent terjadi ketika pinjaman ditolak
type LoanRejectedEvent struct {
	LoanID     LoanID
	ReviewerID ReviewerID
	Reason     string
	occurredAt time.Time
}

func (e LoanRejectedEvent) EventName() string    { return "loan.rejected" }
func (e LoanRejectedEvent) OccurredAt() time.Time { return e.occurredAt }

Bounded Context: Memecah Domain yang Besar #

Sistem besar memiliki banyak sub-domain yang mungkin menggunakan istilah yang sama dengan arti berbeda. “Customer” di konteks marketing berbeda dengan “Customer” di konteks billing. Bounded Context adalah batas eksplisit di mana satu model domain berlaku.

flowchart TD
    subgraph LENDING["Bounded Context: Lending"]
        LA["Loan Aggregate\nApplicant, CreditScore\nRepaymentSchedule"]
    end

    subgraph NOTIFICATION["Bounded Context: Notification"]
        NA["Recipient\nMessage, Channel\nDeliveryStatus"]
    end

    subgraph REPORTING["Bounded Context: Reporting"]
        RA["LoanSummary\nApprovalRate\nPortfolioStats"]
    end

    LENDING -->|"LoanApprovedEvent"| MB[(Message Broker)]
    MB -->|"LoanApprovedEvent"| NOTIFICATION
    MB -->|"LoanApprovedEvent"| REPORTING

Di dalam setiap Bounded Context, Loan bisa direpresentasikan dengan cara yang berbeda sesuai kebutuhan konteks tersebut — tidak perlu satu model tunggal yang memenuhi semua kebutuhan sekaligus.

// Di Lending context: Loan adalah aggregate kaya dengan business rules
type Loan struct { // domain/lending/loan.go
    id         LoanID
    applicant  *Applicant
    creditScore CreditScore
    repayment  *RepaymentSchedule
    // ... banyak aturan bisnis
}

// Di Reporting context: LoanSummary adalah read model yang ringan
type LoanSummary struct { // domain/reporting/loan_summary.go
    LoanID      string
    Amount      float64
    Status      string
    ApprovedAt  *time.Time
    // hanya data yang dibutuhkan untuk laporan
}

// Di Notification context: LoanNotification adalah model untuk pesan
type LoanNotification struct { // domain/notification/loan_notification.go
    RecipientEmail string
    Subject        string
    Body           string
}

Struktur Direktori untuk DDA #

Struktur folder yang mencerminkan Bounded Context lebih dari sekadar layer teknikal:

myapp/
├── domain/
│   ├── lending/                  ← Bounded Context: Lending
│   │   ├── loan.go               ← Aggregate Root
│   │   ├── loan_item.go          ← Entity dalam aggregate
│   │   ├── applicant.go          ← Entity
│   │   ├── money.go              ← Value Object
│   │   ├── loan_term.go          ← Value Object
│   │   ├── eligibility_service.go ← Domain Service
│   │   ├── events.go             ← Domain Events
│   │   └── repository.go         ← Repository interfaces
│   │
│   ├── notification/             ← Bounded Context: Notification
│   │   ├── recipient.go
│   │   ├── message.go
│   │   └── repository.go
│   │
│   └── reporting/                ← Bounded Context: Reporting
│       ├── loan_summary.go
│       └── repository.go
│
├── application/                  ← Application Services (Use Cases)
│   ├── lending/
│   │   ├── apply_loan.go
│   │   ├── approve_loan.go
│   │   └── reject_loan.go
│   └── notification/
│       └── send_loan_notification.go
│
├── infrastructure/               ← Implementasi konkret
│   ├── postgres/
│   │   ├── loan_repository.go
│   │   └── applicant_repository.go
│   └── kafka/
│       └── event_publisher.go
│
└── interfaces/                   ← Entry points (HTTP, gRPC, CLI)
    └── http/
        └── loan_handler.go

Anti-Pattern: Anemic Domain Model #

Anemic Domain Model adalah anti-pattern paling umum dalam DDA — entity hanya berisi data (getter dan setter), sementara semua logika bisnis ada di service layer. Ini terasa seperti OOP tapi sebenarnya hanya prosedural yang dibungkus class.

// ✗ ANTI-PATTERN: Anemic Domain Model — entity hanya data, logika di service
type Loan struct { // hanya data container
    ID     string
    Status string
    Amount float64
}

type LoanService struct { // semua logika di sini — domain jadi "database record"
    repo LoanRepository
}

func (s *LoanService) ApproveLoan(loanID, reviewerID string) error {
    loan, _ := s.repo.FindByID(loanID)
    if loan.Status != "pending_review" { // ✗ aturan bisnis di service, bukan entity
        return errors.New("invalid status")
    }
    loan.Status = "approved"             // ✗ mutasi langsung dari luar
    loan.ReviewerID = reviewerID
    return s.repo.Update(loan)
}

// ✓ BENAR: Rich Domain Model — logika bisnis ada di dalam entity
type Loan struct {
    id         LoanID
    status     LoanStatus
    amount     Money
    approvedBy *ReviewerID
}

// Approve adalah method entity yang mengandung aturan bisnis
func (l *Loan) Approve(reviewerID ReviewerID, maxAuthority Money) error {
    if l.status != LoanStatusPendingReview {  // ✓ aturan bisnis di dalam entity
        return ErrLoanNotPendingReview
    }
    if l.amount.GreaterThan(maxAuthority) {   // ✓ validasi bisnis di dalam entity
        return ErrExceedsReviewerAuthority
    }
    l.status = LoanStatusApproved
    l.approvedBy = &reviewerID
    l.recordEvent(LoanApprovedEvent{LoanID: l.id})
    return nil
}

// Application Service hanya mengorkestrasikan — tidak mengandung logika bisnis
type ApproveLoanService struct {
    loanRepo     LoanRepository
    reviewerRepo ReviewerRepository
    eventBus     EventBus
}

func (s *ApproveLoanService) Execute(ctx context.Context, loanID, reviewerID string) error {
    loan, err := s.loanRepo.FindByID(ctx, LoanID(loanID))
    if err != nil { return err }

    reviewer, err := s.reviewerRepo.FindByID(ctx, ReviewerID(reviewerID))
    if err != nil { return err }

    // Delegasikan ke entity — service tidak mengandung logika bisnis
    if err := loan.Approve(reviewer.ID(), reviewer.MaxAuthority()); err != nil {
        return err
    }

    if err := s.loanRepo.Save(ctx, loan); err != nil { return err }

    // Publikasikan domain events
    for _, event := range loan.PullEvents() {
        s.eventBus.Publish(ctx, event)
    }
    return nil
}

DDA vs Clean Architecture: Hubungan yang Sering Disalahartikan #

DDA dan Clean Architecture sering dianggap bersaing, padahal keduanya saling melengkapi:

flowchart LR
    subgraph WHAT["DDA menjawab: APA yang dimodelkan"]
        UL["Ubiquitous Language"]
        BC["Bounded Context"]
        BB["Building Blocks\nEntity, VO, Aggregate"]
        DE["Domain Events"]
    end

    subgraph HOW["Clean Architecture menjawab: BAGAIMANA dependency diatur"]
        DR["Dependency Rule"]
        LA["Layer separation"]
        DI["Dependency Injection"]
    end

    WHAT -->|"DDA mendefinisikan domain\nClean Architecture mengorganisirnya"| HOW
Aspek DDA Clean Architecture
Fokus utama Memodelkan domain bisnis dengan akurat Mengatur arah dependency antar layer
Konsep kunci Ubiquitous Language, Bounded Context, Aggregate Dependency Rule, layer separation
Output Rich domain model yang merepresentasikan bisnis Codebase yang loosely coupled dan testable
Bisa dipakai terpisah? Ya Ya
Bersama-sama DDA mendefinisikan apa yang ada di domain layer, Clean Architecture mengorganisir bagaimana layer berinteraksi

Kapan Tidak Menggunakan DDA #

Gunakan DDA jika:
  ✓ Sistem memiliki aturan bisnis yang kompleks dan terus berkembang
  ✓ Ada banyak istilah domain spesifik yang perlu dimodelkan secara eksplisit
  ✓ Tim bisa berkomunikasi langsung dengan domain expert
  ✓ Sistem diperkirakan berumur panjang (> 3 tahun)
  ✓ Use case: fintech, e-commerce kompleks, ERP, healthcare, logistics

Pertimbangkan pendekatan lebih sederhana jika:
  ✗ Sistem adalah CRUD murni tanpa aturan bisnis yang signifikan
  ✗ MVP dengan timeline sangat ketat
  ✗ Tim belum familiar dengan konsep DDD — learning curve tinggi
  ✗ Domain sangat stabil dan tidak akan berubah
  ✗ Tim kecil (< 3 developer) — overhead modeling tidak sebanding

Checklist Review Domain-Driven Architecture #

UBIQUITOUS LANGUAGE:
  □ Nama class, method, dan variable mencerminkan bahasa bisnis
  □ Tidak ada nama teknikal murni di domain layer (updateRecord, processData)
  □ Istilah yang dipakai dalam kode konsisten dengan yang dipakai tim bisnis
  □ Glossary domain didokumentasikan dan diketahui semua anggota tim

ENTITY:
  □ Entity punya identitas unik yang eksplisit (strongly-typed ID)
  □ Aturan bisnis ada di dalam entity, bukan di service layer
  □ State hanya bisa diubah lewat method yang menegakkan invariant
  □ Factory function (NewX) menegakkan semua validasi pembuatan

VALUE OBJECT:
  □ Value Object immutable — tidak ada setter, operasi menghasilkan objek baru
  □ Equality berdasarkan nilai, bukan referensi atau ID
  □ Validasi dilakukan saat konstruksi, tidak setelahnya

AGGREGATE:
  □ Aggregate Root dikomunikasikan dengan jelas
  □ Entity di dalam aggregate tidak diakses langsung dari luar
  □ Transaksi database selaras dengan batas aggregate (satu transaksi = satu aggregate)
  □ Aggregate tidak terlalu besar (tidak punya lebih dari 3–4 entity)

DOMAIN EVENT:
  □ Event dinamakan dengan past tense (LoanApproved, OrderPlaced)
  □ Event dipublikasikan setelah perubahan state berhasil disimpan
  □ Event tidak membawa referensi ke aggregate langsung

BOUNDED CONTEXT:
  □ Boundary antar context didefinisikan dengan jelas
  □ Komunikasi antar context melalui event atau anti-corruption layer
  □ Satu model domain tidak dipakai lintas banyak context dengan arti berbeda

ANTI-PATTERN:
  □ Tidak ada anemic domain model (entity hanya data, logik di service)
  □ Tidak ada God Object (satu entity yang tahu tentang semua)
  □ Infrastructure tidak bocor ke domain layer

Ringkasan #

  • DDA menempatkan domain bisnis sebagai pusat arsitektur — database, framework, dan infrastruktur adalah detail implementasi; business rule adalah aset utama yang harus dilindungi.
  • Ubiquitous Language adalah fondasi — satu bahasa yang sama dipakai developer dan stakeholder bisnis dalam kode, dokumentasi, dan diskusi; ApproveLoan() bukan updateStatus("approved").
  • Empat building block utama — Entity (punya identitas), Value Object (immutable, equality by value), Aggregate (cluster dengan Root yang mengontrol akses), Domain Service (logika yang melibatkan banyak aggregate).
  • Domain Event merepresentasikan sesuatu yang telah terjadiLoanApprovedEvent, OrderPlacedEvent; event memungkinkan loose coupling antar bounded context dan menjadi audit trail alami.
  • Bounded Context adalah batas eksplisit satu model domain — “Customer” di billing berbeda dengan “Customer” di marketing; jangan paksakan satu model untuk semua konteks.
  • Anemic Domain Model adalah anti-pattern terbesar — entity yang hanya berisi getter/setter dan service yang mengandung semua logika terlihat seperti OOP tapi sebenarnya prosedural; logika bisnis harus ada di dalam entity.
  • Aggregate Root mengontrol semua perubahan — entity di dalam aggregate tidak bisa diakses langsung dari luar; ini menjaga invariant bisnis selalu valid.
  • DDA dan Clean Architecture saling melengkapi — DDA mendefinisikan apa yang ada di domain layer (Ubiquitous Language, Bounded Context, building blocks), Clean Architecture mendefinisikan bagaimana dependency antar layer diatur.
  • Investasi jangka panjang — DDA terasa berat di awal karena membutuhkan pemodelan domain yang matang dan komunikasi intensif dengan domain expert; manfaatnya baru terasa optimal setelah sistem berkembang.
  • Mulai dari Ubiquitous Language — sebelum menulis satu baris kode, duduk bersama domain expert dan bangun glossary istilah bisnis; kode yang baik adalah kode yang bisa dibaca oleh orang bisnis.

← Sebelumnya: Clean Architecture   Berikutnya: Hexagonal Architecture →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact