Domain-Driven Architecture #

There is a paradox that often occurs in software development: a system that is technically excellent — clean code, high test coverage, superb performance — yet still fails to serve the business well. The engineering team talks about tables and columns, while the business team talks about customers and transactions. Every business requirement change takes a long time for engineers to understand, and every technical decision is hard to explain to stakeholders. This is not a code quality problem — it is a gap between the software model and business reality. Domain-Driven Architecture (DDA) exists to bridge that gap: by placing the business domain at the center of the architecture, building a shared language between developers and stakeholders, and ensuring every business rule is explicitly represented in code.

What Is Domain-Driven Architecture? #

Domain-Driven Architecture is an architectural approach originating from the principles of Domain-Driven Design (DDD) by Eric Evans. The core idea is simple but deep in its implications: the application’s structure should mirror the business model, not the database or framework structure.

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 from"| DO

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

Three pillars define DDA:

PillarExplanation
Domain at the centerBusiness rules and business models live in the domain layer, not in the database or service layer
Ubiquitous LanguageOne shared language used by developers, domain experts, and stakeholders in code and discussion
Bounded ContextThe system is split by business context boundaries, not by technical layers

Ubiquitous Language: The Language That Unites #

Ubiquitous Language is not just about good variable naming — it is a commitment that code must speak the same language as the business domain. If a domain expert says “loan approval”, the code must be ApproveLoan(), not updateStatus("approved").

// ✗ ANTI-PATTERN: technical language that does not represent the 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)
}

// ✓ CORRECT: Ubiquitous Language — code speaks the business language
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 also means the names used in code reviews, documentation, Jira tickets, and daily discussions must be the same. This reduces the cognitive translation cost incurred every time a developer reads a business specification.


DDD Building Blocks #

DDA uses a set of building blocks, each with a specific responsibility in representing the domain.

Entity #

An Entity is an object with a unique identity — two entities with the same ID are the same object, even if all their attributes differ. Entities are the primary place where business rules are defined.

package domain

import (
	"errors"
	"time"
)

// Loan is an entity with a unique identity
type Loan struct {
	id          LoanID
	applicantID ApplicantID
	amount      Money
	status      LoanStatus
	term        LoanTerm
	appliedAt   time.Time
	approvedBy  *ReviewerID
	approvedAt  *time.Time
	events      []DomainEvent
}

// LoanID is a strongly-typed ID — prevents bugs from misused strings
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 is a factory function enforcing creation invariants
func NewLoan(applicantID ApplicantID, amount Money, term LoanTerm) (*Loan, error) {
	if amount.IsZeroOrNegative() {
		return nil, errors.New("loan amount must be greater than zero")
	}
	if !term.IsValid() {
		return nil, errors.New("invalid loan term")
	}

	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 submits the loan for review — enforcing the business state machine
func (l *Loan) Submit() error {
	if l.status != LoanStatusDraft {
		return errors.New("only draft loans can be submitted")
	}
	l.status = LoanStatusPendingReview
	l.recordEvent(LoanSubmittedEvent{LoanID: l.id})
	return nil
}

// Approve approves the loan — contains the approval business rules
func (l *Loan) ApproveBy(reviewerID ReviewerID, maxAuthority Money) error {
	if l.status != LoanStatusPendingReview {
		return errors.New("only pending review loans can be approved")
	}
	if l.amount.GreaterThan(maxAuthority) {
		return errors.New("loan amount exceeds reviewer authority")
	}
	l.status = LoanStatusApproved
	l.approvedBy = &reviewerID
	now := time.Now()
	l.approvedAt = &now
	l.recordEvent(LoanApprovedEvent{LoanID: l.id, ReviewerID: reviewerID})
	return nil
}

// Reject rejects the loan with a reason
func (l *Loan) Reject(reviewerID ReviewerID, reason string) error {
	if l.status != LoanStatusPendingReview {
		return errors.New("only pending review loans can be rejected")
	}
	if reason == "" {
		return errors.New("rejection reason must be provided")
	}
	l.status = LoanStatusRejected
	l.recordEvent(LoanRejectedEvent{LoanID: l.id, ReviewerID: reviewerID, Reason: reason})
	return nil
}

// recordEvent adds a domain event to the queue
func (l *Loan) recordEvent(event DomainEvent) {
	l.events = append(l.events, event)
}

// PullEvents takes and clears the domain event queue
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 #

A Value Object has no identity — two Value Objects with the same values are the same object. They are always immutable: a “change” is done by creating a new Value Object.

// Money is a Value Object — no ID, compared by value
type Money struct {
	amount   int64  // in the smallest unit (cents)
	currency string
}

func NewMoney(amount int64, currency string) (Money, error) {
	if currency == "" {
		return Money{}, errors.New("currency must not be empty")
	}
	return Money{amount: amount, currency: currency}, nil
}

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

// Operations produce a new Money — immutable
func (m Money) Add(other Money) (Money, error) {
	if m.currency != other.currency {
		return Money{}, errors.New("cannot add different currencies")
	}
	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 is a Value Object for the loan term
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("term can only be 3, 6, 12, 24, or 36 months")
	}
	return LoanTerm{months: months}, nil
}

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

Aggregate #

An Aggregate is a cluster of entities and value objects treated as one unit for data changes. The Aggregate Root is the main entity controlling access to all aggregate members — changes can only happen through the Aggregate Root.

// Order is the Aggregate Root controlling OrderItem
type Order struct {
	id         OrderID
	customerID CustomerID
	items      []OrderItem // OrderItem can only be accessed through Order
	status     OrderStatus
	coupon     *Coupon    // optional Value Object
	events     []DomainEvent
}

// OrderItem is an entity inside the aggregate, but has no repository of its own
type OrderItem struct {
	productID ProductID
	quantity  int
	unitPrice Money
}

// AddItem can only be called through Order (the Aggregate Root)
// ✓ This preserves the invariant: total items must not exceed 50
func (o *Order) AddItem(productID ProductID, quantity int, unitPrice Money) error {
	if o.status != OrderStatusDraft {
		return errors.New("items can only be added to a draft order")
	}
	if len(o.items) >= 50 {
		return errors.New("order must not have more than 50 items")
	}
	if quantity <= 0 {
		return errors.New("quantity must be greater than 0")
	}

	// Check if the product already exists, add to its quantity
	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 is calculated from the existing items — business logic lives in the 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 #

A Domain Service is used for business logic that involves more than one aggregate or does not fit naturally in any single entity:

// LoanEligibilityService is a Domain Service
// This logic involves the Applicant and credit history — it does not fit in one entity
type LoanEligibilityService struct{}

func (s *LoanEligibilityService) CheckEligibility(
	applicant *Applicant,
	creditHistory *CreditHistory,
	requestedAmount Money,
) (*EligibilityResult, error) {
	// Business rules involving many domain concepts
	if applicant.Age() < 21 {
		return &EligibilityResult{Eligible: false, Reason: "minimum age is 21"}, nil
	}
	if creditHistory.HasDefaultInLast2Years() {
		return &EligibilityResult{Eligible: false, Reason: "has defaulted credit history"}, nil
	}
	maxLoanable := creditHistory.MaxLoanableAmount()
	if requestedAmount.GreaterThan(maxLoanable) {
		return &EligibilityResult{
			Eligible: false,
			Reason:   fmt.Sprintf("amount exceeds the limit of %s", maxLoanable),
		}, nil
	}
	return &EligibilityResult{Eligible: true}, nil
}

Domain Event #

A Domain Event represents something that happened in the business domain — not something you do, but something that has occurred:

// DomainEvent is the interface for all domain events
type DomainEvent interface {
	EventName() string
	OccurredAt() time.Time
}

// LoanApprovedEvent occurs when a loan is approved
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 occurs when a loan is rejected
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: Breaking Up a Large Domain #

Large systems have many sub-domains that may use the same term with different meanings. “Customer” in the marketing context differs from “Customer” in the billing context. A Bounded Context is the explicit boundary where one domain model applies.

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

Inside each Bounded Context, Loan can be represented differently according to that context’s needs — there is no need for a single model that satisfies every need at once.

// In the Lending context: Loan is a rich aggregate with business rules
type Loan struct { // domain/lending/loan.go
    id         LoanID
    applicant  *Applicant
    creditScore CreditScore
    repayment  *RepaymentSchedule
    // ... many business rules
}

// In the Reporting context: LoanSummary is a lightweight read model
type LoanSummary struct { // domain/reporting/loan_summary.go
    LoanID      string
    Amount      float64
    Status      string
    ApprovedAt  *time.Time
    // only the data needed for reports
}

// In the Notification context: LoanNotification is a model for messages
type LoanNotification struct { // domain/notification/loan_notification.go
    RecipientEmail string
    Subject        string
    Body           string
}

Directory Structure for DDA #

A folder structure that mirrors Bounded Contexts is more valuable than one based purely on technical layers:

myapp/
├── domain/
│   ├── lending/                  ← Bounded Context: Lending
│   │   ├── loan.go               ← Aggregate Root
│   │   ├── loan_item.go          ← Entity inside the 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/               ← Concrete implementations
│   ├── postgres/
│   │   ├── loan_repository.go
│   │   └── applicant_repository.go
│   └── kafka/
│       └── event_publisher.go
│
└── interfaces/                   ← Entry points (HTTP, gRPC, CLI)
    └── http/
        └── loan_handler.go

Anti-Pattern: The Anemic Domain Model #

The Anemic Domain Model is the most common DDA anti-pattern — entities contain only data (getters and setters), while all business logic lives in the service layer. This feels like OOP but is actually procedural code wrapped in classes.

// ✗ ANTI-PATTERN: Anemic Domain Model — entity is only data, logic in services
type Loan struct { // just a data container
    ID     string
    Status string
    Amount float64
}

type LoanService struct { // all logic here — the domain becomes a "database record"
    repo LoanRepository
}

func (s *LoanService) ApproveLoan(loanID, reviewerID string) error {
    loan, _ := s.repo.FindByID(loanID)
    if loan.Status != "pending_review" { // ✗ business rules in the service, not the entity
        return errors.New("invalid status")
    }
    loan.Status = "approved"             // ✗ direct mutation from outside
    loan.ReviewerID = reviewerID
    return s.repo.Update(loan)
}

// ✓ CORRECT: Rich Domain Model — business logic lives inside the entity
type Loan struct {
    id         LoanID
    status     LoanStatus
    amount     Money
    approvedBy *ReviewerID
}

// Approve is an entity method containing business rules
func (l *Loan) Approve(reviewerID ReviewerID, maxAuthority Money) error {
    if l.status != LoanStatusPendingReview {  // ✓ business rules inside the entity
        return ErrLoanNotPendingReview
    }
    if l.amount.GreaterThan(maxAuthority) {   // ✓ business validation inside the entity
        return ErrExceedsReviewerAuthority
    }
    l.status = LoanStatusApproved
    l.approvedBy = &reviewerID
    l.recordEvent(LoanApprovedEvent{LoanID: l.id})
    return nil
}

// The Application Service only orchestrates — it contains no business logic
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 }

    // Delegate to the entity — the service contains no business logic
    if err := loan.Approve(reviewer.ID(), reviewer.MaxAuthority()); err != nil {
        return err
    }

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

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

DDA vs Clean Architecture: A Frequently Misunderstood Relationship #

DDA and Clean Architecture are often considered competitors, when in fact they complement each other:

flowchart LR
    subgraph WHAT["DDA answers: WHAT is modeled"]
        UL["Ubiquitous Language"]
        BC["Bounded Context"]
        BB["Building Blocks\\nEntity, VO, Aggregate"]
        DE["Domain Events"]
    end

    subgraph HOW["Clean Architecture answers: HOW dependencies are organized"]
        DR["Dependency Rule"]
        LA["Layer separation"]
        DI["Dependency Injection"]
    end

    WHAT -->|"DDA defines the domain\\nClean Architecture organizes it"| HOW
AspectDDAClean Architecture
Main focusModeling the business domain accuratelyOrganizing dependency direction between layers
Key conceptsUbiquitous Language, Bounded Context, AggregateDependency Rule, layer separation
OutputA rich domain model representing the businessA loosely coupled, testable codebase
Usable separately?YesYes
TogetherDDA defines what lives in the domain layer; Clean Architecture organizes how the layers interact

When Not to Use DDA #

Use DDA if:
  ✓ The system has complex, continuously evolving business rules
  ✓ There are many domain-specific terms that need explicit modeling
  ✓ The team can communicate directly with domain experts
  ✓ The system is expected to live long (> 3 years)
  ✓ Use cases: fintech, complex e-commerce, ERP, healthcare, logistics

Consider a simpler approach if:
  ✗ The system is pure CRUD without significant business rules
  ✗ An MVP with a very tight timeline
  ✗ The team is not yet familiar with DDD concepts — high learning curve
  ✗ The domain is very stable and will not change
  ✗ Small team (< 3 developers) — the modeling overhead is not worth it

Domain-Driven Architecture Review Checklist #

UBIQUITOUS LANGUAGE:
  □ Class, method, and variable names reflect the business language
  □ No purely technical names in the domain layer (updateRecord, processData)
  □ Terms used in code are consistent with those used by the business team
  □ The domain glossary is documented and known by all team members

ENTITY:
  □ Entities have an explicit unique identity (strongly-typed ID)
  □ Business rules live inside the entity, not in the service layer
  □ State can only change through methods enforcing invariants
  □ Factory functions (NewX) enforce all creation validations

VALUE OBJECT:
  □ Value Objects are immutable — no setters, operations produce new objects
  □ Equality is by value, not by reference or ID
  □ Validation happens at construction time, not afterward

AGGREGATE:
  □ The Aggregate Root is clearly communicated
  □ Entities inside the aggregate are not accessed directly from outside
  □ Database transactions align with aggregate boundaries (one transaction = one aggregate)
  □ Aggregates are not too large (no more than 3–4 entities)

DOMAIN EVENT:
  □ Events are named in the past tense (LoanApproved, OrderPlaced)
  □ Events are published after the state change is successfully saved
  □ Events do not carry direct references to aggregates

BOUNDED CONTEXT:
  □ Boundaries between contexts are clearly defined
  □ Inter-context communication goes through events or an anti-corruption layer
  □ One domain model is not used across many contexts with different meanings

ANTI-PATTERNS:
  □ No anemic domain model (entities only data, logic in services)
  □ No God Object (one entity knowing about everything)
  □ Infrastructure does not leak into the domain layer

Summary #

  • DDA places the business domain at the center of the architecture — databases, frameworks, and infrastructure are implementation details; business rules are the primary asset to protect.
  • Ubiquitous Language is the foundation — one shared language used by developers and business stakeholders in code, documentation, and discussion; ApproveLoan() not updateStatus("approved").
  • Four main building blocks — Entity (has identity), Value Object (immutable, equality by value), Aggregate (a cluster with a Root controlling access), Domain Service (logic involving many aggregates).
  • Domain Events represent something that has happenedLoanApprovedEvent, OrderPlacedEvent; events enable loose coupling between bounded contexts and become a natural audit trail.
  • A Bounded Context is the explicit boundary of one domain model — “Customer” in billing differs from “Customer” in marketing; do not force one model for every context.
  • The Anemic Domain Model is the biggest anti-pattern — an entity with only getters/setters and a service containing all the logic looks like OOP but is actually procedural; business logic must live inside the entity.
  • The Aggregate Root controls all changes — entities inside the aggregate cannot be accessed directly from outside; this keeps business invariants always valid.
  • DDA and Clean Architecture complement each other — DDA defines what lives in the domain layer (Ubiquitous Language, Bounded Context, building blocks), Clean Architecture defines how the dependencies between layers are organized.
  • A long-term investment — DDA feels heavy at the start because it requires mature domain modeling and intensive communication with domain experts; the benefits only fully appear as the system grows.
  • Start with Ubiquitous Language — before writing a single line of code, sit down with the domain expert and build a glossary of business terms; good code is code that business people can read.

← Previous: Clean Architecture   Next: Hexagonal →

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