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:#fffThree pillars define DDA:
| Pillar | Explanation |
|---|---|
| Domain at the center | Business rules and business models live in the domain layer, not in the database or service layer |
| Ubiquitous Language | One shared language used by developers, domain experts, and stakeholders in code and discussion |
| Bounded Context | The 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"| REPORTINGInside 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| Aspect | DDA | Clean Architecture |
|---|---|---|
| Main focus | Modeling the business domain accurately | Organizing dependency direction between layers |
| Key concepts | Ubiquitous Language, Bounded Context, Aggregate | Dependency Rule, layer separation |
| Output | A rich domain model representing the business | A loosely coupled, testable codebase |
| Usable separately? | Yes | Yes |
| Together | DDA 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()notupdateStatus("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 happened —
LoanApprovedEvent,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.