A simplified Product Catalog Service built with DDD, Clean Architecture, CQRS, gRPC, and Google Cloud Spanner.
- Go 1.21+
- Docker & Docker Compose
- protoc (Protocol Buffers compiler)
- protoc-gen-go, protoc-gen-go-grpc
# Start Spanner emulator
docker-compose up -d
# Run migrations (creates instance, database, and schema)
make migrate
# Run tests
make test
# Start gRPC server (port 50051)
make runTransport (gRPC) → Use Cases (Commands/Queries) → Domain → Repository → Spanner
-
Domain Layer (
internal/app/product/domain/): Pure Go business logic. No external dependencies — nocontext.Context, no database imports, no proto imports. Contains the Product aggregate, Money/Discount value objects, domain events, and sentinel errors. -
Use Cases (
internal/app/product/usecases/): Commands that follow the Golden Mutation Pattern: Load aggregate → Execute domain logic → Build commit plan → Apply atomically. -
Queries (
internal/app/product/queries/): Read-side handlers that bypass the domain for optimized reads via the read model interface. -
Repository (
internal/app/product/repo/): Spanner implementation. Repositories return*spanner.Mutation— they never apply mutations themselves. TheUpdateMutmethod reads the change tracker to build targeted updates (only dirty fields). -
Transport (
internal/transport/grpc/product/): Thin gRPC handlers that validate requests, map to use case inputs, call the use case, and map domain errors to gRPC status codes.
- Golden Mutation Pattern: Every write operation: Load → Domain logic → Build plan (mutations) → Apply plan atomically
- CQRS: Commands go through the domain aggregate; queries use a read model with DTOs
- Transactional Outbox: Domain events are stored in the
outbox_eventstable within the same Spanner transaction as the aggregate mutation - Change Tracking: The
ChangeTrackeron the Product aggregate tracks which fields were modified, enabling targeted Spanner updates - CommitPlan: A local wrapper around
spanner.Client.Apply()that batches mutations (replaces the privategithub.com/Vektor-AI/commitplanlibrary)
Monetary values use *big.Rat (rational numbers) for precise decimal arithmetic. Stored in Spanner as a numerator/denominator INT64 pair.
price := domain.NewMoney(1999, 100) // $19.99-
commitplan wrapper: The required
github.com/Vektor-AI/commitplanlibrary is private. Implemented a minimal compatible wrapper (internal/pkg/committer/) that batches*spanner.Mutationand applies them viaspanner.Client.Apply(). -
Product starts as
draft: New products must be explicitly activated before discounts can be applied. This enforces a clear lifecycle: draft → active → inactive → archived. -
One discount per product: Only one active discount at a time per the requirements. Applying a new discount replaces the existing one.
-
Offset-based pagination: Used for
ListProductswith limit/offset. Simpler than cursor-based pagination and sufficient for the use case. -
FakeClock for testing: Time is injected via a
Clockinterface, allowing deterministic E2E tests without real time dependencies. -
Outbox events are stored, not published: Per requirements, the outbox pattern stores events transactionally. A separate processor (not implemented) would publish them.
├── cmd/server/main.go # Entry point, migrations, gRPC server
├── internal/
│ ├── app/product/
│ │ ├── domain/ # Pure domain (aggregate, value objects, events, errors)
│ │ │ └── services/ # Domain services (pricing calculator)
│ │ ├── usecases/ # Command interactors (create, update, activate, etc.)
│ │ ├── queries/ # Query handlers (get, list)
│ │ ├── contracts/ # Repository & read model interfaces
│ │ └── repo/ # Spanner implementations
│ ├── models/ # DB model structs & mutation helpers
│ ├── transport/grpc/product/ # gRPC handlers, mappers, error mapping
│ ├── services/ # DI container
│ └── pkg/ # Shared utilities (committer, clock)
├── proto/product/v1/ # Proto definition & generated code
├── migrations/ # Spanner DDL
├── tests/e2e/ # End-to-end tests against Spanner emulator
├── docker-compose.yml # Spanner emulator
└── Makefile
Unit tests cover domain logic in isolation (55 tests):
go test ./internal/... -vE2E tests run against the Spanner emulator:
docker-compose up -d
SPANNER_EMULATOR_HOST=localhost:9010 go test ./tests/e2e/ -v -count=1E2E tests cover: product creation, updates, activation/deactivation, discount application/removal, business rule validation, and outbox event verification.