MiniCart
Stage 02 The Domain

Stage 02 - The Domain

Build the catalog's core: the Product entity, its rules, and the tests that prove them - all with no server and no database.

Prereqs: stage 01 complete · Time: ~25 min

By the end: go test ./catalog/domain/ passes, with no database in sight.


Entities

Goal: define the Product entity and see why it sits at the centre.

Learn

An entity is a thing your business talks about, modelled as a plain Go type. The catalog has one: a Product. Entities live in the domain layer - the centre of the hexagon - and the domain imports nothing outward: no net/http, no database driver, no JSON tags. Standard-library basics (strings, errors) are fine.

That ignorance is the point. Because Product knows nothing about how it is stored or who is asking, you can test its rules with no server and no database, and swap in Postgres later without touching it.

Here is the type (you write the file in the Valid by construction section, once its constructor exists):

Go
// Product is a catalog entry. Prices are integer cents - never floats - so money
// arithmetic is exact. Code outside tests should create Product values through
// NewProduct, so normal application flow is valid by construction.
type Product struct {
	ID         string
	Name       string
	PriceCents int64
	Stock      int
}

Four exported fields, no methods, no tags. PriceCents is int64 (money is cents - the Money in cents section explains why); Stock is a plain int, because a count is not a ledger entry.

Exercise

Why does the Product struct carry no json:"..." tags, even though an HTTP API will return it?

Answer

JSON is a wire concern, not a business one. Tagging the entity would bind the domain to one serialization format and let a wire-shape change ripple into the core. The HTTP layer defines its own response type instead (stage 05). The domain stays clean.


Valid by construction

Goal: guard the product rules in one constructor, and name every way they fail.

Learn

Scattering if price <= 0 checks across the codebase means every caller must remember them, and the one that forgets creates an invalid Product. Give the type one blessed door instead: a constructor that validates, so a Product created through NewProduct is valid. If NewProduct returns a nil error, the product it hands back needs no further checking - anywhere, ever.

Each failure is a named sentinel error: a package-level error value that callers identify with errors.Is. The domain returns the sentinel; the HTTP edge maps it to a status code later (stage 06). The domain itself knows nothing about HTTP.

Build

1. Create catalog/domain/errors.go:

Go
package domain

import "errors"

// Domain errors are exported sentinels. Callers compare with errors.Is; the HTTP
// edge maps them to status codes. The domain itself knows nothing about HTTP.
var (
	ErrProductNotFound  = errors.New("product not found")
	ErrEmptyName        = errors.New("product name must not be empty")
	ErrNonPositivePrice = errors.New("product price must be greater than zero")
	ErrNegativeStock    = errors.New("product stock must not be negative")
)

ErrProductNotFound has no caller yet - the repository port raises it in stage 03.

2. Create catalog/domain/product.go - the struct from the Entities section plus its constructor:

Go
package domain

import "strings"

// Product is a catalog entry. Prices are integer cents - never floats - so money
// arithmetic is exact. Code outside tests should create Product values through
// NewProduct, so normal application flow is valid by construction.
type Product struct {
	ID         string
	Name       string
	PriceCents int64
	Stock      int
}

// NewProduct validates its inputs and returns a Product, or a domain error.
func NewProduct(id, name string, priceCents int64, stock int) (Product, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return Product{}, ErrEmptyName
	}
	if priceCents <= 0 {
		return Product{}, ErrNonPositivePrice
	}
	if stock < 0 {
		return Product{}, ErrNegativeStock
	}
	return Product{ID: id, Name: name, PriceCents: priceCents, Stock: stock}, nil
}

The constructor trims the name, rejects the three bad states, and otherwise returns a valid value. id is not validated: it is never user input - the application layer mints it (stage 04), so the domain takes it on trust. On failure it returns the zero Product{}; the error tells the caller not to touch it.

Verify

Shell
go build ./catalog/domain/

Prints nothing, exits 0.

Exercise

Nothing stops another package writing domain.Product{Name: ""} directly. So what actually protects the rule?

Answer

Convention and review - the constructor is the blessed path, and reviewers reject raw literals. Go's stricter tool is unexporting the fields (name, not Name) with getters, so nothing outside the package can build one by hand. We keep plain structs for readability, as much production Go does. In a harder domain, turn the dial up: private fields plus behavior methods like Reserve, Cancel, or Approve let the compiler enforce more of the rule.


Money in cents

Goal: know why prices are int64 cents and never float64.

Learn

PriceCents is an int64 holding a whole number of cents - 1299 is $12.99. Money is never a float64 in this codebase. The reason is exactness:

Go
0.1 + 0.2 == 0.30000000000000004 // float64

Binary floating point cannot represent most decimal fractions exactly, so sums drift by fractions of a cent. Across many rows that drift becomes real money the books can't explain. Integers have no such error: cents add, compare, and total exactly.

int64 (not int) fixes the width at 64 bits on every platform - headroom for any total, and a stable type across the wire and the database column. Convert to a decimal string only at the outer edge, for display.

Exercise

Where is the only correct place to turn 1299 into "$12.99"?

Answer

At the outermost edge, for presentation - a formatting step in the client or the HTTP response layer. The domain, application, and storage all keep integer cents so arithmetic stays exact.


Table-driven tests

Goal: test every NewProduct rule with one table, and run it with no database.

Learn

A table-driven test lists cases as a slice of structs and runs one loop over them. Adding a case tomorrow costs one line, not a new function. Because the domain imports nothing outward, this test needs no server and no database - it runs in milliseconds.

Build

Create catalog/domain/product_test.go:

Go
package domain

import (
	"errors"
	"testing"
)

func TestNewProduct(t *testing.T) {
	tests := []struct {
		name        string
		productName string
		priceCents  int64
		stock       int
		wantErr     error
	}{
		{"valid", "Widget", 500, 3, nil},
		{"trims surrounding space", "  Widget  ", 500, 0, nil},
		{"empty name", "   ", 500, 0, ErrEmptyName},
		{"zero price", "Widget", 0, 0, ErrNonPositivePrice},
		{"negative price", "Widget", -1, 0, ErrNonPositivePrice},
		{"negative stock", "Widget", 500, -1, ErrNegativeStock},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			p, err := NewProduct("id-1", tt.productName, tt.priceCents, tt.stock)
			if tt.wantErr != nil {
				if !errors.Is(err, tt.wantErr) {
					t.Fatalf("want error %v, got %v", tt.wantErr, err)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if p.Name != "Widget" {
				t.Fatalf("want trimmed name Widget, got %q", p.Name)
			}
		})
	}
}
Go note: a file ending _test.go compiles only under go test. Each TestXxx(t *testing.T) runs automatically; t.Run(name, fn) makes each row a named subtest. errors.Is reports whether an error matches a sentinel through any %w wrapping.

The "trims surrounding space" row proves the constructor trims before validating: " Widget " becomes "Widget", which is why the happy-path rows assert p.Name == "Widget".

Verify

Shell
go test ./catalog/domain/
Code
ok  	minimart/catalog/domain	0.001s

Add -v to see each row by name.

Exercise

What makes this test fast enough to run constantly while you work?

Answer

It tests only the domain constructor. There is no HTTP server, database, clock, broker, or filesystem involved, so each row checks one rule directly and finishes in milliseconds.