Domain-Driven Design: A Comprehensive Technical Guide for Modern Go Applications
Introduction
As software systems grow in complexity, maintaining a clear, maintainable, and scalable architecture becomes increasingly challenging. Domain-Driven Design (DDD) offers a structured approach to tackle this complexity by aligning software design with business domains. Having implemented DDD across multiple Go projects, I've found it to be transformative in how engineering teams approach complex business problems.
This article provides a comprehensive technical exploration of DDD principles and patterns in the context of Go applications. Drawing from practical implementation experience, I'll walk through the fundamental concepts, architectural approaches, and implementation techniques that make DDD effective in real-world projects.
What is Domain-Driven Design?
Domain-Driven Design is both a design philosophy and a set of technical practices aimed at creating software that accurately models the business domain it serves. Introduced by Eric Evans in his seminal book "Domain-Driven Design: Tackling Complexity in the Heart of Software," DDD provides a framework for developing complex systems where the business domain is the central focus.
At its core, DDD emphasizes:
- Focus on the core domain and domain logic
- Basing complex designs on a model of the domain
- Collaboration between technical and domain experts
- Iteratively refining the domain model
Key Technical Components of DDD
Ubiquitous Language
The foundation of DDD is establishing a common language that is used consistently within a bounded context by both domain experts and developers. This isn't just a glossary - it's a living language embedded in code, documentation, and conversation.
In Go, we implement ubiquitous language through carefully named types, methods, and packages:
// Example of ubiquitous language in Go code
type Account struct {
ID AccountID
Owner CustomerID
Balance Money
AccountStatus Status
Type AccountType
CreatedAt time.Time
}
// Methods use domain terminology
func (a *Account) Deposit(amount Money) error {
if !a.IsActive() {
return ErrInactiveAccount
}
a.Balance = a.Balance.Add(amount)
return nil
}
func (a *Account) Withdraw(amount Money) error {
if !a.IsActive() {
return ErrInactiveAccount
}
if a.Balance.LessThan(amount) {
return ErrInsufficientFunds
}
a.Balance = a.Balance.Subtract(amount)
return nil
}
func (a *Account) IsActive() bool {
return a.AccountStatus == StatusActive
}
The code above illustrates how domain concepts like Account, Deposit, and Withdraw are directly represented in the codebase, creating a shared language between technical and business stakeholders.
Value Objects
Value objects are immutable objects that don't have identity. They're defined by their attributes rather than by who they are. In Go, we can implement value objects as structs with well-defined equality semantics:
// Money as a Value Object
type Money struct {
Amount decimal.Decimal
Currency string
}
// Value objects are immutable - operations return new instances
func (m Money) Add(other Money) Money {
if m.Currency != other.Currency {
panic("cannot add different currencies")
}
return Money{
Amount: m.Amount.Add(other.Amount),
Currency: m.Currency,
}
}
func (m Money) Subtract(other Money) Money {
if m.Currency != other.Currency {
panic("cannot subtract different currencies")
}
return Money{
Amount: m.Amount.Sub(other.Amount),
Currency: m.Currency,
}
}
func (m Money) LessThan(other Money) bool {
if m.Currency != other.Currency {
panic("cannot compare different currencies")
}
return m.Amount.LessThan(other.Amount)
}
// Custom equality check
func (m Money) Equals(other Money) bool {
return m.Currency == other.Currency && m.Amount.Equal(other.Amount)
}
Go's lack of operator overloading means we need explicit methods for operations, but this actually enhances code clarity by making operations explicit.
Entities
Entities are objects defined by their identity rather than their attributes. They have lifecycles, can change over time, and are tracked through various states:
// Strong typing for identities
type AccountID string
type CustomerID string
// Entity with identity
type Customer struct {
ID CustomerID
Name string
Email Email
PhoneNumber PhoneNumber
Address Address
Status CustomerStatus
CreatedAt time.Time
UpdatedAt time.Time
}
// Entities have behavior
func (c *Customer) UpdateContactInfo(email Email, phone PhoneNumber) {
c.Email = email
c.PhoneNumber = phone
c.UpdatedAt = time.Now()
}
func (c *Customer) ChangeAddress(newAddress Address) {
c.Address = newAddress
c.UpdatedAt = time.Now()
}
func (c *Customer) Activate() error {
if c.Status == StatusClosed {
return ErrCannotActivateClosedCustomer
}
c.Status = StatusActive
c.UpdatedAt = time.Now()
return nil
}
Notice how entities maintain their internal consistency through behavior methods, rather than allowing direct state manipulation.
Aggregates
Aggregates define a consistency boundary around one or more entities. Each aggregate has a root entity (the aggregate root) that controls access to all entities within the aggregate:
// Order aggregate with OrderLine entities
type Order struct {
ID OrderID
CustomerID CustomerID
Status OrderStatus
Lines []OrderLine
ShippingInfo ShippingInfo
PaymentInfo PaymentInfo
TotalAmount Money
CreatedAt time.Time
UpdatedAt time.Time
}
type OrderLine struct {
ProductID ProductID
Quantity int
UnitPrice Money
TotalPrice Money
}
// Aggregate maintains its invariants
func (o *Order) AddProduct(product Product, quantity int) error {
if o.Status != OrderStatusDraft {
return ErrCannotModifyNonDraftOrder
}
// Check if product already exists in order
for i, line := range o.Lines {
if line.ProductID == product.ID {
// Update existing line
o.Lines[i].Quantity += quantity
o.Lines[i].TotalPrice = product.Price.Multiply(decimal.NewFromInt(int64(o.Lines[i].Quantity)))
o.recalculateTotal()
o.UpdatedAt = time.Now()
return nil
}
}
// Add new line
newLine := OrderLine{
ProductID: product.ID,
Quantity: quantity,
UnitPrice: product.Price,
TotalPrice: product.Price.Multiply(decimal.NewFromInt(int64(quantity))),
}
o.Lines = append(o.Lines, newLine)
o.recalculateTotal()
o.UpdatedAt = time.Now()
return nil
}
func (o *Order) Submit() error {
if o.Status != OrderStatusDraft {
return ErrCannotSubmitNonDraftOrder
}
if len(o.Lines) == 0 {
return ErrCannotSubmitEmptyOrder
}
if o.PaymentInfo.IsEmpty() {
return ErrMissingPaymentInfo
}
if o.ShippingInfo.IsEmpty() {
return ErrMissingShippingInfo
}
o.Status = OrderStatusSubmitted
o.UpdatedAt = time.Now()
return nil
}
// Private helper method
func (o *Order) recalculateTotal() {
total := Money{Amount: decimal.Zero, Currency: "USD"}
for _, line := range o.Lines {
total = total.Add(line.TotalPrice)
}
o.TotalAmount = total
}
The Order aggregate encapsulates its internal OrderLine entities and enforces business rules that maintain the consistency of the entire aggregate.
Repositories
Repositories provide a collection-like interface for accessing domain objects. They abstract the underlying persistence mechanism and work with fully-constructed aggregates:
// Repository interfaces in domain layer
type OrderRepository interface {
FindByID(ctx context.Context, id OrderID) (*Order, error)
Save(ctx context.Context, order *Order) error
FindByCustomer(ctx context.Context, customerID CustomerID) ([]*Order, error)
}
// Implementation in infrastructure layer
type PostgresOrderRepository struct {
db *sql.DB
}
func NewPostgresOrderRepository(db *sql.DB) OrderRepository {
return &PostgresOrderRepository{db: db}
}
func (r *PostgresOrderRepository) FindByID(ctx context.Context, id OrderID) (*Order, error) {
// SQL query to retrieve order and its lines
// Map database rows to domain objects
// Return fully constructed aggregate
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *Order) error {
// Start transaction
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Save order header
// Save order lines
// Commit transaction
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
Repositories handle the complexity of persistence while presenting a clean domain-oriented interface to the application.
Domain Services
When an operation doesn't naturally belong to any single entity or value object, we use domain services to express that behavior:
// Domain service interface
type PricingService interface {
CalculateOrderTotal(order Order, promotions []Promotion) (Money, error)
}
// Implementation
type pricingService struct {
taxCalculator TaxCalculator
}
func NewPricingService(taxCalculator TaxCalculator) PricingService {
return &pricingService{
taxCalculator: taxCalculator,
}
}
func (s *pricingService) CalculateOrderTotal(order Order, promotions []Promotion) (Money, error) {
subtotal := Money{Amount: decimal.Zero, Currency: "USD"}
// Calculate subtotal
for _, line := range order.Lines {
subtotal = subtotal.Add(line.TotalPrice)
}
// Apply promotions
discountTotal := Money{Amount: decimal.Zero, Currency: "USD"}
for _, promotion := range promotions {
if promotion.AppliesTo(order) {
discount := promotion.CalculateDiscount(order)
discountTotal = discountTotal.Add(discount)
}
}
// Ensure discount doesn't exceed subtotal
if discountTotal.Amount.GreaterThan(subtotal.Amount) {
discountTotal = subtotal
}
// Calculate after discount
afterDiscount := subtotal.Subtract(discountTotal)
// Calculate tax
tax, err := s.taxCalculator.CalculateTax(afterDiscount, order.ShippingInfo.Address)
if err != nil {
return Money{}, fmt.Errorf("tax calculation failed: %w", err)
}
// Return final total
return afterDiscount.Add(tax), nil
}
Domain services encapsulate operations that span multiple domain objects, maintaining a focus on the domain logic without burdening individual entities.
Tactical Design Patterns
Factories
Factories encapsulate the complex logic of creating valid domain objects:
// Factory for creating valid orders
type OrderFactory struct {
idGenerator IDGenerator
productRepository ProductRepository
}
func NewOrderFactory(idGen IDGenerator, prodRepo ProductRepository) *OrderFactory {
return &OrderFactory{
idGenerator: idGen,
productRepository: prodRepo,
}
}
func (f *OrderFactory) CreateOrder(ctx context.Context, customerID CustomerID) (*Order, error) {
return &Order{
ID: OrderID(f.idGenerator.NextID()),
CustomerID: customerID,
Status: OrderStatusDraft,
Lines: []OrderLine{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
TotalAmount: Money{
Amount: decimal.Zero,
Currency: "USD",
},
}, nil
}
func (f *OrderFactory) CreateOrderFromCart(ctx context.Context, cart *Cart) (*Order, error) {
order, err := f.CreateOrder(ctx, cart.CustomerID)
if err != nil {
return nil, err
}
// Transform cart items to order lines
for _, item := range cart.Items {
product, err := f.productRepository.FindByID(ctx, item.ProductID)
if err != nil {
return nil, fmt.Errorf("failed to find product: %w", err)
}
err = order.AddProduct(*product, item.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to add product to order: %w", err)
}
}
return order, nil
}
Specification Pattern
The specification pattern allows for encapsulating complex business rules into reusable objects:
// Specification interface
type Specification interface {
IsSatisfiedBy(entity interface{}) bool
}
// Example of a concrete specification
type PremiumCustomerSpecification struct {
minimumOrderCount int
minimumOrderValue Money
}
func NewPremiumCustomerSpecification(minOrderCount int, minOrderValue Money) *PremiumCustomerSpecification {
return &PremiumCustomerSpecification{
minimumOrderCount: minOrderCount,
minimumOrderValue: minOrderValue,
}
}
func (s *PremiumCustomerSpecification) IsSatisfiedBy(entity interface{}) bool {
customer, ok := entity.(*Customer)
if !ok {
return false
}
// Check if customer has sufficient order history
if customer.OrderCount < s.minimumOrderCount {
return false
}
// Check if customer has spent enough
if customer.TotalSpend.LessThan(s.minimumOrderValue) {
return false
}
return true
}
// Usage example
func IdentifyPremiumCustomers(customers []*Customer) []*Customer {
premiumSpec := NewPremiumCustomerSpecification(10, Money{Amount: decimal.NewFromInt(1000), Currency: "USD"})
var premiumCustomers []*Customer
for _, customer := range customers {
if premiumSpec.IsSatisfiedBy(customer) {
premiumCustomers = append(premiumCustomers, customer)
}
}
return premiumCustomers
}
Specifications can be combined through logical operations to create complex business rules:
// Composite specifications
type AndSpecification struct {
specs []Specification
}
func (s *AndSpecification) IsSatisfiedBy(entity interface{}) bool {
for _, spec := range s.specs {
if !spec.IsSatisfiedBy(entity) {
return false
}
}
return true
}
type OrSpecification struct {
specs []Specification
}
func (s *OrSpecification) IsSatisfiedBy(entity interface{}) bool {
for _, spec := range s.specs {
if spec.IsSatisfiedBy(entity) {
return true
}
}
return false
}
type NotSpecification struct {
spec Specification
}
func (s *NotSpecification) IsSatisfiedBy(entity interface{}) bool {
return !s.spec.IsSatisfiedBy(entity)
}
Strategic Design Patterns
Bounded Contexts
Bounded contexts are explicit boundaries within which a domain model applies. They help manage complexity by dividing a large domain into smaller, more manageable subdomains.
In Go, we can represent bounded contexts through package structure:
/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── ordering/ # Ordering bounded context
│ │ ├── domain/
│ │ │ ├── order.go
│ │ │ ├── product.go
│ │ │ └── customer.go
│ │ ├── application/
│ │ │ ├── orderservice.go
│ │ │ └── dto/
│ │ ├── infrastructure/
│ │ │ ├── postgres/
│ │ │ └── rest/
│ │ └── interfaces/
│ │ └── http/
│ ├── catalog/ # Product catalog bounded context
│ │ ├── domain/
│ │ │ ├── product.go
│ │ │ ├── category.go
│ │ │ └── inventory.go
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── interfaces/
│ └── customer/ # Customer management bounded context
│ ├── domain/
│ ├── application/
│ ├── infrastructure/
│ └── interfaces/
└── pkg/ # Shared utilities and cross-cutting concerns
├── errors/
├── logging/
└── validation/
Context Maps
Context maps define the relationships between bounded contexts. In code, we implement these relationships through anti-corruption layers, shared kernels, or other integration patterns.
Here's an example of an anti-corruption layer that translates between two bounded contexts:
// Anti-corruption layer translating between Customer and Ordering contexts
type CustomerTranslator struct {
customerRepository customer.Repository
}
func NewCustomerTranslator(repo customer.Repository) *CustomerTranslator {
return &CustomerTranslator{
customerRepository: repo,
}
}
// Translate customer.Customer to ordering.Customer
func (t *CustomerTranslator) ToOrderingCustomer(ctx context.Context, customerID string) (*ordering.Customer, error) {
// Fetch customer from Customer bounded context
customerCtx, err := t.customerRepository.FindByID(ctx, customerID)
if err != nil {
return nil, fmt.Errorf("failed to find customer: %w", err)
}
// Map to Ordering bounded context representation
return &ordering.Customer{
ID: ordering.CustomerID(customerCtx.ID),
Name: customerCtx.FullName(),
Email: ordering.Email(customerCtx.Email),
Address: t.translateAddress(customerCtx.Address),
}, nil
}
// Helper method to translate address
func (t *CustomerTranslator) translateAddress(addr customer.Address) ordering.Address {
return ordering.Address{
Street: addr.Street,
City: addr.City,
State: addr.State,
Country: addr.Country,
ZipCode: addr.PostalCode, // Note the different field names
}
}
Implementing DDD in a Go Application
Now that we've covered the tactical and strategic patterns, let's look at how to structure a complete Go application using DDD principles.
Layered Architecture
A common approach in DDD is to use a layered architecture:
- Domain Layer: Contains the domain model (entities, value objects, domain services)
- Application Layer: Orchestrates domain objects to perform use cases
- Infrastructure Layer: Provides implementations for persistence, external services, etc.
- Interfaces Layer: Handles interaction with the outside world (HTTP, gRPC, CLI)
Here's an example of how these layers interact in a complete use case:
// Domain Layer: The core domain model
type Order struct {
// Fields and methods as shown earlier
}
// Application Layer: Use case implementation
type PlaceOrderService struct {
orderRepo OrderRepository
customerRepo CustomerRepository
productRepo ProductRepository
pricingService PricingService
paymentService PaymentService
eventPublisher EventPublisher
}
func NewPlaceOrderService(
orderRepo OrderRepository,
customerRepo CustomerRepository,
productRepo ProductRepository,
pricingService PricingService,
paymentService PaymentService,
eventPublisher EventPublisher,
) *PlaceOrderService {
return &PlaceOrderService{
orderRepo: orderRepo,
customerRepo: customerRepo,
productRepo: productRepo,
pricingService: pricingService,
paymentService: paymentService,
eventPublisher: eventPublisher,
}
}
// Application service method
func (s *PlaceOrderService) PlaceOrder(ctx context.Context, request PlaceOrderRequest) (*PlaceOrderResponse, error) {
// Validate customer exists
customer, err := s.customerRepo.FindByID(ctx, CustomerID(request.CustomerID))
if err != nil {
return nil, fmt.Errorf("customer not found: %w", err)
}
// Create new order
order := &Order{
ID: OrderID(uuid.New().String()),
CustomerID: customer.ID,
Status: OrderStatusDraft,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Add products to order
for _, item := range request.Items {
product, err := s.productRepo.FindByID(ctx, ProductID(item.ProductID))
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
err = order.AddProduct(*product, item.Quantity)
if err != nil {
return nil, fmt.Errorf("failed to add product: %w", err)
}
}
// Set shipping and payment info
order.ShippingInfo = ShippingInfo{
Address: customer.Address,
Method: request.ShippingMethod,
TrackingNum: "",
}
order.PaymentInfo = PaymentInfo{
Method: request.PaymentMethod,
Amount: order.TotalAmount,
Status: PaymentStatusPending,
Timestamp: time.Time{},
}
// Calculate final price including promotions
activePromotions, err := s.promotionRepo.GetActivePromotions(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get promotions: %w", err)
}
finalPrice, err := s.pricingService.CalculateOrderTotal(*order, activePromotions)
if err != nil {
return nil, fmt.Errorf("failed to calculate total: %w", err)
}
order.TotalAmount = finalPrice
// Process payment
paymentResult, err := s.paymentService.ProcessPayment(ctx, order.PaymentInfo)
if err != nil {
return nil, fmt.Errorf("payment processing failed: %w", err)
}
// Update order with payment result
order.PaymentInfo.Status = paymentResult.Status
order.PaymentInfo.Timestamp = paymentResult.Timestamp
// Submit the order
if paymentResult.Status == PaymentStatusCompleted {
err = order.Submit()
if err != nil {
return nil, fmt.Errorf("failed to submit order: %w", err)
}
}
// Save the order
err = s.orderRepo.Save(ctx, order)
if err != nil {
return nil, fmt.Errorf("failed to save order: %w", err)
}
// Publish domain event
event := &OrderPlacedEvent{
OrderID: order.ID,
CustomerID: order.CustomerID,
Amount: order.TotalAmount,
Timestamp: time.Now(),
}
err = s.eventPublisher.Publish(ctx, "order.placed", event)
if err != nil {
// Log but don't fail the operation
log.Printf("Failed to publish order.placed event: %v", err)
}
// Return response
return &PlaceOrderResponse{
OrderID: string(order.ID),
TotalAmount: order.TotalAmount,
Status: string(order.Status),
}, nil
}
// Infrastructure Layer: Repository implementation
type PostgresOrderRepository struct {
db *sql.DB
}
func (r *PostgresOrderRepository) Save(ctx context.Context, order *Order) error {
// Implementation as shown earlier
}
// Interfaces Layer: HTTP handler
type OrderHandler struct {
placeOrderService *PlaceOrderService
}
func (h *OrderHandler) HandlePlaceOrder(w http.ResponseWriter, r *http.Request) {
var req PlaceOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
resp, err := h.placeOrderService.PlaceOrder(r.Context(), req)
if err != nil {
// Handle different error types with appropriate status codes
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
Dependency Injection
DDD and dependency injection go hand in hand. By injecting dependencies, we maintain a clean separation between layers:
// In main.go or a dedicated setup package
func SetupServices(db *sql.DB) (*OrderHandler, error) {
// Repositories
orderRepo := postgres.NewOrderRepository(db)
customerRepo := postgres.NewCustomerRepository(db)
productRepo := postgres.NewProductRepository(db)
promotionRepo := postgres.NewPromotionRepository(db)
// Domain services
pricingService := domain.NewPricingService(NewTaxCalculator())
// Infrastructure services
paymentService := payment.NewStripePaymentService(os.Getenv("STRIPE_API_KEY"))
eventPublisher := messaging.NewRabbitMQPublisher(os.Getenv("RABBITMQ_URL"))
// Application services
placeOrderService := application.NewPlaceOrderService(
orderRepo,
customerRepo,
productRepo,
promotionRepo,
pricingService,
paymentService,
eventPublisher,
)
// HTTP handlers
orderHandler := interfaces.NewOrderHandler(placeOrderService)
return orderHandler, nil
}
Event-Driven Architecture and DDD
DDD works particularly well with event-driven architectures. Domain events represent meaningful occurrences within the domain and can be used to coordinate between bounded contexts:
// Domain event
type OrderPlacedEvent struct {
OrderID OrderID
CustomerID CustomerID
Amount Money
Timestamp time.Time
}
// Event publisher interface
type EventPublisher interface {
Publish(ctx context.Context, eventType string, event interface{}) error
}
// Example handler in another bounded context
type InventoryEventHandler struct {
inventoryService InventoryService
}
func (h *InventoryEventHandler) HandleOrderPlaced(event OrderPlacedEvent) error {
// Extract order items from the event or load the order
// Reserve inventory for the order
return h.inventoryService.ReserveInventory(event.OrderID)
}
Advanced DDD Techniques
CQRS (Command Query Responsibility Segregation)
CQRS separates read and write operations, allowing for different models optimized for each purpose:
// Command side - focused on behavior
type PlaceOrderCommand struct {
CustomerID string
Items []OrderItemRequest
ShippingMethod string
PaymentMethod string
}
type OrderCommandHandler struct {
orderRepo OrderRepository
// Other dependencies
}
func (h *OrderCommandHandler) HandlePlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (string, error) {
// Implementation similar to PlaceOrderService.PlaceOrder
}
// Query side - optimized for reading
type OrderSummaryQuery struct {
OrderID string
}
type OrderSummaryView struct {
OrderID string
CustomerName string
OrderDate time.Time
TotalAmount string
Status string
ItemCount int
ShippingInfo string
PaymentStatus string
}
type OrderQueryService struct {
readDB *sql.DB
}
func (s *OrderQueryService) GetOrderSummary(ctx context.Context, query OrderSummaryQuery) (*OrderSummaryView, error) {
// Direct optimized read from a dedicated read model or view
row := s.readDB.QueryRowContext(ctx, `
SELECT o.id, c.name, o.created_at, o.total_amount, o.status,
COUNT(oi.id) as item_count, o.shipping_method, o.payment_status
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
WHERE o.id = $1
GROUP BY o.id, c.name
`, query.OrderID)
var summary OrderSummaryView
err := row.Scan(
&summary.OrderID,
&summary.CustomerName,
&summary.OrderDate,
&summary.TotalAmount,
&summary.Status,
&summary.ItemCount,
&summary.ShippingInfo,
&summary.PaymentStatus,
)
if err != nil {
return nil, fmt.Errorf("failed to get order summary: %w", err)
}
return &summary, nil
}
Event Sourcing
Event sourcing stores state changes as a sequence of events rather than just the current state:
// Event interface
type DomainEvent interface {
AggregateID() string
EventType() string
OccurredAt() time.Time
Version() int
}
// Concrete events
type OrderCreatedEvent struct {
ID string
CustomerID string
Timestamp time.Time
Version int
}
func (e OrderCreatedEvent) AggregateID() string { return e.ID }
func (e OrderCreatedEvent) EventType() string { return "order.created" }
func (e OrderCreatedEvent) OccurredAt() time.Time { return e.Timestamp }
func (e OrderCreatedEvent) Version() int { return e.Version }
type ProductAddedToOrderEvent struct {
OrderID string
ProductID string
Quantity int
UnitPrice Money
Timestamp time.Time
Version int
}
func (e ProductAddedToOrderEvent) AggregateID() string { return e.OrderID }
func (e ProductAddedToOrderEvent) EventType() string { return "order.product_added" }
func (e ProductAddedToOrderEvent) OccurredAt() time.Time { return e.Timestamp }
func (e ProductAddedToOrderEvent) Version() int { return e.Version }
// Event store interface
type EventStore interface {
SaveEvents(ctx context.Context, aggregateID string, events []DomainEvent, expectedVersion int) error
GetEvents(ctx context.Context, aggregateID string) ([]DomainEvent, error)
}
// Rebuilding an aggregate from events
func RebuildOrderFromEvents(events []DomainEvent) (*Order, error) {
if len(events) == 0 {
return nil, errors.New("no events to rebuild from")
}
var order *Order
for _, event := range events {
switch e := event.(type) {
case OrderCreatedEvent:
order = &Order{
ID: OrderID(e.ID),
CustomerID: CustomerID(e.CustomerID),
Status: OrderStatusDraft,
Lines: []OrderLine{},
CreatedAt: e.Timestamp,
UpdatedAt: e.Timestamp,
}
case ProductAddedToOrderEvent:
if order == nil {
return nil, errors.New("received product added event before order created")
}
line := OrderLine{
ProductID: ProductID(e.ProductID),
Quantity: e.Quantity,
UnitPrice: e.UnitPrice,
TotalPrice: e.UnitPrice.Multiply(decimal.NewFromInt(int64(e.Quantity))),
}
order.Lines = append(order.Lines, line)
order.recalculateTotal()
order.UpdatedAt = e.Timestamp
// Handle other event types...
}
}
return order, nil
}
Common Challenges and Solutions in DDD
Challenge 1: Large Aggregates
Large aggregates can lead to performance and concurrency issues. The solution is to right-size aggregates:
// Instead of this (large aggregate):
type Order struct {
ID OrderID
CustomerID CustomerID
Customer *Customer // Including full customer data
Lines []OrderLine
PaymentInfo PaymentInfo
FullHistory []OrderHistoryEntry // Complete history
// Many more fields
}
// Prefer this (right-sized):
type Order struct {
ID OrderID
CustomerID CustomerID // Reference only
Status OrderStatus
Lines []OrderLine
PaymentInfo PaymentInfo
CreatedAt time.Time
UpdatedAt time.Time
}
// Move history to a separate aggregate
type OrderHistory struct {
OrderID OrderID
Entries []OrderHistoryEntry
}
Challenge 2: Persistence Complexity
DDD aggregates can be complex to persist, especially in relational databases:
// Repository implementation with custom mapping
func (r *PostgresOrderRepository) Save(ctx context.Context, order *Order) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Upsert order header
_, err = tx.ExecContext(ctx, `
INSERT INTO orders (id, customer_id, status, total_amount, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE
SET customer_id = $2, status = $3, total_amount = $4, updated_at = $6
`,
string(order.ID),
string(order.CustomerID),
string(order.Status),
order.TotalAmount.Amount.String(),
order.CreatedAt,
order.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to save order header: %w", err)
}
// Delete existing order lines to replace them
_, err = tx.ExecContext(ctx, "DELETE FROM order_lines WHERE order_id = $1", string(order.ID))
if err != nil {
return fmt.Errorf("failed to delete order lines: %w", err)
}
// Insert order lines
for _, line := range order.Lines {
_, err = tx.ExecContext(ctx, `
INSERT INTO order_lines (order_id, product_id, quantity, unit_price, total_price)
VALUES ($1, $2, $3, $4, $5)
`,
string(order.ID),
string(line.ProductID),
line.Quantity,
line.UnitPrice.Amount.String(),
line.TotalPrice.Amount.String())
if err != nil {
return fmt.Errorf("failed to save order line: %w", err)
}
}
// Commit transaction
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
Best Practices for DDD in Go
1. Embrace Go's Type System
Go's type system is powerful for DDD:
// Strong typing for identifiers
type OrderID string
type CustomerID string
type ProductID string
// Custom types for domain concepts
type Money struct {
Amount decimal.Decimal
Currency string
}
type Email string
// Validation at type creation
func NewEmail(raw string) (Email, error) {
if !emailRegex.MatchString(raw) {
return "", fmt.Errorf("invalid email format: %s", raw)
}
return Email(raw), nil
}
2. Keep Domain Logic Pure
Domain logic should be free from infrastructure concerns:
// Good: Pure domain logic
func (o *Order) Submit() error {
if o.Status != OrderStatusDraft {
return ErrCannotSubmitNonDraftOrder
}
if len(o.Lines) == 0 {
return ErrCannotSubmitEmptyOrder
}
// More domain validations...
o.Status = OrderStatusSubmitted
o.UpdatedAt = time.Now()
return nil
}
// Bad: Domain logic mixed with infrastructure
func (o *Order) Submit(ctx context.Context, db *sql.DB) error {
if o.Status != OrderStatusDraft {
return ErrCannotSubmitNonDraftOrder
}
// Don't do this - mixing database operations with domain logic
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Database operations don't belong in domain objects
_, err = tx.ExecContext(ctx, "UPDATE orders SET status = $1 WHERE id = $2",
OrderStatusSubmitted, o.ID)
if err != nil {
return err
}
return tx.Commit()
}
3. Use Immutability Where Appropriate
Immutable types reduce complexity and bugs:
// Value objects should be immutable
type Money struct {
amount decimal.Decimal
currency string
}
// Constructor ensures all fields are set
func NewMoney(amount decimal.Decimal, currency string) Money {
return Money{
amount: amount,
currency: currency,
}
}
// Getters instead of public fields
func (m Money) Amount() decimal.Decimal {
return m.amount
}
func (m Money) Currency() string {
return m.currency
}
// Operations return new instances
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, errors.New("cannot add different currencies")
}
return NewMoney(m.amount.Add(other.amount), m.currency), nil
}
4. Make Domain Errors Meaningful
Domain errors should convey business meaning:
// Define domain-specific errors
var (
ErrInsufficientFunds = errors.New("account has insufficient funds for this operation")
ErrInactiveAccount = errors.New("account is not active")
ErrExceedsWithdrawalLimit = errors.New("operation exceeds daily withdrawal limit")
ErrInvalidAmount = errors.New("amount must be positive")
)
// Error types with context
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Message)
}
// Using domain errors
func (a *Account) Withdraw(amount Money) error {
if !a.IsActive() {
return ErrInactiveAccount
}
if amount.Amount().LessThanOrEqual(decimal.Zero) {
return ErrInvalidAmount
}
if a.Balance.LessThan(amount) {
return ErrInsufficientFunds
}
if a.exceedsDailyLimit(amount) {
return ErrExceedsWithdrawalLimit
}
// Perform withdrawal
a.Balance = a.Balance.Subtract(amount)
a.UpdatedAt = time.Now()
return nil
}
Conclusion
Domain-Driven Design is not just a set of technical patterns but a comprehensive approach to software development that puts the business domain at the center of the design process. By adopting DDD principles in Go applications, we can create software that is:
- Aligned with business needs: The domain model directly reflects the business domain
- Maintainable: Clean separation of concerns makes code easier to understand and modify
- Extensible: Well-defined boundaries and interfaces make it easier to add new functionality
- Testable: Pure domain logic is easier to test in isolation
- Scalable: Strategic patterns like bounded contexts provide natural boundaries for scaling
The journey to implementing DDD effectively is ongoing. As your understanding of the business domain deepens, your domain model will evolve. The most successful DDD implementations are those that embrace this evolution and continuously refine the model to better represent the business reality.
Remember that DDD is not a one-size-fits-all approach. Not every application needs the full complexity of DDD. For simpler domains, a lighter approach may be more appropriate. The true value of DDD shines in complex domains where the cost of misunderstanding business rules is high.
By thoughtfully applying the principles and patterns discussed in this article, you can build Go applications that not only fulfill technical requirements but truly serve the needs of the business.