4. Domain layer
Before writing anything that talks to a database or an HTTP request, we need to decide what an “order” and a “user” actually are in this system — independent of how they’ll be stored, cached, or served. That’s the whole job of this chapter, and it’s deliberately the smallest one: a package with no dependency on anything else we’ll write, so every later chapter has something stable to build against instead of chasing a moving target.
Create the domain package
Create domain/domain.go. Start with what an order status can be:
package domain
import (
"errors"
"time"
"uuid"
)
type OrderStatus string
const OrderStatusPending OrderStatus = "pending"
Just one status for now — this service never transitions an order out of pending. If it needed
to later (shipped, cancelled), this is where that would live, along with whatever rules govern
which transitions are legal; a const type is enough for a closed set of values on its own.
Now the two types everything else in this tutorial will pass around:
type Order struct {
ID uuid.UUID
UserID uuid.UUID
Item string
Quantity int
Status OrderStatus
CreatedAt time.Time
}
type User struct {
ID uuid.UUID
Username string
PasswordHash string
}
Notice ID and UserID are uuid.UUID, generated in our own code (uuid.New()), not integers a
database assigns. Two reasons this matters, not just a style choice: an ID never has to round-trip
through Postgres before the rest of the code can use it — the service layer will generate an
order’s ID, publish it in an event, and write it to the database, in whatever order is convenient,
because nothing is waiting on the database to hand back an auto-incremented value first (see
chapter 7). And an ID is never guessable or enumerable, which matters the
moment one shows up in a URL (GET /orders/{id}) that only its owner should be able to fetch.
Add the errors every other layer will check for
var (
ErrNotFound = errors.New("resource not found")
ErrForbidden = errors.New("access forbidden")
ErrValidation = errors.New("validation failed")
)
These three sentinel errors are what the rest of the service will use to talk about failure,
instead of each layer inventing its own. The repository we build in the next chapter will return
ErrNotFound when a row doesn’t exist; the service layer will return ErrForbidden when someone
requests an order that isn’t theirs; and only the API layer, right at the edge, will translate each
one into an HTTP status code:
flowchart LR
PG["postgres.Store<br/>returns domain.ErrNotFound"]
Svc["OrderService<br/>passes it through unwrapped"]
API["api handler<br/>maps it to 404"]
PG --> Svc --> API
The reason these live in domain and not, say, postgres (where the not-found case first comes
up) is what makes that translation trustworthy: if the repository had defined ErrNotFound,
the service layer would need a compile-time dependency on postgres just to check an error — the
exact layering violation chapter 1 set out to avoid.
Defining them here means every layer above can depend on the error without depending on whichever
package first produced it.
What doesn’t belong here
It’s worth naming the boundary explicitly, since it’s easy to blur once real code starts landing
in this file. A json:"..." struct tag is fine — it doesn’t imply a specific transport. A method
that returns an http.StatusCode, or a field typed as something from database/sql, is not: the
moment domain needs to import net/http or a driver package, the layering has quietly stopped
meaning anything. If validation rules grow later (a maximum quantity, an allowed set of items), a
Validate() error method on Order would belong here too, so the rule is enforced no matter which
layer constructs one. This tutorial’s validation is simple enough that it lives inline in
the service layer instead — a reflection of how little there is to validate
yet, not a rule to copy.
Nothing to test yet
There’s no test file for this chapter, and that’s deliberate rather than an oversight: everything
in domain.go right now is a type or constant declaration with no branching logic, and a test
would just restate the code back to itself. That changes the moment real logic — like the
Validate() method above — actually lands here.
Diagnostics
- Tempted to import
contexthere? That’s fine on its own — domain types don’t do I/O, but nothing stops the package from holding pure, cancellable functions later. What’s not fine is importing anything that implies a specific transport or storage technology.
Do’s and don’ts
- Don’t put
OrderStatustransition logic directly on the type without a plan — validating which transitions are legal is business logic, and belongs inservice, not scattered across whichever layer happens to set the field. - Don’t add a struct tag here that only one consumer cares about — a
db:"..."tag tied to a specific SQL library’s scanning convention belongs inpostgres, not here.
Next
Chapter 5: Repository layer — persisting domain.Order and
domain.User for real, in Postgres.