45.4 Domain-Driven Design Concepts Adapted for Go
Look, let’s get one thing straight: Domain-Driven Design isn’t a library you import. You can’t go get it. It’s a way of thinking, a set of principles for taming complexity. And in Go, which prizes simplicity and pragmatism above all else, applying DDD is less about rigidly following every pattern from the book and more about stealing the good ideas and adapting them to fit the language’s ethos. We’re going to focus on the concepts that give you the most bang for your buck without turning your code into an abstract factory factory.
The Ubiquitous Language is Your Best Friend
The single most important concept from DDD is the Ubiquitous Language. This is the practice of building a shared, precise language between your developers and your domain experts. In Go, this isn’t just a fluffy idea you talk about in meetings; it’s something you encode directly into your code. Your structs, interfaces, and method names should reflect the business domain, not your database schema.
If your business expert says “a user places an order,” you shouldn’t have a function called InsertOrder(ctx context.Context, userID int, items []int) error. That’s database-speak. The code should scream the domain language. Let’s get specific.
// Bad: Leaky abstraction, focused on persistence
err := db.InsertOrder(ctx, userID, []int{101, 205})
// Good: The code *is* the ubiquitous language
order, err := customer.PlaceOrder(ctx, basket)
The PlaceOrder method doesn’t care if it’s saving to PostgreSQL, Bigtable, or a flat file. It’s a business operation. This makes the code self-documenting and drastically reduces the mental overhead of translating between what the business says and what the code does.
Entities and Value Objects: A Go Perspective
In DDD, an Entity is an object defined not by its attributes but by a thread of continuity and identity (e.g., a User with an ID). A Value Object is an immutable object described solely by its attributes (e.g., an Address). How does this map to Go?
Entities are your structs with an ID field. The key here is to not let your database’s idea of identity (an auto-incrementing integer) leak into your core domain. An ID can be a UUID, a string, an aggregate ID—whatever makes sense for the business.
// Customer is an Entity
type Customer struct {
ID CustomerID // This is a domain concept, not just a `int`
Name string
// ... other fields
}
// CustomerID is a typed string to avoid primitives. This is a huge win for clarity and type-safety.
type CustomerID string
func (id CustomerID) String() string { return string(id) }
Value Objects are where Go really shines. We use composition and leverage the fact that structs are value types. The critical rule: they must be immutable. Once created, they cannot be changed. You don’t set properties on a Value Object; you create a new one.
// Address is a Value Object
type Address struct {
Street string
City string
PostalCode string
}
// Function to create a new Address or return an error. No partial updates!
func NewAddress(street, city, postalCode string) (Address, error) {
// Validate everything here first
if street == "" || city == "" {
return Address{}, errors.New("street and city are required")
}
return Address{Street: street, City: city, PostalCode: postalCode}, nil
}
// Notice there are no SetStreet() methods. It's immutable.
Aggregates: The Guardians of Invariants
An Aggregate is a cluster of associated objects treated as a single unit for data changes. It has a root (an Entity) that acts as the gatekeeper for the entire cluster. This is arguably the most misunderstood concept. In Go, an Aggregate isn’t a fancy framework type; it’s just a struct that holds other structs and is responsible for maintaining business rules (invariants).
Think of an Order and its OrderItems. You can’t have an order with a negative total. The Order aggregate root is responsible for ensuring this rule is never broken.
// Order is the Aggregate Root
type Order struct {
ID OrderID
CustomerID CustomerID
Items []OrderItem // Items are within the Order's boundary
Total Money
Status OrderStatus
}
// AddItem is the *only* way to add an item. It maintains the invariant.
func (o *Order) AddItem(productID ProductID, price Money, quantity int) error {
if o.Status != OrderStatusDraft {
return errors.New("cannot add items to a submitted order")
}
if quantity <= 0 {
return errors.New("quantity must be positive")
}
lineTotal := price.Multiply(quantity)
o.Items = append(o.Items, OrderItem{
ProductID: productID,
Price: price,
Quantity: quantity,
Total: lineTotal,
})
o.Total = o.Total.Add(lineTotal) // Recalculate the aggregate's total
return nil
}
The pitfall here is being lazy and letting external code manipulate Order.Items directly. Don’t do it! The aggregate’s methods are the public API for changing its state. This is how you prevent the entire codebase from being responsible for knowing every single business rule.
Repositories: Persistence without Pollution
The Repository pattern provides a collection-like interface for accessing aggregates. Its job is to mediate between the domain and the data mapping layers. In Go, this almost always means defining an interface first, in your domain layer, and implementing it elsewhere (like in an internal/postgres package).
The beauty of this is your core business logic becomes completely decoupled from the database. It depends on an interface it owns.
// Domain layer: defines what we can *do*
package order
// Repository is defined in terms of domain objects, not SQL
type Repository interface {
FindByID(ctx context.Context, id OrderID) (*Order, error)
FindByCustomerID(ctx context.Context, customerID CustomerID) ([]*Order, error)
Save(ctx context.Context, order *Order) error // Insert or Update
}
Your implementation in, say, postgres.OrderRepository, will handle the messy details of mapping the Order aggregate to one or many SQL tables. Your use cases or services then depend only on the order.Repository interface, making them trivially easy to test with a mock or in-memory implementation.
Services: When an Operation Doesn’t Fit
Sometimes, a business operation doesn’t neatly fit on a single aggregate. This is where Domain Services come in. In Go, this is often just a plain struct that takes its dependencies (like repositories) as interfaces.
The key is to keep the service focused on the domain logic. It should coordinate between aggregates, not become a dumping ground for all your code. If you find yourself writing a GodService, you’ve missed the point of aggregates.
type OrderService struct {
orderRepo order.Repository
customerRepo customer.Repository
paymentGateway payment.Provider // another interface!
}
func (s *OrderService) PlaceOrder(ctx context.Context, customerID customer.ID, items []Item) (*Order, error) {
// 1. Fetch the Customer aggregate
c, err := s.customerRepo.FindByID(ctx, customerID)
if err != nil {
return nil, err
}
// 2. Create a new Order aggregate
newOrder, err := c.CreateOrder(items) // Domain logic on the Customer entity
if err != nil {
return nil, err
}
// 3. Maybe call an external payment provider?
err = s.paymentGateway.Charge(ctx, newOrder.Total)
if err != nil {
return nil, fmt.Errorf("payment failed: %w", err)
}
// 4. Persist the changed state
newOrder.Status = OrderStatusConfirmed
if err := s.orderRepo.Save(ctx, newOrder); err != nil {
// Here be dragons: compensating transactions for the payment might be needed!
return nil, err
}
return newOrder, nil
}
The takeaway? DDD in Go is about using the language’s strengths—simple structs, interfaces, and composition—to create a codebase that reflects your business reality. It’s about being intentional with your boundaries and names. It’s not about cargo-culting a bunch of complex patterns. It’s about writing code that is, above all else, clear and maintainable. And if you do it right, you’ll end up with a system that is far easier to change when your domain inevitably does.