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.
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
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.
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.
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.