MiniCart
Stage 04 Use Cases

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:

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

Shell
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:

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:

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

Shell
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:

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:

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

Shell
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:

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

Shell
go test ./catalog/application/
Code
ok  	minimart/catalog/application	0.003s

What 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.