Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ migrations/
├── 000001_initial_schema.down.sql # Rollback for initial schema
├── 000002_price_as_money.up.sql # Money as integer cents + currency
├── 000003_outbox.up.sql # Transactional outbox table
├── 000004_price_minor_units.up.sql # Rename to ISO 4217 minor units
└── ...
```

Expand Down Expand Up @@ -235,16 +236,16 @@ curl -s -X POST http://localhost:8080/api/v1/sellers \
{"id":"0197a3c2-...","name":"Acme Corp","created_at":"2026-07-14T09:00:00Z","updated_at":"2026-07-14T09:00:00Z"}
```

Create a product for that seller (prices are integer cents — never floats):
Create a product for that seller (prices are integer minor units — never floats):

```bash
curl -s -X POST http://localhost:8080/api/v1/products \
-H 'Content-Type: application/json' \
-d '{"name": "Wooden Chair", "price_cents": 4999, "currency": "EUR", "seller_id": "<seller-id-from-above>"}'
-d '{"name": "Wooden Chair", "price_minor_units": 4999, "currency": "EUR", "seller_id": "<seller-id-from-above>"}'
```

```json
{"id":"0197a3c3-...","name":"Wooden Chair","price_cents":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."}
{"id":"0197a3c3-...","name":"Wooden Chair","price_minor_units":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."}
```

Replay a request with the same `idempotency_key` — you get the cached response back instead of a duplicate seller:
Expand Down
5 changes: 3 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ migrations/
├── 000001_initial_schema.down.sql # 初始模式的回滚
├── 000002_price_as_money.up.sql # 金额改为整数分 + 货币
├── 000003_outbox.up.sql # 事务性发件箱表
├── 000004_price_minor_units.up.sql # 重命名为 ISO 4217 最小货币单位
└── ...
```

Expand Down Expand Up @@ -249,11 +250,11 @@ curl -s -X POST http://localhost:8080/api/v1/sellers \
```bash
curl -s -X POST http://localhost:8080/api/v1/products \
-H 'Content-Type: application/json' \
-d '{"name": "Wooden Chair", "price_cents": 4999, "currency": "EUR", "seller_id": "<上一步返回的 seller-id>"}'
-d '{"name": "Wooden Chair", "price_minor_units": 4999, "currency": "EUR", "seller_id": "<上一步返回的 seller-id>"}'
```

```json
{"id":"0197a3c3-...","name":"Wooden Chair","price_cents":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."}
{"id":"0197a3c3-...","name":"Wooden Chair","price_minor_units":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."}
```

用相同的 `idempotency_key` 重放请求——返回缓存的响应,而不是创建重复卖家:
Expand Down
12 changes: 6 additions & 6 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -263,17 +263,17 @@ components:
$ref: "#/components/schemas/Seller"
CreateProductRequest:
type: object
required: [name, price_cents, currency, seller_id]
required: [name, price_minor_units, currency, seller_id]
properties:
idempotency_key:
type: string
name:
type: string
example: Wooden Chair
price_cents:
price_minor_units:
type: integer
format: int64
description: Price in minor units (cents) — no floats for money.
description: Price in ISO 4217 minor units (cents for EUR/USD) — no floats for money.
example: 4999
currency:
type: string
Expand All @@ -283,13 +283,13 @@ components:
format: uuid
UpdateProductRequest:
type: object
required: [name, price_cents, currency, seller_id]
required: [name, price_minor_units, currency, seller_id]
properties:
idempotency_key:
type: string
name:
type: string
price_cents:
price_minor_units:
type: integer
format: int64
currency:
Expand All @@ -306,7 +306,7 @@ components:
format: uuid
name:
type: string
price_cents:
price_minor_units:
type: integer
format: int64
currency:
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ The wiring point where everything meets is [`cmd/marketplace/main.go`](https://g

**Infrastructure** ([tutorial 5](../tutorial/05-repositories.md), [7](../tutorial/07-domain-events-outbox.md)) — the details. sqlc-generated, type-safe SQL behind the domain's repository interfaces; row-to-entity mapping routes through validating constructors; the aggregate write and its outbox events share one transaction; the relay polls and publishes with at-least-once semantics.

**Interface** — the edge. Echo controllers parse DTOs, call services, and map sentinel errors to status codes in [`errors.go`](https://github.com/sklinkert/go-ddd/blob/main/internal/interface/api/rest/errors.go): `ErrProductNotFound` → 404, `ErrValidation` → 400, `ErrRequestInFlight` → 409, `ErrIdempotencyKeyReuse` → 422. DTOs use explicit primitives (`price_cents`, not `price`) so the wire format can never be ambiguous.
**Interface** — the edge. Echo controllers parse DTOs, call services, and map sentinel errors to status codes in [`errors.go`](https://github.com/sklinkert/go-ddd/blob/main/internal/interface/api/rest/errors.go): `ErrProductNotFound` → 404, `ErrValidation` → 400, `ErrRequestInFlight` → 409, `ErrIdempotencyKeyReuse` → 422. DTOs use explicit primitives (`price_minor_units`, not `price`) so the wire format can never be ambiguous.

## The request flows

Expand Down
8 changes: 4 additions & 4 deletions docs/tutorial/02-entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func NewProduct(name string, price Money, seller ValidatedSeller) *Product {
}

product.recordEvent(events.NewProductCreated(
product.Id, name, price.Cents(), string(price.Currency()), seller.Id))
product.Id, name, price.MinorUnits(), string(price.Currency()), seller.Id))

return product
}
Expand Down Expand Up @@ -76,7 +76,7 @@ func (p *Product) validate() error {
if p.Name == "" {
return fmt.Errorf("%w: name must not be empty", ErrValidation)
}
if p.Price.Cents() == 0 {
if p.Price.MinorUnits() == 0 {
return fmt.Errorf("%w: price must be greater than 0", ErrValidation)
}
if p.SellerId == uuid.Nil {
Expand Down Expand Up @@ -122,14 +122,14 @@ Is this bulletproof? No — `Product` fields are exported (the persistence layer

You might object: my HTTP layer already validates requests. Keep it! Edge validation and domain validation answer different questions:

- The edge asks: *is this request well-formed?* (Is `price_cents` a number? Is `seller_id` a UUID?)
- The edge asks: *is this request well-formed?* (Is `price_minor_units` a number? Is `seller_id` a UUID?)
- The domain asks: *is this a valid product?* (Is the price positive? Does the seller exist and pass validation?)

The edge check is about protocol; it produces friendly 400s fast. The domain check is about business truth, and it runs no matter where the call came from — HTTP today, a message consumer tomorrow, a backfill script at 2am. The scattered-validation problem isn't solved by choosing one location; it's solved by giving each rule its *one correct* location.

## Try it

1. Delete the `Price.Cents() == 0` check from `validate()` and run `go test ./internal/...` — watch which tests fail and read what they assert. The test suite documents the invariants.
1. Delete the `Price.MinorUnits() == 0` check from `validate()` and run `go test ./internal/...` — watch which tests fail and read what they assert. The test suite documents the invariants.
2. Add a new rule: product names must be at most 200 characters. Notice you touch exactly two files — the entity and its test.
3. Try to call `productRepository.Create` with a plain `*Product`. Enjoy the compile error; that error is the pattern working.

Expand Down
48 changes: 27 additions & 21 deletions docs/tutorial/03-value-objects.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,53 +33,59 @@ const (
USD Currency = "USD"
)

var supportedCurrencies = map[Currency]struct{}{
EUR: {},
USD: {},
// supportedCurrencies maps each supported currency to its ISO 4217
// minor-unit exponent. Not every currency has two decimals: JPY, KRW,
// CLP and ISK have 0; BHD, JOD, KWD, OMR and TND have 3.
var supportedCurrencies = map[Currency]int{
EUR: 2,
USD: 2,
}

// Money is an immutable value object storing an amount in minor units
// (cents) to avoid floating-point rounding errors.
// Money is an immutable value object storing an amount in ISO 4217 minor
// units (cents for EUR/USD, yen for JPY, fils for BHD) to avoid
// floating-point rounding errors.
type Money struct {
cents int64
currency Currency
minorUnits int64
currency Currency
}

func NewMoney(cents int64, currency Currency) (Money, error) {
if cents < 0 {
func NewMoney(minorUnits int64, currency Currency) (Money, error) {
if minorUnits < 0 {
return Money{}, fmt.Errorf("%w: amount must not be negative", ErrValidation)
}
if _, ok := supportedCurrencies[currency]; !ok {
return Money{}, fmt.Errorf("%w: unsupported currency %q", ErrValidation, currency)
}

return Money{cents: cents, currency: currency}, nil
return Money{minorUnits: minorUnits, currency: currency}, nil
}

func (m Money) Cents() int64 { return m.cents }
func (m Money) MinorUnits() int64 { return m.minorUnits }
func (m Money) Currency() Currency { return m.currency }
```

The design decisions, spelled out:

**Unexported fields, one constructor.** Because `cents` and `currency` are unexported, the only way to build a `Money` outside the package is `NewMoney`. Every `Money` in the entire system has passed the negative check and the currency whitelist. There is no "construct raw, validate later" path to forget. This is [chapter 2's](02-entities.md) idea taken to its logical end: for a value object, we *can* make invalid states fully unrepresentable, because nothing needs to mutate it.
**Unexported fields, one constructor.** Because `minorUnits` and `currency` are unexported, the only way to build a `Money` outside the package is `NewMoney`. Every `Money` in the entire system has passed the negative check and the currency whitelist. There is no "construct raw, validate later" path to forget. This is [chapter 2's](02-entities.md) idea taken to its logical end: for a value object, we *can* make invalid states fully unrepresentable, because nothing needs to mutate it.

**Integer minor units, not `big.Rat` or a decimal library.** `int64` cents gives exact addition and comparison for free, sorts and indexes trivially in the database, and covers about ±92 quadrillion dollars. For prices and balances, minor units are the boring answer that works. (For FX rates or interest math, reach for a decimal type — different problem.)
**"Minor units", not "cents".** The first version of this type called the field `cents`, and a sharp reader pointed out the trap: "cents" is only correct for currencies with two decimal places. ISO 4217 defines a *minor-unit exponent* per currency — JPY, KRW, CLP and ISK have 0 (there is no sub-yen), BHD, JOD, KWD, OMR and TND have 3. A field named `cents` invites whoever adds JPY to divide by 100 and turn ¥5000 into ¥50. The currency-neutral name plus the exponent stored in the whitelist keeps the type honest, and `String()` formats from the exponent instead of a hardcoded 100.

**Integer minor units, not `big.Rat` or a decimal library.** `int64` minor units give exact addition and comparison for free, sort and index trivially in the database, and cover about ±92 quadrillion dollars. For prices and balances, minor units are the boring answer that works. (For FX rates or interest math, reach for a decimal type — different problem.)

**Currency is part of equality.** `Money` is a comparable struct, so `==` compares amount *and* currency: `NewMoney(1000, EUR) != NewMoney(1000, USD)`. The euros-versus-cents class of bug now fails at the type level instead of on an accountant's spreadsheet.

**A whitelist of currencies.** Two entries looks restrictive; that's the point. The *domain* decides which currencies the business supports. Adding one is a one-line change that forces a moment of thought — which is what you want when the alternative is silently accepting `"BTC"` or `"EURO"` from a client.
**A whitelist of currencies.** Two entries looks restrictive; that's the point. The *domain* decides which currencies the business supports. Adding one is a one-line change that forces a moment of thought — including looking up the right exponent — which is what you want when the alternative is silently accepting `"BTC"` or `"EURO"` from a client.

## Immutability changes how arithmetic looks

There's no `SetCents`. If the template grew an `Add`, it would return a *new* value:
There's no `SetMinorUnits`. If the template grew an `Add`, it would return a *new* value:

```go
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, fmt.Errorf("%w: cannot add %s to %s", ErrValidation, other.currency, m.currency)
}
return NewMoney(m.cents+other.cents, m.currency)
return NewMoney(m.minorUnits+other.minorUnits, m.currency)
}
```

Expand All @@ -98,7 +104,7 @@ func (m *Money) UnmarshalJSON(data []byte) error {
return err
}

money, err := NewMoney(raw.Cents, raw.Currency)
money, err := NewMoney(raw.MinorUnits, raw.Currency)
if err != nil {
return err
}
Expand All @@ -110,12 +116,12 @@ func (m *Money) UnmarshalJSON(data []byte) error {

Why bother, when the value was valid at serialization time? Because JSON doesn't only come from you. It comes from an outbox row written by last year's code, a queue message from another service, a fixture someone hand-edited. Revalidating on the way in costs two comparisons and closes the whole category.

At the REST edge, the DTO doesn't expose the domain type at all — it carries `price_cents` and `currency` as explicit primitive fields. The name `price_cents` does real work: no client developer will ever wonder whether to send `19.99` or `1999`.
At the REST edge, the DTO doesn't expose the domain type at all — it carries `price_minor_units` and `currency` as explicit primitive fields. The explicit name does real work: no client developer will ever wonder whether to send `19.99` or `1999`.

## The sharp edges I'll tell you about myself

- **`String()` assumes two decimal places.** Correct for EUR and USD, wrong for JPY (0 decimals) or KWD (3). The whitelist keeps the assumption safe *by construction* — but if you go properly multi-currency, "cents" itself becomes a misleading word. The accurate term is *minor units*, and you'll need per-currency exponents from ISO 4217.
- **Division still rounds.** Integer cents make addition exact, but 100 cents split three ways is 33+33+34 no matter what. You need an allocation strategy (largest-remainder works), and if you allocate, *persist the allocation* — a refund must mirror the original split, not recompute it.
- **Formatting is only as good as the exponent table.** `String()` derives its divisor from the whitelist's per-currency exponent, so JPY (0 decimals) and KWD (3) format correctly the day they're added — but only if whoever adds them looks up the right exponent. The table is the contract; a wrong entry is a wrong display everywhere.
- **Division still rounds.** Integer minor units make addition exact, but 100 split three ways is still 33+33+34. You need an allocation strategy (largest-remainder works), and if you allocate, *persist the allocation* — a refund must mirror the original split, not recompute it.
- **Parsing at ingestion boundaries is where money bugs actually live.** A bank API sending `"5000"` JPY means 5000 yen, not 50.00 of anything. Parse currency-aware, before the value ever becomes an integer in your system.

## Beyond Money
Expand All @@ -124,7 +130,7 @@ The pattern generalizes to anything defined by its value and burdened with rules

## Try it

1. Add `GBP` to the whitelist and write the test. One line plus one assertion — feel how cheap extending a value object is.
1. Add `GBP` to the whitelist (exponent 2) and write the test. One line plus one assertion — feel how cheap extending a value object is. Then imagine adding `JPY` instead: the exponent table already handles the formatting.
2. Write `Sub(other Money)` and decide what happens when the result would be negative. (There's no universally right answer: an account balance may go negative, a price may not. Your domain decides.)
3. Round-trip a `Money` through `json.Marshal`/`json.Unmarshal`, then hand-edit the JSON to `"currency": "XXX"` and unmarshal again. Watch the constructor reject it.

Expand Down
6 changes: 3 additions & 3 deletions docs/tutorial/05-repositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The concrete side, [`SqlcProductRepository`](https://github.com/sklinkert/go-ddd

```sql
-- name: GetProductById :one
SELECT p.id, p.name, p.price_cents, p.currency, p.seller_id, p.created_at, p.updated_at
SELECT p.id, p.name, p.price_minor_units, p.currency, p.seller_id, p.created_at, p.updated_at
FROM products p
JOIN sellers s ON p.seller_id = s.id
WHERE p.id = $1 AND p.deleted_at IS NULL AND s.deleted_at IS NULL;
Expand All @@ -53,8 +53,8 @@ I prefer this over an ORM in a DDD codebase for a specific reason: **the mapping
Reading a row back is a boundary crossing, and the same rule from [chapter 3](03-value-objects.md) applies — reconstruction routes through the validating constructors:

```go
func productFromRow(id uuid.UUID, name string, priceCents int64, currency string, /* ... */) (*entities.Product, error) {
price, err := entities.NewMoney(priceCents, entities.Currency(currency))
func productFromRow(id uuid.UUID, name string, priceMinorUnits int64, currency string, /* ... */) (*entities.Product, error) {
price, err := entities.NewMoney(priceMinorUnits, entities.Currency(currency))
if err != nil {
return nil, err
}
Expand Down
6 changes: 3 additions & 3 deletions docs/tutorial/06-cqrs.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ type CreateProductCommand struct {
IdempotencyKey string
Id uuid.UUID
Name string
PriceCents int64
PriceMinorUnits int64
Currency entities.Currency
SellerId uuid.UUID
}
```

It carries raw-ish data (`PriceCents int64`, not `Money`) because the command is the *request* to do something — turning its data into validated domain objects is the application service's job:
It carries raw-ish data (`PriceMinorUnits int64`, not `Money`) because the command is the *request* to do something — turning its data into validated domain objects is the application service's job:

```go
func (s *ProductService) CreateProduct(ctx context.Context, cmd *command.CreateProductCommand) (*command.CreateProductCommandResult, error) {
Expand All @@ -38,7 +38,7 @@ func (s *ProductService) CreateProduct(ctx context.Context, cmd *command.CreateP
return nil, err
}

price, err := entities.NewMoney(cmd.PriceCents, cmd.Currency)
price, err := entities.NewMoney(cmd.PriceMinorUnits, cmd.Currency)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorial/07-domain-events-outbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func NewProduct(name string, price Money, seller ValidatedSeller) *Product {
product := &Product{ /* ... */ }

product.recordEvent(events.NewProductCreated(
product.Id, name, price.Cents(), string(price.Currency()), seller.Id))
product.Id, name, price.MinorUnits(), string(price.Currency()), seller.Id))

return product
}
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorial/08-idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Against the live stack, the behavior in four commands:
for i in $(seq 5); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/api/v1/products \
-H 'Content-Type: application/json' \
-d '{"idempotency_key":"k-42","name":"Widget","price_cents":999,"currency":"EUR","seller_id":"..."}' &
-d '{"idempotency_key":"k-42","name":"Widget","price_minor_units":999,"currency":"EUR","seller_id":"..."}' &
done; wait
```

Expand Down
12 changes: 6 additions & 6 deletions internal/application/command/create_product_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import (
)

type CreateProductCommand struct {
IdempotencyKey string
Id uuid.UUID
Name string
PriceCents int64
Currency entities.Currency
SellerId uuid.UUID
IdempotencyKey string
Id uuid.UUID
Name string
PriceMinorUnits int64
Currency entities.Currency
SellerId uuid.UUID
}

type CreateProductCommandResult struct {
Expand Down
Loading
Loading