How to Implement Domain-Driven Design (DDD) in Go

Implement DDD in Go by separating domain logic, application services, and infrastructure into distinct packages with clear interfaces.

The tangled ball of yarn

You are three months into a Go project. The models package has grown to forty files. The User struct has a method called SaveToPostgres. The Order struct has a method called SendSlackNotification. You need to switch the database to MySQL. Now you have to touch every model file. You need to write tests. Now you have to refactor every model to accept a mock database. You change a field in User and the build fails in notifications, billing, and analytics.

This is the leaky abstraction trap. Your business logic is glued to your database driver and your HTTP server. Domain-Driven Design stops the bleed by enforcing boundaries. In Go, those boundaries are packages. DDD in Go is not about heavy frameworks or magic decorators. It is about organizing code so the core business rules live in a package that knows nothing about SQL, Redis, or HTTP.

Bounded contexts and package boundaries

Domain-Driven Design introduces the concept of a bounded context. A bounded context is a boundary where a particular model applies. Inside that boundary, terms have specific meanings and rules hold true. Outside the boundary, the same terms might mean something different.

In Go, a bounded context usually maps to a package or a module. The compiler enforces the boundary. If your domain package imports database/sql, the boundary is broken. The domain now depends on infrastructure. You cannot swap the database without touching the domain. You cannot test the domain without a database.

Think of a restaurant kitchen. The chef needs ingredients. The chef does not care if the ingredients come from a local farm, a global distributor, or a walk-in freezer. The chef just needs "tomatoes". The interface is "give me tomatoes". The implementation is the supply chain. The kitchen (domain) defines what a tomato is and how to use it. The supply chain (infrastructure) handles the logistics.

The domain package should compile without a database driver installed. If it compiles, your boundaries are clean.

The domain owns the rules

Start by defining your core entities and interfaces in a domain package. This package contains structs that represent business concepts and interfaces that define how the world interacts with them. The domain package has no external dependencies. It imports only the standard library for basic types like time or errors.

Here is a minimal domain package. The User struct holds state and behavior. The UserRepository interface defines how the domain retrieves users. The domain does not know about SQL. It only knows that someone will provide a user given an ID.

// domain/user.go
package domain

import "errors"

// User represents a core business concept with behavior.
// The domain owns the rules for what makes a valid user.
type User struct {
	ID   string
	Name string
}

// Validate checks business invariants.
// This logic lives here, not in the HTTP handler.
func (u *User) Validate() error {
	if u.Name == "" {
		return errors.New("user name cannot be empty")
	}
	return nil
}

// UserRepository defines how the domain retrieves users.
// The domain doesn't care about SQL, Redis, or mocks.
type UserRepository interface {
	FindByID(id string) (*User, error)
}

The receiver name is one or two letters matching the type. Use (u *User), not (this *User) or (self *User). This is a Go convention that keeps code readable. The Validate method enforces a business rule. If the name is empty, the user is invalid. This logic belongs on the struct, not scattered across handlers.

The domain owns the truth. Infrastructure serves the domain.

Infrastructure serves the domain

The infrastructure layer implements the interfaces defined by the domain. This is where you use database/sql, redis, or http. The infrastructure package imports the domain package. It never imports other infrastructure packages if it can avoid it. This keeps the dependency graph pointing inward toward the domain.

Here is a PostgreSQL implementation of the repository. It satisfies the domain.UserRepository interface. It returns domain types, not database rows. The conversion happens here, shielding the domain from database specifics.

// infrastructure/postgres/user_repo.go
package postgres

import (
	"context"
	"database/sql"
	"yourapp/domain"
)

// userRepository implements domain.UserRepository using PostgreSQL.
type userRepository struct {
	db *sql.DB
}

// NewUserRepository creates a repository backed by a database connection.
func NewUserRepository(db *sql.DB) domain.UserRepository {
	return &userRepository{db: db}
}

// FindByID queries the database and returns a domain.User.
// The domain never sees the SQL query or the table schema.
func (r *userRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
	var name string
	err := r.db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id).Scan(&name)
	if err != nil {
		// Wrap the error with context for debugging.
		// The domain gets a clean error; the caller gets details.
		return nil, err
	}
	return &domain.User{ID: id, Name: name}, nil
}

The FindByID method takes a context.Context as the first parameter. Context is plumbing. Run it through every long-lived call site. It allows cancellation and deadlines to propagate from the HTTP handler down to the database query. The function returns a *domain.User. The infrastructure layer converts raw data into domain objects.

The receiver name matches the type abbreviation. (r *userRepository) is standard. The error handling follows the verbose pattern. if err != nil { return nil, err } is boilerplate by design. It makes the unhappy path visible. Do not hide errors.

Wiring it all together

Go does not have a dependency injection framework. You wire dependencies manually in your main package or a cmd package. This is a feature, not a bug. Manual wiring is explicit. You can see exactly what depends on what. There is no magic reflection scanning your code at startup.

The wiring code creates concrete implementations and passes them to constructors. The service layer sits between the domain and the infrastructure. It orchestrates use cases. It calls the repository, applies domain logic, and returns results.

// application/user_service.go
package application

import (
	"context"
	"yourapp/domain"
)

// userService orchestrates user-related use cases.
// It depends on the domain interface, not the concrete repo.
type userService struct {
	repo domain.UserRepository
}

// NewUserService creates a service with a repository dependency.
func NewUserService(repo domain.UserRepository) *userService {
	return &userService{repo: repo}
}

// GetUser retrieves a user and validates domain rules.
// The service coordinates data access and business logic.
func (s *userService) GetUser(ctx context.Context, id string) (*domain.User, error) {
	user, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if err := user.Validate(); err != nil {
		return nil, err
	}
	return user, nil
}

The service accepts the domain.UserRepository interface. This means you can pass a PostgreSQL repo, a mock repo for tests, or a cache-backed repo. The service does not care. It calls the interface. This is the "accept interfaces, return structs" mantra in action. Functions accept interfaces to be flexible. They return structs to be concrete.

In main, you bring it all together.

// cmd/server/main.go
package main

import (
	"log"
	"net/http"
	"yourapp/application"
	"yourapp/infrastructure/postgres"
)

func main() {
	// Open database connection in infrastructure layer.
	db := postgres.Connect()

	// Wire dependencies: repo implements domain interface.
	repo := postgres.NewUserRepository(db)

	// Service uses the interface, not the concrete repo.
	svc := application.NewUserService(repo)

	// Handler depends on the service.
	http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
		// Handler logic here using svc.GetUser
	})

	log.Fatal(http.ListenAndServe(":8080", nil))
}

The main package is the only place that knows about both the service and the PostgreSQL repo. It creates the graph. Everything else depends on abstractions. Trust gofmt. Argue logic, not formatting. Most editors run gofmt on save. Let the tool decide indentation.

Pitfalls and compiler errors

DDD in Go relies on package boundaries. The compiler helps you enforce these boundaries, but you can still make mistakes.

The most common error is a circular dependency. This happens when the domain package imports infrastructure, or two infrastructure packages import each other. The compiler rejects the program with import cycle not allowed. If you see this error, you have crossed a boundary. Move the interface to the domain package. Move the shared type to a common package. Never let the domain import infrastructure.

Another pitfall is the anemic domain model. This occurs when your domain structs have no behavior. They are just bags of fields. All the logic lives in the service layer. If your User struct only has getters and setters, you are just doing CRUD with extra steps. Put the logic in the struct methods. func (u *User) ApplyDiscount(amount int) error belongs on the user, not in a service. The service should coordinate, not calculate.

Interface satisfaction is checked at compile time. If your infrastructure struct does not implement all methods of the domain interface, the compiler complains. You might get cannot use repo (type *postgres.UserRepository) as domain.UserRepository in argument. This error means the method signatures do not match. Check parameter types, return types, and receiver pointers. A mismatch in a single parameter type breaks the interface.

Do not create an interface for every single method. Interfaces should be small and focused. io.Reader has one method. UserRepository might have a few. If you find yourself creating UserReader, UserWriter, UserDeleter, you are over-engineering. Start with a single interface. Split it only when you have a concrete reason to swap one part but not another.

The worst goroutine bug is the one that never logs. When spawning background tasks in your infrastructure, ensure you have a cancellation path. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always pass context and respect cancellation.

When to use DDD

Domain-Driven Design adds structure and discipline. It also adds cognitive overhead. Use the right tool for the job.

Use DDD when your business rules are complex and change frequently. If your product is the logic, not the data, DDD pays off. The boundaries protect you from accidental coupling. The domain package becomes the source of truth.

Use a simple layered architecture when you need structure but the domain logic is thin. If you are building a dashboard or a CRUD API, DDD might be overkill. A handlers, services, models, and db package structure is often enough. Keep the layers, but relax the strict interface boundaries.

Use a single package when the project is a prototype, a script, or a tiny tool. If the code fits in one file, do not split it into bounded contexts. Simplicity is the ultimate sophistication.

Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing. DDD is about organization, not concurrency. Do not conflate the two.

Boundaries save you from future pain. Define them early, enforce them with packages, and let the compiler guard the gates.

Where to go next