Elegant Domain-Driven Design objects in Go

Published: (January 19, 2026 at 12:41 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

Example Scenario

“How do we encourage customers to return to our coffee shop by rewarding repeat purchases?”

Core Problems

  1. Customers buy coffee.
  2. We want to give them points for each purchase.
  3. Points expire after 30 days.
  4. We can show a leaderboard and give rewards (e.g., free coffee).

After several collaborations with domain experts we identified:

  • EntityCustomer (contains customer info and their coffee orders)
  • Value ObjectCoffeeOrder (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 ID or CoffeeOrderID – 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

  1. Make fields unexported – prevents external mutation.
  2. Provide a constructor – validates input and returns a fully‑initialized object.
  3. Expose behavior through an interface – hides the concrete implementation.
  4. 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:

  1. Retrieves all CoffeeOrders placed in the last 30 days.
  2. Fetches the Customers associated with those orders.
  3. 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.New inside 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!

Back to Blog

Related posts

Read more »