My Thoughts on Domain-Driven Design in Golang
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.
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
infrastructureholds anything that touches the outside world: config readers, DB connections, cache clients, the router setup, main bootstrap. One-time initialization.interfacesholds controllers and presenters. It routes requests to the right service method and presents the output.servicesholds use cases and repository interfaces. Business logic lives here.datamodelsholds 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
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
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.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
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
int64in 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 withdepguard.
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
%wand context; log once or return once, never both. ctx context.Contextis 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/