Stage 04 - Use Cases
Add the application layer: a service that orchestrates use cases over the domain and its port, depending only on interfaces.
Prereqs: stage 03 complete · Time: ~25 min
By the end: go test ./catalog/application/ passes, driving the port through the service.
The service
Goal: create the application service and give it the port through its constructor.
Learn
The application layer holds use cases - the orchestration that turns a request into domain calls and persistence. It sits between the outside adapters and the domain, and depends only on interfaces: the domain entities and the Repository port. It never names a concrete database or an HTTP type.
The Service holds the port as a field. The constructor takes it as a parameter - dependency injection. Because the field is the interface, not a concrete type, the caller decides what goes in: a test injects the in-memory adapter, production injects Postgres, and the service code is identical either way. The place that makes that choice is the composition root (stage 05's main.go, stage 07's module).
Build
Create catalog/application/service.go:
package application
import "minimart/catalog/domain"
// Service is the catalog application layer. It orchestrates use cases over the
// domain and its ports, and depends only on interfaces - never on a concrete
// database or HTTP type.
type Service struct {
repo domain.Repository
}
// NewService injects the repository port. The concrete adapter is chosen by the
// composition root, not here.
func NewService(repo domain.Repository) *Service {
return &Service{repo: repo}
}repo is unexported: nothing outside the package reaches the port directly; callers go through the use-case methods you add next.
Verify
go build ./catalog/...Prints nothing, exits 0. A service with a field and a constructor but no methods yet still compiles.
Exercise
NewService accepts domain.Repository (an interface), not *memory.Repository (the concrete type). What would break if it took the concrete type?
Answer
The application layer would import infrastructure/memory, coupling the core to one storage engine. You could no longer inject Postgres without editing the service, and tests would be tied to that one adapter. Depending on the interface keeps the choice at the edge.
One use case per file
Goal: write the CreateProduct use case and mint IDs in the application layer.
Learn
Each use case is one method on Service, in its own file named after it (create_product.go). The file tells you what the application does at a glance, and a use case never grows tangled with unrelated ones.
CreateProduct takes a CreateProductRequest - an application DTO, distinct from the HTTP request body you build in stage 05. Keeping them separate means the wire format can change without touching the use case, and the use case can be called from a Kafka consumer or a test with no HTTP in sight.
The ID is minted here, not accepted from the client. Recall the Valid by construction section: the domain takes id on trust because the application controls it. IDs use crypto/rand, not math/rand, so they are unguessable.
This use case is a command: it changes state. Later reads (GetProduct, ListProducts) are queries: they ask questions and should not mutate anything. That small split is CQRS-lite - useful vocabulary without new packages or ceremony.
Build
1. Create catalog/application/create_product.go:
package application
import (
"context"
"minimart/catalog/domain"
)
// CreateProductRequest is the input to the CreateProduct use case. It is an
// application-layer type, distinct from any HTTP DTO.
type CreateProductRequest struct {
Name string
PriceCents int64
Stock int
}
// CreateProduct mints an ID, builds a valid Product, and saves it through the port.
func (s *Service) CreateProduct(ctx context.Context, req CreateProductRequest) (domain.Product, error) {
p, err := domain.NewProduct(newID(), req.Name, req.PriceCents, req.Stock)
if err != nil {
return domain.Product{}, err
}
if err := s.repo.Save(ctx, p); err != nil {
return domain.Product{}, err
}
return p, nil
}The use case validates by delegating to NewProduct - a bad request returns the domain error unchanged, and nothing is saved. Only a valid product reaches the port.
2. Create catalog/application/id.go:
package application
import (
"crypto/rand"
"encoding/hex"
)
// newID returns a random 16-byte hex identifier.
func newID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing means the OS entropy source is broken; there is no
// sensible way to continue.
panic(err)
}
return hex.EncodeToString(b)
}The panic is deliberate: a failing entropy source is a broken program, not bad input. Reserve panic for that; return errors for everything a caller might handle.
Verify
go build ./catalog/...Prints nothing, exits 0.
Exercise
Why mint the product ID in the application layer instead of in the HTTP handler or inside the repository?
Answer
Creating a product is a use case, and the application layer coordinates the steps of that use case: choose an ID, validate the entity, then save it. The handler should only translate HTTP, and the repository should only persist what it is given.
Reads
Goal: add the two read use cases, one file each.
Learn
Reads follow the same rule: one use case, one file. Right now each is a thin pass-through to the port - no orchestration to do yet. That is fine. The seam still earns its place: when a read later needs a rule (filter out-of-stock items, check who is asking), the change lands in exactly one file, and the handler above it does not move. The signature belongs to the use case even when the body is one line.
Reads are allowed to evolve differently from writes. If a screen later needs a summary, search result, or denormalized shape, add a query/read model for that use case instead of forcing domain.Product to become every view at once. MiniMart stays simple today; the boundary keeps the option open.
Build
1. Create catalog/application/get_product.go:
package application
import (
"context"
"minimart/catalog/domain"
)
// GetProduct returns a product by ID, or domain.ErrProductNotFound.
func (s *Service) GetProduct(ctx context.Context, id string) (domain.Product, error) {
return s.repo.GetByID(ctx, id)
}2. Create catalog/application/list_products.go:
package application
import (
"context"
"minimart/catalog/domain"
)
// ListProducts returns the whole catalog.
func (s *Service) ListProducts(ctx context.Context) ([]domain.Product, error) {
return s.repo.List(ctx)
}GetProduct passes domain.ErrProductNotFound straight up from the adapter; the HTTP edge turns it into a 404 in stage 06.
Verify
go build ./catalog/...Prints nothing, exits 0. The service now has all three catalog use cases: create, get, and list. Save is the repository port's job, not a use case.
Exercise
A pass-through method that just calls the port looks like dead weight. Why keep it instead of calling repo.List from the handler directly?
Answer
The handler would then depend on the port, and every future read rule would have to live in a handler - an input adapter - where it can't be reused by a Kafka consumer or a test, and can't be unit-tested without HTTP. The one-line method reserves the place where that logic belongs before you need it.
Test the use case
Goal: test CreateProduct end to end by injecting the in-memory adapter.
Learn
The test is itself a small composition root: it constructs the service with a real adapter - NewService(memory.New()) - and drives the whole use case: mint an ID, validate, save. No database, no HTTP server. That is the payoff of injecting the port through the constructor: the same wiring that main does, a test can do in one line.
The table checks the happy path (an ID is generated, the name survives) and each validation error, which the use case passes through from NewProduct unchanged.
Build
Create catalog/application/create_product_test.go:
package application
import (
"context"
"errors"
"testing"
"minimart/catalog/domain"
"minimart/catalog/infrastructure/memory"
)
// The test is itself a small composition root: it injects the in-memory adapter,
// no database required.
func TestCreateProduct(t *testing.T) {
tests := []struct {
name string
req CreateProductRequest
wantErr error
}{
{"valid", CreateProductRequest{Name: "Widget", PriceCents: 500, Stock: 3}, nil},
{"empty name", CreateProductRequest{Name: " ", PriceCents: 500}, domain.ErrEmptyName},
{"zero price", CreateProductRequest{Name: "Widget", PriceCents: 0}, domain.ErrNonPositivePrice},
{"negative stock", CreateProductRequest{Name: "Widget", PriceCents: 500, Stock: -1}, domain.ErrNegativeStock},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewService(memory.New())
got, err := svc.CreateProduct(context.Background(), tt.req)
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 got.ID == "" {
t.Fatal("expected a generated ID")
}
if got.Name != "Widget" {
t.Fatalf("want name Widget, got %q", got.Name)
}
})
}
}The application test may import infrastructure/memory - a test file is not the application layer, and the in-memory adapter is a legitimate test double. The production code under test still imports only the port.
Verify
go test ./catalog/application/ok minimart/catalog/application 0.003sWhat you learned
- The application layer orchestrates use cases and depends only on the port.
- One use case per file; the application DTO is separate from the wire DTO.
- Injecting the port makes a test a composition root - no external infrastructure needed.
Next: the HTTP input adapter, the program's front door.
Exercise
Why is this test allowed to use the real in-memory adapter and still count as a focused use-case test?
Answer
The adapter is an in-process implementation of the port, not an external system. Using it exercises the service through the same constructor production uses, while still avoiding HTTP, Postgres, and slow infrastructure.