C ontrary to what most people think, DDD is not about folder naming. It is not about having directories called domain and infrastructure and calling it a day.

Here is my lightweight approach that helps you think about what your app domain actually is. The concept is straightforward: you see a domain, you create a directory for it. The real value is not in the structure itself — it is in the thinking the structure forces you to do.

The second half of this article is the long version: the building blocks, the layer rules, the anti-patterns, and the practices I actually enforce — taken from PayMeter, a Go billing service where all of this is load-bearing rather than decorative.

What DDD Actually Sells#

Two things, done well, pay for all the complexity:

Clear separation of concerns. Business rules live in the domain layer. They do not leak into HTTP handlers, database queries, or Kafka consumers. When you need to change a business rule, you know exactly where to go.

Testability. When your domain is isolated from infrastructure, you can test it without spinning up a database. Unit tests become fast and deterministic. Integration tests cover the seams, not the logic.

What Actually Goes Wrong#

Theory and reality diverge fast. Three failures, in the order I keep meeting them.

A struct in the root package that everyone refers to. The root-level models package becomes a catch-all. Every service imports it, it grows, and fields that started with clear meaning blur. Engineers stop reusing fields because they cannot tell who else depends on them, so they duplicate instead — bloated interfaces, partial updates, and fields that mean slightly different things in different places. The package was supposed to create clarity. It created a sprawl of dependencies.

DDD as a replacement for thinking. Convoluted hierarchies of interfaces and types. Abstractions for every conceivable transport layer when all the team needed was an HTTP API. A models package full of types that belonged next to their use. That may have been an artefact of the person who introduced it being “young, motivated, but inexperienced” — but it soured the whole team on a concept that was not at fault.

Consensus. The hard part is not the code, it is getting a team to agree on how to organise packages. Once one person interprets “domain layer” differently from everyone else, you get a hybrid with all the overhead of DDD and none of the benefits.

The failure mode is never “we used DDD.” It is “we used half of DDD, inconsistently, on a problem that did not need it.”

A Practical Structure That Works#

If you want to try it without going overboard, here is a structure that makes sense:

/infrastructure   — config, database connections, cache setup, bootstrap
/interfaces       — controllers, presenters (HTML/JSON)
/services         — use cases and repositories
/datamodels       — aggregates and domain types, close to their use
  • infrastructure holds anything that touches the outside world: config readers, DB connections, cache clients, the router setup, main bootstrap. One-time initialization.
  • interfaces holds controllers and presenters. It routes requests to the right service method and presents the output.
  • services holds use cases and repository interfaces. Business logic lives here.
  • datamodels holds your aggregates — structs that represent business concepts and can be manipulated and presented.

If you have a large project, you can add subfolders inside interfaces/controllers/ — for example interfaces/controllers/finance. Do the same for services and repos. But only do that when there is a real need, not before.

For shared utilities — error types, logging helpers, things needed across the whole program — root/common is fine. When you start a second project, some of those common things might move to a shared library. But not before you need it.

The Full Version: What It Looks Like at Scale#

Everything above is the sketch. What follows is the version I actually run when a service is big enough to earn it — the layout, the building blocks, and the rules that keep them honest.

The worked example is PayMeter: a metered billing API. Customers subscribe to plans, usage gets recorded, invoices get generated and finalized, payments settle them. It is a good DDD example because the business rules are genuinely non-trivial and getting them wrong costs money.

internal/
├── domain/              ← business rules. imports nothing but stdlib.
│   ├── shared/          ← Money, Currency, BaseEvent
│   ├── customer/
│   ├── billing/
│   └── payment/
├── application/         ← use cases + DTOs. imports domain only.
│   ├── customer/
│   ├── billing/
│   └── payment/
├── infrastructure/      ← adapters. implements domain interfaces.
│   ├── pg/
│   ├── cache/
│   ├── memory/
│   └── mpesa/
├── presentation/        ← HTTP. imports application + domain errors.
│   └── rest/
│       ├── handlers/
│       ├── middleware/
│       └── routes.go
└── foundation/          ← config, logger, otel, di
    └── di/              ← the only package that sees every layer
flowchart TB subgraph PRES["PRESENTATION LAYER"] direction LR H["REST Handlers
Decode the request
Call one use case
Write the response"] MW["Middleware
Auth · JWT
Rate limiting
Tracing · logging"] RT["Routes
URL to handler
Route groups
Middleware chains"] EM["Error Mapping
Domain error to code
404 · 409 · 422
One place only"] end subgraph APP["APPLICATION LAYER"] direction LR UC["Use Cases
Orchestrate, not decide
Call repo interfaces
No HTTP · no SQL"] IN["Input DTOs
CreateSubscriptionInput
Decoded from JSON
One per operation"] OUT["Output DTOs
SubscriptionOutput
Entities never escape
One per operation"] EP["Event Publisher
Dispatches events
Decouples producers
from consumers"] end subgraph DOM["DOMAIN LAYER · the core · no external dependencies"] direction LR RI["Repo Interfaces
InvoiceRepository
Declared here
Implemented outside"] EN["Entities
Customer · Invoice
Subscription · Payment
Private fields, methods"] VO["Value Objects
Money · Currency
PaymentStatus
Immutable, no identity"] DE["Domain Events
InvoicePaid
SubscriptionCreated
Past-tense facts"] end subgraph INF["INFRASTRUCTURE LAYER · implements what domain declares"] direction LR PG["pg/
PostgreSQL repos
SQL to entity
Owns every query"] MP["mpesa/
Payment gateway
Implements the port
Owns retries"] CA["cache/
Redis
Rate limit store
Session store"] MEM["memory/
In-memory repos
Tests and local dev
Same interface"] end H -->|calls use case| UC UC -->|uses interfaces| RI RI <-.-|implements| PG style PRES fill:transparent,stroke:#a5d8ff,stroke-width:1.5px,stroke-dasharray:6 4,color:#a5d8ff style APP fill:transparent,stroke:#b2f2bb,stroke-width:1.5px,stroke-dasharray:6 4,color:#b2f2bb style DOM fill:transparent,stroke:#ffec99,stroke-width:2.5px,color:#ffec99 style INF fill:transparent,stroke:#d0bfff,stroke-width:1.5px,stroke-dasharray:6 4,color:#d0bfff classDef pres fill:transparent,stroke:#a5d8ff,color:#e5e7eb classDef app fill:transparent,stroke:#b2f2bb,color:#e5e7eb classDef dom fill:transparent,stroke:#ffec99,stroke-width:2px,color:#e5e7eb classDef infra fill:transparent,stroke:#d0bfff,color:#e5e7eb class H,MW,RT,EM pres class UC,IN,OUT,EP app class RI,EN,VO,DE dom class PG,MP,CA,MEM infra
The dependency rule: arrows only point inward. infrastructure/ and presentation/ depend on domain/. domain/ depends on nothing outside itself. Every other rule in this article is a consequence of that one.

Layer Rules, Non-Negotiable#

One rule generates all the others: an import may only point inward. Order the layers and the whole policy collapses into three lines.

presentation/   → may import application/, domain/, stdlib, net/http
application/    → may import domain/, stdlib
domain/         → may import stdlib. nothing else. ever.

infrastructure/ sits beside that stack rather than in it. It imports domain/ to implement its interfaces, and it is the only layer allowed to import a database driver or a vendor SDK. It never imports application/ or presentation/ — an adapter has no business knowing who called it.

foundation/di/ is the one deliberate exception. It imports everything, because somebody has to pick the concrete types.

The three that get broken#

net/http in domain/. It usually starts as a single http.StatusConflict on an error type. Now your business rules only make sense over HTTP, and the gRPC endpoint someone adds next quarter has to translate backwards out of a transport it never wanted.

infrastructure/ in application/. A use case takes *pg.InvoiceRepo instead of billing.InvoiceRepository, because it is the only implementation anyway. Every test of that use case now needs Postgres.

infrastructure/ in presentation/. A handler reaches for the repository directly to avoid writing a use case method. That handler is now the only place the rule lives, and no other entry point — a cron job, a queue consumer, a CLI — can reach it.

Each of these is one line of code, and each is invisible in review a month later. So do not review for them:

# .golangci.yml — golangci-lint v2
version: "2"
linters:
  enable: [depguard]
  settings:
    depguard:
      rules:
        domain:
          files: ["**/internal/domain/**"]
          deny:
            - pkg: net/http
              desc: domain must not know about transport
            - pkg: github.com/jackc/pgx
              desc: domain must not know about persistence
            - pkg: github.com/MikeMwita/PayMeter.git/internal/application
              desc: dependencies point inward
            - pkg: github.com/MikeMwita/PayMeter.git/internal/infrastructure
              desc: dependencies point inward
            - pkg: github.com/MikeMwita/PayMeter.git/internal/presentation
              desc: dependencies point inward
        application:
          files: ["**/internal/application/**"]
          deny:
            - pkg: net/http
              desc: use cases take DTOs, not requests
            - pkg: github.com/MikeMwita/PayMeter.git/internal/infrastructure
              desc: depend on the domain interface, not the adapter
        presentation:
          files: ["**/internal/presentation/**"]
          deny:
            - pkg: github.com/MikeMwita/PayMeter.git/internal/infrastructure
              desc: go through a use case — di does the wiring

That block is the architecture document. It is the only version of it that cannot go stale, because the build fails when the code and the diagram disagree.

The Building Blocks#

Six of them. Most confusion about DDD is really confusion about which of these six a given struct is supposed to be.

1. Entity#

An object defined by its identity, not its attributes. Two customers with the same name are still different customers because they have different IDs.

Entities use private fields plus constructor validation. State changes go through methods that enforce business rules.

type Invoice struct {
    id             string
    customerID     string
    subscriptionID string
    status         InvoiceStatus
    lineItems      []*LineItem
    total          shared.Money
    periodStart    time.Time
    periodEnd      time.Time
    paidAt         *time.Time
}

// The constructor is the gatekeeper — invalid state is impossible to construct.
func NewInvoice(id, customerID, subscriptionID string, periodStart, periodEnd, dueDate time.Time, currency shared.Currency) (*Invoice, error) {
    if periodEnd.Before(periodStart) {
        return nil, errors.New("invoice period end must be after period start")
    }

    zeroMoney, _ := shared.NewMoney(0, currency)

    return &Invoice{
        id:          id,
        customerID:  customerID,
        status:      InvoiceStatusDraft,
        lineItems:   []*LineItem{},
        total:       zeroMoney,
        periodStart: periodStart,
        periodEnd:   periodEnd,
        dueDate:     dueDate,
        createdAt:   time.Now().UTC(),
    }, nil
}

State transitions are explicit methods, not free-form field updates:

func (inv *Invoice) MarkPaid() error {
    if inv.status != InvoiceStatusOpen {
        return errors.New("only open invoices can be marked paid")
    }

    now := time.Now().UTC()
    inv.status = InvoiceStatusPaid
    inv.paidAt = &now

    return nil
}

Rule: never expose setters. Every field mutation goes through a method that validates the transition. Getters are fine — func (inv *Invoice) Status() InvoiceStatus { return inv.status } — because reads cannot corrupt state.

Private fields are what make this work in Go. Exported fields mean any package can write garbage into your aggregate, and no method can stop it. If you export the fields, you do not have an entity — you have a struct with extra steps.

2. Value Object#

An object defined by its value, not its identity. Two Money{999, "USD"} instances are equal and interchangeable. Value objects are immutable: methods return new instances, never mutate the receiver.

type Money struct {
    amount   int64    // smallest currency unit — cents, fils
    currency Currency
}

func NewMoney(amount int64, currency Currency) (Money, error) {
    if amount < 0 {
        return Money{}, errors.New("money amount cannot be negative")
    }

    return Money{amount: amount, currency: currency}, nil
}

func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, fmt.Errorf("cannot add %s to %s", other.currency, m.currency)
    }

    // returns a NEW Money — does not modify m
    return Money{amount: m.amount + other.amount, currency: m.currency}, nil
}

func (m Money) Multiply(factor int64) Money {
    return Money{amount: m.amount * factor, currency: m.currency}
}

Note the value receivers. Money is copied on every call, which is exactly what you want — there is no way for a caller to hold a reference and mutate your invoice total behind your back.

The currency check in Add is the point of the whole type. Adding KES to USD is a bug that a float64 will happily commit and never report.

Never use float64 for money. 0.1 + 0.2 != 0.3 and your reconciliation job will find out about it six months in. Store the smallest currency unit as int64 and wrap it in a type that knows its own currency.

3. Aggregate#

A cluster of entities and value objects treated as a single unit for data changes. One entity is the aggregate root — the only entry point for changes.

Invoice is an aggregate root. LineItem is an entity inside that aggregate. Line items can only be added through the invoice, never directly:

func (inv *Invoice) AddLineItem(item *LineItem) error {
    if inv.status != InvoiceStatusDraft {
        return errors.New("can only add line items to draft invoices")
    }

    total, err := inv.total.Add(item.TotalAmount())
    if err != nil {
        return err
    }

    inv.lineItems = append(inv.lineItems, item)
    inv.total = total   // total is always consistent with line items

    return nil
}

Two invariants are enforced in five lines: you cannot bill a customer for a line added after the invoice was finalized, and the total can never drift from the sum of its items. Neither invariant is possible to violate from outside the package.

Rule: repositories save and load aggregate roots, not individual sub-entities. You never have a LineItemRepository. You load the Invoice and reach items through it.

Aggregate boundaries are transaction boundaries. One aggregate, one transaction, one lock. If you find yourself needing to modify two aggregates atomically, that is a signal to either merge them or accept eventual consistency between them via an event.

4. Repository Interface#

A contract that describes what the application needs from persistence, without caring how it is stored. It is defined in the domain package — the domain owns the contract, infrastructure obeys it.

// internal/domain/billing/repository.go
type InvoiceRepository interface {
    Save(ctx context.Context, inv *Invoice) error
    FindByID(ctx context.Context, id string) (*Invoice, error)
    FindBySubscriptionID(ctx context.Context, subscriptionID string) ([]*Invoice, error)
    FindOpenByDueDate(ctx context.Context, before time.Time) ([]*Invoice, error)
    Update(ctx context.Context, inv *Invoice) error
}
// internal/infrastructure/pg/billing/repository.go
type InvoiceRepo struct{ db *pgxpool.Pool }

var _ billing.InvoiceRepository = (*InvoiceRepo)(nil)

func (r *InvoiceRepo) FindByID(ctx context.Context, id string) (*billing.Invoice, error) {
    // SQL lives here. The domain never sees it.
}

This is the Go convention doing real work: interfaces belong in the package that uses them, not the one that implements them. The domain declares its needs; Postgres, DynamoDB, or an in-memory map can satisfy them.

The methods are named in domain language — FindOpenByDueDate, not SelectWhereStatusAndDate. If a repository method name reads like SQL, the query has leaked upward.

The var _ billing.InvoiceRepository = (*InvoiceRepo)(nil) line costs nothing at runtime and turns “this adapter drifted from its interface” into a compile error instead of a wiring panic at startup.

5. Domain Event#

A record of something that already happened in the business. Events are facts, so they are named in the past tense: InvoicePaid, not PayInvoice.

// internal/domain/shared/events.go
type DomainEvent interface {
    EventName() string
    OccurredAt() time.Time
}

type BaseEvent struct {
    name       string
    occurredAt time.Time
}

func NewBaseEvent(name string) BaseEvent {
    return BaseEvent{name: name, occurredAt: time.Now().UTC()}
}
// internal/domain/billing/events.go
const (
    EventSubscriptionCreated = "billing.subscription.created"
    EventInvoiceFinalized    = "billing.invoice.finalized"
    EventInvoicePaid         = "billing.invoice.paid"
)

type InvoicePaidEvent struct {
    shared.BaseEvent
    InvoiceID string
    PaymentID string
}

func NewInvoicePaidEvent(invoiceID, paymentID string) InvoicePaidEvent {
    return InvoicePaidEvent{
        BaseEvent: shared.NewBaseEvent(EventInvoicePaid),
        InvoiceID: invoiceID,
        PaymentID: paymentID,
    }
}

Events decouple producers from consumers. When an invoice is paid, billing emits InvoicePaid. The webhook system listens and dispatches. Neither knows the other exists.

flowchart LR A["Invoice.MarkPaid()"] --> B["InvoicePaidEvent"] B --> C["webhook: POST customer endpoint"] B --> D["email: send receipt"] B --> E["metrics: paid_invoices++"]

Adding a fourth consumer requires zero changes to billing. That is the entire return on the abstraction, and it only pays off if the event carries IDs and facts rather than a pointer to a live aggregate.

Events carry data, not references. InvoicePaidEvent holds an InvoiceID string, not an *Invoice. Hand a consumer a live pointer and you have handed it the ability to mutate your aggregate from another goroutine.

6. Domain Service#

A stateless function containing business logic that does not naturally belong to a single entity. Proration is the classic case: it involves a Plan, a Subscription, and Money, and belongs to none of them.

// internal/domain/billing/proration.go
func CalculateProration(currentPlan, newPlan *Plan, sub *Subscription, upgradeDate time.Time) (shared.Money, error) {
    total := sub.CurrentPeriodEnd().Sub(sub.CurrentPeriodStart())
    if total <= 0 {
        return shared.Money{}, ErrInvalidBillingPeriod
    }

    remaining := sub.CurrentPeriodEnd().Sub(upgradeDate)
    if remaining < 0 {
        remaining = 0
    }

    // integer arithmetic throughout — no float rounding on money
    ratioNum := int64(remaining / time.Minute)
    ratioDen := int64(total / time.Minute)

    credit := currentPlan.UnitPrice().Amount() * ratioNum / ratioDen
    charge := newPlan.UnitPrice().Amount() * ratioNum / ratioDen

    return shared.NewMoney(charge-credit, newPlan.UnitPrice().Currency())
}

Rule: domain services are pure functions. No DB calls, no HTTP, no clock reads if you can avoid it — pass upgradeDate in rather than calling time.Now() inside, so the function is testable at any point in the billing cycle. If you need to fetch something, fetch it in the use case and pass it down.

The Application Layer Does Not Contain Rules#

The use case is a coordinator. It fetches, delegates, persists, and translates — and it makes no decisions of its own.

type UseCase struct {
    repo customer.Repository
    log  *slog.Logger
}

func NewUseCase(repo customer.Repository, log *slog.Logger) *UseCase {
    return &UseCase{repo: repo, log: log}
}

func (uc *UseCase) CreateCustomer(ctx context.Context, input CreateCustomerInput) (*CustomerOutput, error) {
    exists, err := uc.repo.ExistsByEmail(ctx, input.Email)
    if err != nil {
        return nil, fmt.Errorf("checking existing customer: %w", err)
    }

    if exists {
        return nil, customer.ErrAlreadyExists
    }

    c, err := customer.NewCustomer(uuid.NewString(), input.Name, input.Email)
    if err != nil {
        return nil, fmt.Errorf("%w: %s", customer.ErrInvalidInput, err)
    }

    if err := uc.repo.Save(ctx, c); err != nil {
        return nil, fmt.Errorf("saving customer: %w", err)
    }

    uc.log.InfoContext(ctx, "customer created", slog.String("customer_id", c.ID()))
    return toOutput(c), nil
}

Three things to notice.

The use case depends on customer.Repository — the interface, not *pg.CustomerRepo. Swap in memory.NewCustomerRepository() and the test needs no database.

Validation of the name and email happens inside customer.NewCustomer, not here. The use case only decides what order things happen in.

The return type is *CustomerOutput, a DTO, not *customer.Customer. The entity never escapes the application layer.

DTOs are a boundary, not boilerplate. Returning the entity would let a handler call c.UpdateName() — a business operation — while writing a JSON response. Worse, entity fields are private, so they would not serialize anyway. The DTO is where you decide what the outside world is allowed to see.

A Full Request Lifecycle#

POST /api/v1/billing/subscriptions, end to end:

1. presentation/rest/middleware/auth.go
       validates JWT, attaches customer ID to context

2. presentation/rest/handlers/billing.go
       decodes JSON body → application/billing.CreateSubscriptionInput
       calls uc.CreateSubscription(ctx, input)

3. application/billing/usecase.go
       ├── planRepo.FindByID()                       → domain/billing.Plan
       ├── subRepo.FindActiveByCustomerAndPlan()
       │       └── if found → return billing.ErrAlreadySubscribed
       ├── billing.NewSubscription()                 → validates period logic
       ├── subRepo.Save()
       └── returns application/billing.SubscriptionOutput

4. infrastructure/pg/billing/repository.go
       INSERT INTO subscriptions ...
       maps DB row ↔ domain/billing.Subscription

5. handler writes HTTP 201 + JSON
       domain error ErrAlreadySubscribed → HTTP 409 Conflict

The same path as a sequence, including the branch where the customer is already subscribed:

sequenceDiagram autonumber actor C as Client box rgba(165,216,255,0.06) PRESENTATION participant MW as middleware/auth.go participant H as handlers/billing.go end box rgba(178,242,187,0.06) APPLICATION participant UC as billing/usecase.go end box rgba(255,236,153,0.09) DOMAIN participant D as billing entities end box rgba(208,191,255,0.06) INFRASTRUCTURE participant R as pg/repository.go end C->>MW: POST /api/v1/billing/subscriptions MW->>MW: validate JWT MW->>H: customer ID in context H->>H: decode into input DTO H->>UC: CreateSubscription(ctx, input) UC->>R: planRepo.FindByID(planID) R-->>UC: domain/billing.Plan UC->>R: subRepo.FindActive... alt already subscribed R-->>UC: active subscription UC-->>H: billing.ErrAlreadySubscribed H-->>C: 409 Conflict else not subscribed yet R-->>UC: nil UC->>D: billing.NewSubscription D-->>UC: Subscription, periods valid UC->>R: subRepo.Save(subscription) R->>R: INSERT + map row R-->>UC: saved UC-->>H: SubscriptionOutput DTO H-->>C: 201 Created + JSON end

Read it vertically and the layer rule falls out: no layer skips another. The handler never talks to the database. The use case never parses HTTP. The domain never knows any of this is happening.

The handler is genuinely thin — decode, call, translate:

func (h *CustomerHandler) Create(w http.ResponseWriter, r *http.Request) {
    var in customerapp.CreateCustomerInput
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        jsonError(w, http.StatusUnprocessableEntity, "validation_error", "invalid request body")
        return
    }

    out, err := h.uc.CreateCustomer(r.Context(), in)
    if err != nil {
        writeCustomerError(w, err)
        return
    }

    jsonResponse(w, http.StatusCreated, out)
}

Errors: Sentinels in the Domain, Status Codes at the Edge#

The domain declares what went wrong in business terms. Only the presentation layer knows that “already subscribed” is a 409.

// internal/domain/billing/errors.go
var (
    ErrSubscriptionNotFound = errors.New("subscription not found")
    ErrInvoiceNotFound      = errors.New("invoice not found")
    ErrAlreadySubscribed    = errors.New("customer already has an active subscription to this plan")
    ErrInvalidBillingPeriod = errors.New("invalid billing period")
    ErrInvoiceNotOpen       = errors.New("invoice is not in open state")
)
// internal/presentation/rest/handlers/…
func mapDomainError(w http.ResponseWriter, err error) {
    switch {
    case errors.Is(err, customer.ErrNotFound),
         errors.Is(err, billing.ErrSubscriptionNotFound),
         errors.Is(err, payment.ErrPaymentNotFound):
        jsonError(w, http.StatusNotFound, "not_found", err.Error())
    case errors.Is(err, customer.ErrAlreadyExists),
         errors.Is(err, billing.ErrAlreadySubscribed):
        jsonError(w, http.StatusConflict, "conflict", err.Error())
    case errors.Is(err, customer.ErrInvalidInput):
        jsonError(w, http.StatusUnprocessableEntity, "validation_error", err.Error())
    default:
        jsonError(w, http.StatusInternalServerError, "internal_error", "something went wrong")
    }
}

The use case wraps with %w so errors.Is still works three layers up. It wraps with context — fmt.Errorf("saving customer: %w", err) — and does not also log it. Handle an error once: log it or return it, never both.

Do not put an HTTP status code field on a domain error. The moment domain/ knows about 409, you have coupled your business rules to a transport that might be gRPC next quarter.

Bounded Contexts#

Each top-level domain package is a bounded context — a self-contained model of one part of the business.

customer/    — who is paying
billing/     — what they owe and when
payment/     — how they pay it
webhook/     — how we notify them

Contexts communicate through events and use case calls, never by importing each other’s internals.

// WRONG — payment reaching into billing's model
import "github.com/MikeMwita/PayMeter.git/internal/domain/billing"

func (uc *PaymentUseCase) Process(ctx context.Context, inv *billing.Invoice) {  }
// CORRECT — payment receives what it needs, by ID
func (uc *PaymentUseCase) ProcessPayment(ctx context.Context, input ProcessPaymentInput) error {
    inv, err := uc.invoiceRepo.FindByID(ctx, input.InvoiceID)
    
}

The distinction matters because “customer” means different things in different contexts. To customer/ it is a name, an email, and metadata. To billing/ it is a customerID attached to a subscription. To payment/ it is a payer with a phone number for M-Pesa. Forcing one shared Customer struct across all three is exactly the root-package models failure from earlier, rebuilt with better vocabulary.

The word “context” is doing real work here. A bounded context is a boundary around a meaning, not around a folder. Two contexts are allowed to have a Customer each, and they are allowed to disagree about what it contains.

Anti-Patterns#

Anaemic domain model#

Entities are data bags with no behaviour. All the logic leaks into use cases and handlers.

// WRONG
type Invoice struct {
    Status string   // mutable, no validation
    Total  float64  // float for money
}

func (uc *BillingUseCase) FinalizeInvoice(id string) {
    inv.Status = "open"   // nothing stops Status = "garbage"
}
// CORRECT — the entity owns its transitions
func (inv *Invoice) Finalize() error {
    if inv.status != InvoiceStatusDraft {
        return errors.New("only draft invoices can be finalized")
    }

    inv.status = InvoiceStatusOpen
    return nil
}

This is the most common way a DDD codebase becomes DDD-shaped rather than DDD-behaving. All the folders are there; none of the rules are.

Fat use cases#

Use cases that contain business rules instead of delegating to the domain.

// WRONG — the use case knows billing rules
func (uc *UseCase) AddLineItem(invoiceID string, qty int64, price float64) error {
    if qty < 1 {  }                        // belongs in NewLineItem
    if inv.Status != "draft" {  }          // belongs in Invoice.AddLineItem
    total := qty * price                    // belongs in Money
}
// CORRECT — orchestrate, do not decide
func (uc *UseCase) AddLineItem(ctx context.Context, input AddLineItemInput) error {
    inv, err := uc.invoiceRepo.FindByID(ctx, input.InvoiceID)
    if err != nil {
        return fmt.Errorf("fetching invoice: %w", err)
    }

    item, err := billing.NewLineItem(uuid.NewString(), input.Description, input.Quantity, unitAmount)
    if err != nil {
        return err
    }

    if err := inv.AddLineItem(item); err != nil {
        return err
    }

    return uc.invoiceRepo.Update(ctx, inv)
}

A quick test: if you deleted the use case and called the domain directly from a CLI, would any business rule be lost? If yes, the rule is in the wrong place.

Repositories inside entities#

// WRONG — entity reaching into infrastructure
func (inv *Invoice) FindRelatedSubscription() *Subscription {
    return subscriptionRepo.FindByID(inv.subscriptionID)
}

The use case fetches what it needs and passes it in. An entity that can perform I/O cannot be unit tested, and it silently makes every method call a potential network round trip.

Handlers leaking into the domain#

domain/ must never import net/http. Not for a status code, not for a header name, not for http.StatusOK. If you need this rule enforced rather than remembered, depguard will do it.

Repository methods that return DTOs#

A repository returns *billing.Invoice, not InvoiceOutput. The moment a repository knows about your JSON shape, changing an API response means editing SQL.

Testing#

The whole point of the dependency rule is that it makes tests cheap. Three tiers:

Domain tests need nothing. No mocks, no fixtures, no database — the domain has no dependencies to fake.

func TestInvoice_AddLineItem_RejectsNonDraft(t *testing.T) {
    t.Parallel()

    inv, err := billing.NewInvoice("inv_1", "cus_1", "sub_1", start, end, due, shared.CurrencyKES)
    require.NoError(t, err)
    require.NoError(t, inv.Finalize())

    item, err := billing.NewLineItem("li_1", "API calls", 100, shared.MustNewMoney(50, shared.CurrencyKES))
    require.NoError(t, err)

    err = inv.AddLineItem(item)
    require.Error(t, err)
    require.Equal(t, int64(0), inv.Total().Amount())
}

Table-driven for state machines, which is what most entities are:

func TestInvoice_Transitions(t *testing.T) {
    t.Parallel()

    tests := []struct {
        name    string
        setup   func(*billing.Invoice) error
        action  func(*billing.Invoice) error
        wantErr bool
    }{
        {"draft cannot be paid", nil, (*billing.Invoice).MarkPaid, true},
        {"open can be paid", (*billing.Invoice).Finalize, (*billing.Invoice).MarkPaid, false},
        {"paid cannot be voided", finalizeAndPay, (*billing.Invoice).Void, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel()
            
        })
    }
}

Use case tests use an in-memory repository. Not a mock — a real, working implementation of the interface backed by a map. PayMeter keeps these in internal/infrastructure/memory/, and they are production code, not test helpers: they are also what the local dev server runs on.

func TestCreateCustomer_DuplicateEmail(t *testing.T) {
    t.Parallel()

    repo := memory.NewCustomerRepository()
    uc := customerapp.NewUseCase(repo, slog.New(slog.DiscardHandler))

    _, err := uc.CreateCustomer(ctx, customerapp.CreateCustomerInput{Name: "Acme", Email: "billing@acme.com"})
    require.NoError(t, err)

    _, err = uc.CreateCustomer(ctx, customerapp.CreateCustomerInput{Name: "Acme 2", Email: "billing@acme.com"})
    require.ErrorIs(t, err, customer.ErrAlreadyExists)
}

An in-memory implementation beats a generated mock here because it enforces the invariant across calls. A mock will happily let you assert ExistsByEmail was called and still return the wrong answer.

Edge tests verify the HTTP contract only. PayMeter uses Gherkin features against the running API:

Scenario: Create a customer without a name is rejected
  When I POST "/v1/customers" with body:
    """
    { "email": "billing@acme.com" }
    """
  Then the response status should be 422

That test asserts a status code, not a business rule. The rule that a customer needs a name is already tested in domain/customer in microseconds. This one checks that the rule reaches the wire with the right number attached.

If you find yourself needing a database to test a business rule, the rule is in the wrong layer. That frustration is the architecture telling you something, not the test framework being difficult.

Wiring: One Place That Knows Everything#

Every layer depends only on abstractions, which means somebody has to pick the concrete types. That somebody is foundation/di — a single package permitted to import everything.

// Package di wires all layers together. It is the only place that knows
// about concrete types from all layers simultaneously.
package di

func New(cfg *config.Config, opts ...Option) *Container {
    reg := logger.New(
        logger.WithFormat(cfg.LogFormat),
        logger.WithDefaultLevel(cfg.SlogLevel()),
    )

    customerRepo := memory.NewCustomerRepository()
    customerUC := customerapp.NewUseCase(customerRepo, reg.For("customer"))

    return &Container{cfg: cfg, reg: reg, customerRepo: customerRepo, customerUC: customerUC}
}

No framework, no reflection, no struct tags. Constructors called in order. Swapping memory.NewCustomerRepository() for pg.NewCustomerRepository(pool) is a one-line change, and it is the only line in the codebase that has to change.

Note reg.For("customer") — each use case gets its own component logger, so log verbosity is tunable per subsystem at runtime. That kind of per-component knob is only possible because there is one place that hands out dependencies.

Resist DI frameworks in Go. wire and friends solve a code-generation problem you do not have until the container is a few hundred lines. Hand-written wiring is greppable, debuggable, and fails at compile time.

Best Practices, Condensed#

Everything above, as a list you can review a pull request against.

Domain layer

  • Private fields, always. Exported fields defeat the entire pattern.
  • The constructor validates. Invalid state should be unconstructible, not merely discouraged.
  • No setters. Named transition methods that return error.
  • Value objects are immutable and use value receivers.
  • Money is int64 in the smallest unit, wrapped in a type that knows its currency.
  • Enums start at iota + 1, or are string constants, so the zero value is never a valid state.
  • Sentinel errors are var ErrFoo = errors.New(...) at package level.
  • Repository interfaces live here, named in business language.
  • Domain services are pure functions — pass the clock in, do not read it.
  • Zero imports outside stdlib and domain/shared. Enforce it with depguard.

Application layer

  • Use cases orchestrate; they never decide.
  • Depend on interfaces from domain/, never on concrete infrastructure.
  • Separate input and output DTOs per use case. Do not reuse one struct for read and write.
  • Entities never cross this boundary — map to DTOs on the way out.
  • Wrap errors with %w and context; log once or return once, never both.
  • ctx context.Context is the first parameter of every method.

Infrastructure layer

  • Implements domain interfaces; assert compliance with var _ Iface = (*impl)(nil).
  • Owns all SQL, all SDK calls, all retry and timeout policy.
  • Maps rows to domain objects through the domain constructor where possible, so persisted data goes through the same validation as new data.
  • Keep an in-memory implementation of every repository. It pays for itself in tests and local development.

Presentation layer

  • Decode, call, translate. Fifteen lines is the smell threshold.
  • Maps domain errors to status codes here and only here.
  • Never imports infrastructure/.

Process

  • Add a bounded context when two parts of the business disagree about what a word means, not when a file gets long.
  • Do not create an abstraction for a second implementation you cannot name.
  • Put the layer rules in CI. A rule a linter enforces is architecture; a rule in a wiki is a wish.

Quick Reference#

Got a new business rule?          → domain/ entity method or domain service
Got a new workflow (multi-step)?  → application/ use case
Got a new external system?        → infrastructure/ adapter + domain port interface
Got a new API endpoint?           → presentation/ handler + route
Got a new config value?           → foundation/config/