Elegant Domain-Driven Design objects in Go
Source: Dev.to
Example Scenario
“How do we encourage customers to return to our coffee shop by rewarding repeat purchases?”
Core Problems
- Customers buy coffee.
- We want to give them points for each purchase.
- Points expire after 30 days.
- We can show a leaderboard and give rewards (e.g., free coffee).
After several collaborations with domain experts we identified:
- Entity –
Customer(contains customer info and their coffee orders) - Value Object –
CoffeeOrder(contains information about a single order)
1️⃣ Define the Value Object (CoffeeOrder)
Value objects are immutable objects representing descriptive aspects of a domain, defined solely by their attributes (values) rather than a unique identity.
type CoffeeOrder struct {
// Size of the ordered coffee.
Size string
// OrderBy is the user ID that placed the order.
OrderBy int
// OrderTime is when the order happened.
OrderTime time.Time
}
There is no
IDorCoffeeOrderID– a value object is identified only by its fields.
2️⃣ Define the Entity (Customer)
Entities are objects defined by a unique identity, possessing a lifecycle and continuity through state changes.
type Customer struct {
// ID of the customer.
ID int
// Name of the customer.
Name string
// CoffeeOrders holds the orders of this customer.
CoffeeOrders []CoffeeOrder
}
3️⃣ Business Logic Example
// PointsAt returns the points of a customer at a given moment in time.
func (c *Customer) PointsAt(ref time.Time) int {
points := 0
for _, co := range c.CoffeeOrders {
points += co.Points(ref)
}
return points
}
While this works, there are several improvements we can make—especially around immutability of the value object.
🔒 Achieving Immutability for CoffeeOrder
Why the current implementation isn’t immutable
- Fields are exported, so callers can mutate them directly (
co.OrderBy = 5). - The struct can be instantiated without validation (
co := CoffeeOrder{}), producing an invalid zero‑value object.
Steps to enforce immutability
- Make fields unexported – prevents external mutation.
- Provide a constructor – validates input and returns a fully‑initialized object.
- Expose behavior through an interface – hides the concrete implementation.
- Use value receivers – avoids accidental modifications of the underlying data.
3.1 Define Domain‑Specific Types
Using custom types over primitives adds semantic meaning and lets us attach validation logic.
type CustomerID int
type CoffeeSize string
// IsValid reports whether the coffee size is one of the allowed values.
func (cs CoffeeSize) IsValid() bool {
switch cs {
case "small", "medium", "large":
return true
default:
return false
}
}
3.2 Define the Interface and Private Implementation
type CoffeeOrder interface {
Size() CoffeeSize
OrderBy() CustomerID
OrderTime() time.Time
Points(ref time.Time) CoffeePoints
// x is an unexported method that guarantees the implementation
// can only be defined in this package.
x()
}
type coffeeOrder struct {
size CoffeeSize
orderBy CustomerID
orderTime time.Time
}
// Ensure coffeeOrder implements CoffeeOrder.
var _ CoffeeOrder = (*coffeeOrder)(nil)
// Unexported method to satisfy the interface.
func (c *coffeeOrder) x() {}
3.3 Constructor with Validation
func NewCoffeeOrder(
size CoffeeSize,
orderBy CustomerID,
orderTime time.Time,
) (CoffeeOrder, error) {
if !size.IsValid() {
return nil, errors.New("invalid coffee size")
}
// Additional business‑rule checks can be added here.
return &coffeeOrder{
size: size,
orderBy: orderBy,
orderTime: orderTime,
}, nil
}
3.4 Implement the Interface Methods
func (c *coffeeOrder) Size() CoffeeSize { return c.size }
func (c *coffeeOrder) OrderBy() CustomerID { return c.orderBy }
func (c *coffeeOrder) OrderTime() time.Time { return c.orderTime }
// Points calculates the points earned for this order at the given reference time.
func (c *coffeeOrder) Points(ref time.Time) CoffeePoints {
// Example logic: 1 point per coffee, expires after 30 days.
if ref.Sub(c.orderTime) > 30*24*time.Hour {
return 0
}
return 1
}
Now CoffeeOrder is:
- Immutable – fields cannot be changed after construction.
- Validated – the constructor enforces business rules.
- Encapsulated – callers interact only through the exported interface.
4️⃣ Updating the Entity to Use the New Value Object
type Customer struct {
ID CustomerID
Name string
// Store the interface to keep the entity decoupled from the concrete type.
CoffeeOrders []CoffeeOrder
}
// PointsAt returns the total points of a customer at a particular moment.
func (c *Customer) PointsAt(ref time.Time) CoffeePoints {
var total CoffeePoints
for _, order := range c.CoffeeOrders {
total += order.Points(ref)
}
return total
}
💡 Takeaways
| ✅ | Recommendation |
|---|---|
Define custom types (e.g., CustomerID, CoffeeSize) to give domain meaning to primitive values. | |
| Hide implementation behind interfaces to prevent external code from constructing invalid objects. | |
| Never export fields of value objects; use constructors that enforce invariants. | |
| Prefer value receivers for immutable objects; use pointer receivers only when mutation is required. | |
| Defensive copying is required when a value object receives mutable inputs (slices, maps, etc.). |
By following these patterns you can model DDD concepts in Go while keeping your domain layer type‑safe, expressive, and maintainable. Happy coding!
Custom Errors for Domain Validation
When validating a value supplied by the user—such as a coffee size—you should return a domain‑specific error rather than a generic errors.New. The error should carry enough context for the caller to understand what went wrong.
// Declare that the custom error implements the error interface.
var _ error = new(WrongCoffeeSizeError)
type WrongCoffeeSizeError struct {
Size string
}
// Error implements the error interface.
func (e WrongCoffeeSizeError) Error() string {
return fmt.Sprintf("invalid coffee size: %s", e.Size)
}
You can then use the custom error like this:
if !size.IsValid() {
return CoffeeOrder{}, WrongCoffeeSizeError{Size: string(size)}
}
Services in DDD
In Domain‑Driven Design, services orchestrate business operations that sit above the domain layer. For example, you could create a service that:
- Retrieves all
CoffeeOrders placed in the last 30 days. - Fetches the
Customers associated with those orders. - Sorts the customers by their points to produce a leaderboard.
Enforcing Practices with a Linter
Many Go linters exist, but none focus specifically on DDD patterns. To fill that gap, I built a linter—godddlint—that checks for the following conventions:
- Immutability for value objects.
- Custom types over primitive types for entities.
- Correct use of pointer vs. non‑pointer receivers for entities and value objects.
- No
errors.Newinside method return statements (encourage domain‑specific errors).
The linter is still under heavy development, but it can help you write more expressive and robust domain models.
godddlint – a DDD‑focused linter you might find useful.
Recap
In this article we covered several DDD concepts as well as related best practices:
- Immutability.
- Creating domain‑specific types for compile‑time safety.
- Defining custom domain errors.
What about you?
What patterns have you found effective for DDD in Go?
Share your experiences in the comments!