Domain-Driven Design in Go: Practical Patterns

How Andromeda applies Domain-Driven Design principles in Go, covering entities, value objects, aggregates, repositories, and application services without heavy frameworks.

technical8 min readBy Klivvr Engineering
Share:

Domain-Driven Design was born in the Java and C# world, where frameworks, annotations, and deep inheritance hierarchies make certain patterns easy to express. Go offers none of those affordances. There are no generics-heavy base classes, no annotation-based dependency injection, and no ORM that magically maps objects to tables. This is not a limitation. It is a feature. Go's simplicity forces you to implement DDD patterns explicitly, which makes them easier to understand, test, and maintain.

In Andromeda, we apply DDD to every backend service. The patterns have evolved over two years of active development, and what follows is a practical guide to what worked, what we discarded, and how we structure domain logic in a language that favors composition over inheritance and interfaces over abstract classes.

The Layered Architecture

Every service in Andromeda follows a four-layer architecture. The layers are enforced by Go package boundaries:

// internal/accounts/
// ├── domain/     -- entities, value objects, domain errors, domain services
// ├── app/        -- application services (use cases, orchestration)
// ├── infra/      -- infrastructure adapters (database, NATS, external APIs)
// └── ports/      -- inbound adapters (gRPC handlers, NATS subscribers)

The dependency rule is strict: domain depends on nothing. app depends on domain. infra depends on domain and app (specifically, it implements interfaces defined in app). ports depends on app. No layer may import a layer above it. Go's internal/ directory structure enforces that none of these packages leak outside the service boundary.

This layout maps directly to the hexagonal architecture (ports and adapters) concept. The domain and app packages form the hexagon's core. ports are inbound adapters that drive the application. infra packages are outbound adapters that the application drives.

Entities and Value Objects

An entity is an object with a unique identity that persists over time. A value object is defined entirely by its attributes and has no identity. In Go, both are plain structs, but we distinguish them by construction patterns and behavior.

// internal/accounts/domain/account.go
package domain
 
import (
    "errors"
    "time"
)
 
// Account is an entity. It has a unique ID and a lifecycle.
type Account struct {
    id        string
    ownerID   string
    currency  Currency
    balance   Money
    status    AccountStatus
    createdAt time.Time
    updatedAt time.Time
}
 
// NewAccount is the constructor. It enforces invariants at creation time.
func NewAccount(id, ownerID string, currency Currency) (*Account, error) {
    if id == "" {
        return nil, errors.New("account id is required")
    }
    if ownerID == "" {
        return nil, errors.New("owner id is required")
    }
    if !currency.IsValid() {
        return nil, errors.New("invalid currency")
    }
 
    now := time.Now().UTC()
    return &Account{
        id:        id,
        ownerID:   ownerID,
        currency:  currency,
        balance:   NewMoney(0, currency),
        status:    AccountStatusActive,
        createdAt: now,
        updatedAt: now,
    }, nil
}
 
// ID returns the account's unique identifier.
func (a *Account) ID() string { return a.id }
 
// Credit adds money to the account. It enforces the domain rule
// that only active accounts can receive credits.
func (a *Account) Credit(amount Money) error {
    if a.status != AccountStatusActive {
        return errors.New("cannot credit inactive account")
    }
    if amount.Currency() != a.currency {
        return errors.New("currency mismatch")
    }
    if amount.MinorUnits() <= 0 {
        return errors.New("credit amount must be positive")
    }
 
    a.balance = a.balance.Add(amount)
    a.updatedAt = time.Now().UTC()
    return nil
}

Notice that all fields are unexported. The only way to create an Account is through the NewAccount constructor, and the only way to modify it is through methods that enforce business rules. This is the Go equivalent of making setters private in object-oriented languages.

Value objects are simpler:

// internal/accounts/domain/money.go
package domain
 
// Money is a value object. Two Money values are equal if their
// minor units and currency are equal. Money has no identity.
type Money struct {
    minorUnits int64
    currency   Currency
}
 
func NewMoney(minorUnits int64, currency Currency) Money {
    return Money{minorUnits: minorUnits, currency: currency}
}
 
func (m Money) MinorUnits() int64  { return m.minorUnits }
func (m Money) Currency() Currency { return m.currency }
 
func (m Money) Add(other Money) Money {
    return Money{
        minorUnits: m.minorUnits + other.minorUnits,
        currency:   m.currency,
    }
}
 
func (m Money) Subtract(other Money) (Money, error) {
    if other.minorUnits > m.minorUnits {
        return Money{}, ErrInsufficientFunds
    }
    return Money{
        minorUnits: m.minorUnits - other.minorUnits,
        currency:   m.currency,
    }, nil
}
 
// Currency is a value object represented as a simple string type.
type Currency string
 
const (
    CurrencyEGP Currency = "EGP"
    CurrencyUSD Currency = "USD"
    CurrencyEUR Currency = "EUR"
)
 
func (c Currency) IsValid() bool {
    switch c {
    case CurrencyEGP, CurrencyUSD, CurrencyEUR:
        return true
    default:
        return false
    }
}

The Money type is a struct with value semantics. It is immutable: Add and Subtract return new Money values rather than modifying the receiver. This makes it safe to pass around without worrying about aliasing bugs.

Repositories as Interfaces

In DDD, a repository provides the illusion of an in-memory collection of aggregates. In Go, we define the repository as an interface in the app package and implement it in infra.

// internal/accounts/app/repository.go
package app
 
import (
    "context"
 
    "github.com/klivvr/andromeda/internal/accounts/domain"
)
 
// AccountRepository defines the persistence contract.
// It lives in the app layer because it is part of the use case definition.
type AccountRepository interface {
    FindByID(ctx context.Context, id string) (*domain.Account, error)
    FindByOwnerID(ctx context.Context, ownerID string, limit, offset int) ([]*domain.Account, error)
    Save(ctx context.Context, account *domain.Account) error
}

The implementation in infra uses PostgreSQL:

// internal/accounts/infra/postgres_repository.go
package infra
 
import (
    "context"
    "database/sql"
    "errors"
 
    "github.com/klivvr/andromeda/internal/accounts/app"
    "github.com/klivvr/andromeda/internal/accounts/domain"
)
 
type PostgresAccountRepository struct {
    db *sql.DB
}
 
func NewPostgresAccountRepository(db *sql.DB) *PostgresAccountRepository {
    return &PostgresAccountRepository{db: db}
}
 
// Compile-time check that PostgresAccountRepository implements AccountRepository.
var _ app.AccountRepository = (*PostgresAccountRepository)(nil)
 
func (r *PostgresAccountRepository) FindByID(ctx context.Context, id string) (*domain.Account, error) {
    row := r.db.QueryRowContext(ctx,
        `SELECT id, owner_id, currency, balance_minor_units, status, created_at, updated_at
         FROM accounts WHERE id = $1`, id)
 
    return scanAccount(row)
}
 
func (r *PostgresAccountRepository) Save(ctx context.Context, account *domain.Account) error {
    _, err := r.db.ExecContext(ctx,
        `INSERT INTO accounts (id, owner_id, currency, balance_minor_units, status, created_at, updated_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         ON CONFLICT (id) DO UPDATE SET
             balance_minor_units = EXCLUDED.balance_minor_units,
             status = EXCLUDED.status,
             updated_at = EXCLUDED.updated_at`,
        account.ID(), account.OwnerID(), string(account.Currency()),
        account.Balance().MinorUnits(), string(account.Status()),
        account.CreatedAt(), account.UpdatedAt(),
    )
    return err
}

The compile-time interface check (var _ app.AccountRepository = ...) is a Go idiom we use religiously. It catches interface drift at compile time rather than at runtime.

Application Services

Application services orchestrate domain objects to fulfill use cases. They are the entry point for all business operations and the primary unit of testing.

// internal/accounts/app/service.go
package app
 
import (
    "context"
    "fmt"
 
    "github.com/klivvr/andromeda/internal/accounts/domain"
    "github.com/klivvr/andromeda/pkg/idgen"
)
 
type AccountService struct {
    repo      AccountRepository
    publisher EventPublisher
    idGen     idgen.Generator
}
 
func NewAccountService(repo AccountRepository, pub EventPublisher, gen idgen.Generator) *AccountService {
    return &AccountService{repo: repo, publisher: pub, idGen: gen}
}
 
func (s *AccountService) CreateAccount(ctx context.Context, ownerID string, currency string) (*domain.Account, error) {
    cur := domain.Currency(currency)
    id := s.idGen.New()
 
    account, err := domain.NewAccount(id, ownerID, cur)
    if err != nil {
        return nil, fmt.Errorf("creating account: %w", err)
    }
 
    if err := s.repo.Save(ctx, account); err != nil {
        return nil, fmt.Errorf("saving account: %w", err)
    }
 
    _ = s.publisher.AccountCreated(ctx, id, ownerID, currency)
 
    return account, nil
}
 
func (s *AccountService) GetAccount(ctx context.Context, id string) (*domain.Account, error) {
    account, err := s.repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("finding account %s: %w", id, err)
    }
    return account, nil
}
 
func (s *AccountService) CreditAccount(ctx context.Context, accountID string, amount int64, currency string) error {
    account, err := s.repo.FindByID(ctx, accountID)
    if err != nil {
        return fmt.Errorf("finding account: %w", err)
    }
 
    money := domain.NewMoney(amount, domain.Currency(currency))
    if err := account.Credit(money); err != nil {
        return fmt.Errorf("crediting account: %w", err)
    }
 
    if err := s.repo.Save(ctx, account); err != nil {
        return fmt.Errorf("saving account: %w", err)
    }
 
    return nil
}

Notice that the service depends only on interfaces (AccountRepository, EventPublisher, idgen.Generator). This makes it trivially testable with mocks or fakes.

Domain Errors

Domain errors deserve their own treatment. In Go, we define sentinel errors and typed errors in the domain package and provide helper functions in the app package for classification.

// internal/accounts/domain/errors.go
package domain
 
import "errors"
 
var (
    ErrAccountNotFound   = errors.New("account not found")
    ErrInsufficientFunds = errors.New("insufficient funds")
    ErrAccountInactive   = errors.New("account is inactive")
)
 
type ValidationError struct {
    Field   string
    Message string
}
 
func (e *ValidationError) Error() string {
    return e.Field + ": " + e.Message
}

The gRPC port layer maps these domain errors to appropriate gRPC status codes. The NATS subscriber layer maps them to ack, nak, or term decisions. The domain itself remains blissfully unaware of transport concerns.

Conclusion

DDD in Go is less about patterns and more about discipline. Go gives you the tools, packages for boundaries, interfaces for contracts, structs for data, and methods for behavior, but it does not enforce any particular architecture. That is your job. The layered architecture, unexported entity fields, value objects with immutable semantics, repository interfaces, and application services described here form a practical, tested approach to building domain-rich backend services.

The key insight is that Go's simplicity is your ally. When a pattern requires more machinery than the language naturally provides, it is probably the wrong pattern for Go. Keep your domain types plain, your interfaces small, your layers explicit, and your tests focused on behavior rather than structure. The result is a codebase that is easy to read, easy to test, and easy to change, which is the entire point of Domain-Driven Design.

Related Articles

technical

Testing Strategies for Go Backend Services

A comprehensive guide to testing Go backend services, covering unit tests, integration tests, end-to-end tests, table-driven patterns, test fixtures, and strategies for testing gRPC and NATS-based systems.

11 min read
business

How Monorepos Boost Team Productivity

An exploration of how monorepo architecture improves developer velocity, code quality, and cross-team collaboration, based on real-world experience with Andromeda.

9 min read
technical

Observability in Go: Tracing, Metrics, and Logging

A practical guide to implementing observability in Go backend services using OpenTelemetry for tracing, Prometheus for metrics, and structured logging with log/slog.

7 min read