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.
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.
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.
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.
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).
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.
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.
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.
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
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 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,
})
}
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)
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.
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.
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.
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.
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.
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
}
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)
}
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)
}
}
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.
Workflow has hundreds of tasks, loading all of them for every operation is wasteful. Use lazy loading patterns or split large aggregates into smaller ones. Consider whether all tasks truly share invariants that require them to be in the same aggregate.Workflow has only getters and setters with no business methods, it’s anemic. Business rules should live on the aggregate root: workflow.AddTask() validates and enforces invariants; the service just orchestrates persistence.database/sql, encoding/json, or framework types in the domain package breaks the dependency rule. The domain should depend only on the standard library’s core types and its own interfaces. JSON tags on domain structs are a common warning sign—use separate DTOs in the infrastructure layer.Order context and the Shipping context share the same Address struct, changes in one context ripple into the other. Each context should own its types and translate at the boundary.