Domain-Driven Design in Go

Systems Programming advanced Building with AI ~40 min read

Overview

Domain-Driven Design (DDD) is an approach to software development that centers the design and architecture around the core business domain. Rather than letting the database schema, API framework, or infrastructure concerns dictate how code is organized, DDD starts from the domain model—the entities, value objects, aggregates, and services that represent the problem space—and builds outward. The domain layer contains the business rules and is isolated from infrastructure details through interfaces (ports) and their implementations (adapters).

Go’s design philosophy—simplicity, explicit dependencies, interfaces defined by the consumer—makes it a surprisingly good fit for DDD. Go interfaces are implicit (any type that implements the methods satisfies the interface), which naturally supports the dependency inversion that DDD requires. Go’s package system maps well to bounded contexts, and the language’s preference for composition over inheritance aligns with how DDD models are assembled from small, focused pieces.

As a practical example, a workflow orchestration system can apply DDD principles to model a complex workflow orchestration domain. Workflows, tasks, triggers, and execution contexts are modeled as domain entities with clear boundaries. The persistence layer (repository pattern), messaging layer (event publisher), and HTTP/gRPC transport are all adapters that plug into the domain through interfaces. This separation allows the domain logic to be tested without any infrastructure dependencies and makes it possible to swap storage backends or transport protocols without modifying business rules.

Core Concepts

Entities and Value Objects

An entity is a domain object with a unique identity that persists over time. Two entities with the same attributes but different IDs are different objects (e.g., two users with the same name). A value object is defined entirely by its attributes—two value objects with the same attributes are interchangeable (e.g., a Money{Amount: 100, Currency: "USD"}). In Go, entities typically have an ID field, while value objects are plain structs compared by value.

Aggregates and Aggregate Roots

An aggregate is a cluster of entities and value objects that are treated as a single unit for data changes. The aggregate root is the entry point—all modifications to the aggregate go through the root, which enforces invariants. For example, an Order aggregate contains OrderLine items; adding or removing lines goes through the Order root, which validates total amounts and stock availability. External code never directly modifies an OrderLine.

Repositories

A repository provides collection-like access to aggregates. The domain layer defines a repository interface (OrderRepository); the infrastructure layer provides an implementation (e.g., PostgresOrderRepository). This inversion means the domain layer has no dependency on the database. Repositories deal in whole aggregates, not individual entities—you save or load an Order with all its lines, not individual lines.

Domain Services

Operations that don’t naturally belong to a single entity live in domain services. For example, transferring money between accounts involves two Account entities but shouldn’t be a method on either one. A TransferService coordinates the operation and enforces cross-entity invariants (like ensuring the source has sufficient funds before debiting).

Key Concept: Bounded Contexts

A bounded context is a boundary within which a particular domain model is defined and applicable. The same real-world concept (e.g., “User”) may have different representations in different contexts: the authentication context cares about credentials and sessions, the billing context cares about payment methods and invoices. In Go, bounded contexts map naturally to separate packages or modules, each with its own types and interfaces.

Key Concept: Ubiquitous Language

DDD insists that the code use the same terminology as the domain experts. If the business calls it a “Workflow” rather than a “Pipeline,” the code should use Workflow. This eliminates translation errors between requirements and implementation. In Go, this means choosing type names, method names, and package names that a domain expert would recognize without needing to understand the code.

Key Concept: Ports and Adapters (Hexagonal Architecture)

The domain layer defines ports—interfaces that describe what the domain needs from the outside world (e.g., EventPublisher, TaskRepository). Adapters implement these interfaces using specific technologies (PostgreSQL, Kafka, gRPC). The domain never imports adapter packages; adapters import domain packages and implement domain interfaces. This is the dependency rule that keeps the domain pure.

How It Works

Project Layout for DDD in Go

A common Go project layout for DDD separates the domain, application, and infrastructure layers into distinct packages. The domain package has zero external dependencies:

project/
├── domain/
│   ├── workflow.go          // Workflow entity (aggregate root)
│   ├── task.go              // Task entity
│   ├── trigger.go           // Trigger value object
│   ├── execution.go         // Execution entity
│   ├── errors.go            // Domain-specific errors
│   └── repository.go        // Repository interfaces
├── application/
│   ├── workflow_service.go  // Application services (use cases)
│   ├── task_executor.go     // Orchestrates task execution
│   └── event_handler.go     // Handles domain events
├── infrastructure/
│   ├── postgres/
│   │   ├── workflow_repo.go // PostgresWorkflowRepository
│   │   └── task_repo.go     // PostgresTaskRepository
│   ├── kafka/
│   │   └── event_pub.go     // KafkaEventPublisher
│   └── grpc/
│       └── server.go        // gRPC transport adapter
└── cmd/
    └── server/
        └── main.go          // Wires everything together

Defining Domain Entities and Interfaces

The domain package defines entities as structs and behaviors as methods. Repository interfaces are defined by the domain, not by the infrastructure:

// domain/workflow.go
package domain

import (
    "errors"
    "time"
)

// Workflow is the aggregate root for the workflow bounded context.
type Workflow struct {
    ID          string
    Name        string
    Description string
    Tasks       []Task
    Status      WorkflowStatus
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

type WorkflowStatus string

const (
    WorkflowDraft    WorkflowStatus = "draft"
    WorkflowActive   WorkflowStatus = "active"
    WorkflowArchived WorkflowStatus = "archived"
)

// AddTask adds a task to the workflow, enforcing domain invariants.
func (w *Workflow) AddTask(task Task) error {
    if w.Status == WorkflowArchived {
        return ErrWorkflowArchived
    }
    for _, existing := range w.Tasks {
        if existing.Name == task.Name {
            return ErrDuplicateTaskName
        }
    }
    // Enforce ordering: task position must be contiguous
    task.Position = len(w.Tasks)
    w.Tasks = append(w.Tasks, task)
    w.UpdatedAt = time.Now()
    return nil
}

// Activate transitions the workflow to active status.
func (w *Workflow) Activate() error {
    if len(w.Tasks) == 0 {
        return ErrNoTasks
    }
    if w.Status != WorkflowDraft {
        return ErrInvalidTransition
    }
    w.Status = WorkflowActive
    w.UpdatedAt = time.Now()
    return nil
}

var (
    ErrWorkflowArchived  = errors.New("cannot modify archived workflow")
    ErrDuplicateTaskName = errors.New("task name already exists in workflow")
    ErrNoTasks           = errors.New("workflow must have at least one task")
    ErrInvalidTransition = errors.New("invalid status transition")
)
// domain/repository.go
package domain

import "context"

// WorkflowRepository defines persistence operations for the Workflow aggregate.
// This interface is defined by the domain and implemented by infrastructure.
type WorkflowRepository interface {
    FindByID(ctx context.Context, id string) (*Workflow, error)
    FindByStatus(ctx context.Context, status WorkflowStatus) ([]*Workflow, error)
    Save(ctx context.Context, workflow *Workflow) error
    Delete(ctx context.Context, id string) error
}

// TaskRepository defines persistence operations for Task entities.
type TaskRepository interface {
    FindByID(ctx context.Context, id string) (*Task, error)
    FindByWorkflowID(ctx context.Context, workflowID string) ([]Task, error)
    Save(ctx context.Context, task *Task) error
}

// EventPublisher defines how domain events are published.
type EventPublisher interface {
    Publish(ctx context.Context, event DomainEvent) error
}

// DomainEvent represents something that happened in the domain.
type DomainEvent struct {
    Type      string
    Aggregate string
    ID        string
    Payload   interface{}
}

Application Services (Use Cases)

Application services orchestrate domain objects to implement use cases. They depend on domain interfaces, not implementations:

// application/workflow_service.go
package application

import (
    "context"
    "fmt"

    "project/domain"
)

type WorkflowService struct {
    workflows domain.WorkflowRepository
    events    domain.EventPublisher
}

func NewWorkflowService(
    workflows domain.WorkflowRepository,
    events domain.EventPublisher,
) *WorkflowService {
    return &WorkflowService{
        workflows: workflows,
        events:    events,
    }
}

func (s *WorkflowService) ActivateWorkflow(ctx context.Context, id string) error {
    workflow, err := s.workflows.FindByID(ctx, id)
    if err != nil {
        return fmt.Errorf("find workflow: %w", err)
    }

    // Domain logic lives on the entity
    if err := workflow.Activate(); err != nil {
        return fmt.Errorf("activate: %w", err)
    }

    // Persist the state change
    if err := s.workflows.Save(ctx, workflow); err != nil {
        return fmt.Errorf("save workflow: %w", err)
    }

    // Publish domain event
    return s.events.Publish(ctx, domain.DomainEvent{
        Type:      "workflow.activated",
        Aggregate: "Workflow",
        ID:        workflow.ID,
        Payload:   workflow,
    })
}

Infrastructure Adapters

Adapters implement domain interfaces using specific technologies. The adapter package imports the domain package, never the other way around:

// infrastructure/postgres/workflow_repo.go
package postgres

import (
    "context"
    "database/sql"
    "encoding/json"

    "project/domain"
)

type WorkflowRepository struct {
    db *sql.DB
}

func NewWorkflowRepository(db *sql.DB) *WorkflowRepository {
    return &WorkflowRepository{db: db}
}

func (r *WorkflowRepository) FindByID(ctx context.Context, id string) (*domain.Workflow, error) {
    row := r.db.QueryRowContext(ctx,
        `SELECT id, name, description, status, tasks, created_at, updated_at
         FROM workflows WHERE id = $1`, id)

    w := &domain.Workflow{}
    var tasksJSON []byte
    err := row.Scan(&w.ID, &w.Name, &w.Description, &w.Status,
        &tasksJSON, &w.CreatedAt, &w.UpdatedAt)
    if err != nil {
        return nil, err
    }

    if err := json.Unmarshal(tasksJSON, &w.Tasks); err != nil {
        return nil, err
    }
    return w, nil
}

func (r *WorkflowRepository) Save(ctx context.Context, w *domain.Workflow) error {
    tasksJSON, err := json.Marshal(w.Tasks)
    if err != nil {
        return err
    }

    _, err = r.db.ExecContext(ctx,
        `INSERT INTO workflows (id, name, description, status, tasks, created_at, updated_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         ON CONFLICT (id) DO UPDATE SET
            name = $2, description = $3, status = $4, tasks = $5, updated_at = $7`,
        w.ID, w.Name, w.Description, w.Status, tasksJSON, w.CreatedAt, w.UpdatedAt)
    return err
}

// Compile-time check that WorkflowRepository implements domain.WorkflowRepository
var _ domain.WorkflowRepository = (*WorkflowRepository)(nil)
Key Concept: Dependency Injection via Constructor

Go’s DDD implementations use constructor injection: application services accept domain interfaces as constructor parameters. The main function (or a wire framework) creates concrete implementations and passes them in. This makes dependencies explicit, testable, and swappable. There is no hidden service locator or global state—if a service needs a dependency, it declares it in its constructor.

Practical Applications

Workflow Orchestration in a Workflow Engine

A workflow engine can model its domain using DDD. The Workflow aggregate contains tasks, triggers, and execution policies. Domain rules like “a workflow cannot be activated without at least one task” and “task names must be unique within a workflow” are enforced by the aggregate root. The persistence layer stores workflows as complete aggregates, ensuring consistency across the task collection.

Search Index Management

In a search system, DDD helps model the distinction between the index management domain (creating, updating, configuring indices) and the query execution domain (parsing queries, ranking results). These are separate bounded contexts with different models: an Index in the management context has configuration fields and health status, while in the query context it exposes a Search(query) -> Results interface. The anti-corruption layer between contexts translates as needed.

Multi-Tenant Data Isolation

DDD’s bounded context concept applies naturally to multi-tenancy. Each tenant’s domain operations are scoped to their context, with repository implementations that automatically filter by tenant ID. The domain layer is unaware of multi-tenancy; the repository adapter handles the isolation, keeping the business logic clean and testable without tenant-specific concerns.

Event-Driven Architecture

DDD’s domain events provide a natural integration point for event-driven systems. When a workflow is activated, the domain emits a WorkflowActivated event. Different bounded contexts subscribe to relevant events: the notification context sends alerts, the audit context records the change, and the scheduler context begins executing the workflow. Each context handles the event independently, maintaining loose coupling.

Implementation Patterns

Functional Options for Entity Construction

Go’s functional options pattern works well for constructing domain entities with validation:

type WorkflowOption func(*Workflow) error

func WithDescription(desc string) WorkflowOption {
    return func(w *Workflow) error {
        if len(desc) > 1000 {
            return errors.New("description exceeds 1000 characters")
        }
        w.Description = desc
        return nil
    }
}

func WithTasks(tasks ...Task) WorkflowOption {
    return func(w *Workflow) error {
        for _, t := range tasks {
            if err := w.AddTask(t); err != nil {
                return err
            }
        }
        return nil
    }
}

func NewWorkflow(name string, opts ...WorkflowOption) (*Workflow, error) {
    if name == "" {
        return nil, errors.New("workflow name is required")
    }

    w := &Workflow{
        ID:        generateID(),
        Name:      name,
        Status:    WorkflowDraft,
        CreatedAt: time.Now(),
        UpdatedAt: time.Now(),
    }

    for _, opt := range opts {
        if err := opt(w); err != nil {
            return nil, err
        }
    }
    return w, nil
}

Unit of Work Pattern

When an application service modifies multiple aggregates, the unit of work pattern ensures all changes are persisted atomically. In Go, this is typically implemented by wrapping repository operations in a database transaction:

type UnitOfWork interface {
    Begin(ctx context.Context) (context.Context, error)
    Commit(ctx context.Context) error
    Rollback(ctx context.Context) error
}

func (s *WorkflowService) CreateAndActivate(ctx context.Context, name string) error {
    ctx, err := s.uow.Begin(ctx)
    if err != nil {
        return err
    }
    defer s.uow.Rollback(ctx)

    workflow, err := domain.NewWorkflow(name,
        domain.WithTasks(domain.Task{Name: "default"}),
    )
    if err != nil {
        return err
    }

    if err := workflow.Activate(); err != nil {
        return err
    }

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

    return s.uow.Commit(ctx)
}

Testing Domain Logic in Isolation

Because the domain layer has no infrastructure dependencies, domain logic tests are pure unit tests—no database, no network, no mocks needed:

func TestWorkflow_AddTask_RejectsDuplicate(t *testing.T) {
    w, _ := domain.NewWorkflow("test-workflow")

    err := w.AddTask(domain.Task{Name: "extract"})
    if err != nil {
        t.Fatalf("first add should succeed: %v", err)
    }

    err = w.AddTask(domain.Task{Name: "extract"})
    if !errors.Is(err, domain.ErrDuplicateTaskName) {
        t.Errorf("expected ErrDuplicateTaskName, got: %v", err)
    }
}

func TestWorkflow_Activate_RequiresTasks(t *testing.T) {
    w, _ := domain.NewWorkflow("empty-workflow")

    err := w.Activate()
    if !errors.Is(err, domain.ErrNoTasks) {
        t.Errorf("expected ErrNoTasks, got: %v", err)
    }
}
Key Concept: Anti-Corruption Layer

When integrating with external systems or legacy code, an anti-corruption layer (ACL) translates between the external model and your domain model. The ACL prevents external concepts from leaking into your domain. In Go, this is typically a package that imports both the external client and your domain types, converting between them. The domain never sees the external types directly.

Performance Considerations

Common Pitfalls

Further Reading