TL;DR: In this session we gave Claude Code a descriptive natural-language prompt and, using the Hexago MCP server, it generated the complete scaffolding for a Go project with hexagonal architecture. We then implemented all the business logic layer by layer until we had a working REST API with PostgreSQL, migrations, and a CLI command to manage them.
The project: Family Pantry API
A backend API to manage a family pantry. Products are identified by their EAN-13 barcode. The system must:
- Maintain a product catalog with categories.
- Record stock entries and exits (movements).
- Validate that stock never goes negative.
- Generate stock reports and low-stock alerts.
Pre-requisites
Before start generating the project we needed to do some tasks
- Create the project folder
mkdir pantry
cd pantry
- Register the MCP. For these tests I was registering it as per project, but you can register it globally if you like, just check the documentation about it. This command created a
.mcp.jsonfile in the root of the folder.
claude mcp add --scope project hexago -- hexago mcp
- Copied the initial prompt into a file,
pantry-init-prompt-natural-en.mdin this case.
# Family Pantry API — Go Project with Hexagonal Architecture
You have access to the **Hexago** MCP server, a scaffolding CLI for Go projects with
hexagonal architecture (Ports & Adapters). Use it to generate the complete structure
for the project described below. Work in order and validate the architecture at the end.
---
## Project Context
A backend API to manage a **family pantry**. Products come from purchases at wholesale
stores and are identified by their **EAN-13** barcode. The system must maintain a product
catalog and track their movement (stock entries and exits).
**Target directory:** `/home/pato/go/src/github.com/padiazg/tmp/pantry`
---
## 1. Initialization
Create a new project named `pantry` with Go module `github.com/padiazg/pantry`.
Use **Chi** as the HTTP framework. The project is a standard HTTP server.
Include support for Docker, database migrations, and observability (health checks / metrics).
---
## 2. Domain
### Entities
**Product** — an item stored in the pantry.
Fields: `ean13` (string, natural key), `name`, `description`, `unit` (string — e.g. "kg", "liters", "units"),
`minStock` (float64), `currentStock` (float64), `categoryID` (string), `active` (bool),
`createdAt` and `updatedAt` (time.Time).
**Category** — a grouping of products.
Fields: `id` (string), `name`, `description` (string), `createdAt` (time.Time).
**Movement** — a record of a stock entry or exit.
Fields: `id` (string), `productEan13` (string), `type` (string — see value object),
`quantity` (float64), `reason`, `notes`, `createdBy` (string), `createdAt` (time.Time).
### Value Objects
- **MovementType** — represents the movement type: `"in"` (entry) or `"out"` (exit).
Must be implemented as typed constants in the domain.
- **StockLevel** — a quantity with positive-value validation (> 0).
- **EAN13** — a 13-digit barcode. Must validate that the check digit is correct
according to the standard GS1 algorithm.
---
## 3. Business Services
Generate the following services in the business logic layer:
- **ManageProduct** — create, update, and activate/deactivate products.
- **GetProduct** — fetch a product by EAN-13 and list products with filters.
- **ManageCategory** — create and update categories.
- **RecordMovement** — record a stock entry or exit, enforcing business rules.
- **GetMovements** — query movement history with optional filters.
- **GetStockReport** — generate a current stock summary with low-stock alerts.
---
## 4. Adapters
### Output — Database Repositories
Generate a repository for each entity: `ProductRepository`, `CategoryRepository`,
and `MovementRepository`. All are secondary adapters of type database.
### Input — HTTP Handlers
Generate an HTTP handler for each resource: `ProductHandler`, `CategoryHandler`,
and `MovementHandler`. These are primary adapters.
---
## 5. Migrations
Create migrations with the following names, in this order:
1. `create_categories_table`
2. `create_products_table`
3. `create_movements_table`
4. `add_stock_indexes`
The reference schema to implement them is:
```sql
CREATE TABLE categories (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
ean13 CHAR(13) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
unit VARCHAR(30) NOT NULL,
min_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
current_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
category_id VARCHAR(36) REFERENCES categories(id),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE movements (
id VARCHAR(36) PRIMARY KEY,
product_ean13 CHAR(13) NOT NULL REFERENCES products(ean13),
type VARCHAR(3) NOT NULL CHECK (type IN ('in', 'out')),
quantity DECIMAL(10,3) NOT NULL CHECK (quantity > 0),
reason VARCHAR(200),
notes TEXT,
created_by VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_movements_product ON movements(product_ean13);
CREATE INDEX idx_movements_created ON movements(created_at);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_active ON products(active);
```
````
---
## 6. Infrastructure
Generate the following support tools:
- A **validator** named `PantryValidator`.
- A **mapper** named `ProductMapper`.
- A **mapper** named `MovementMapper`.
---
## 7. Validation
Once all components are generated, verify that the architecture is correct
and that there are no dependency violations between layers.
---
## 8. REST API (reference for handler implementation)
### Categories
```
GET /api/v1/categories # list all
POST /api/v1/categories # create
GET /api/v1/categories/{id} # get by ID
PUT /api/v1/categories/{id} # update
```
### Products
```
GET /api/v1/products # list (?category=&active=&low_stock=)
POST /api/v1/products # create
GET /api/v1/products/{ean13} # get by EAN-13
PUT /api/v1/products/{ean13} # update
DELETE /api/v1/products/{ean13} # deactivate (soft delete)
GET /api/v1/products/{ean13}/stock # current stock + minimum level
GET /api/v1/products/{ean13}/movements # product movement history
```
### Movements
```
GET /api/v1/movements # history (?ean13=&type=&from=&to=)
POST /api/v1/movements # record a movement (in/out)
GET /api/v1/movements/{id} # movement detail
```
### Reports
```
GET /api/v1/reports/stock # current stock summary
GET /api/v1/reports/low-stock # products below minimum
```
---
## 9. Business Rules
1. **Non-negative stock**: when recording an exit (`out`), reject if `currentStock - quantity < 0`.
2. **Atomic stock update**: record the `Movement` and update `Product.currentStock`
in the same database transaction.
3. **Soft delete**: deactivating a product sets `active = false`; the record is never deleted.
4. **Low-stock alert**: a product is low on stock when `currentStock <= minStock`.
5. **EAN-13 validation**: verify that the code has 13 digits and that the check digit
is correct (GS1 algorithm) before persisting any product or movement.
6. **Movement IDs as UUID v4**: generated in the service layer, not delegated to the database.
---
## 10. Technology Stack
| Component | Library |
|-------------|------------------------------------------|
| HTTP | `github.com/go-chi/chi/v5` |
| Database | PostgreSQL + `github.com/jmoiron/sqlx` |
| Migrations | `github.com/golang-migrate/migrate` |
| UUID | `github.com/google/uuid` |
| Config | `github.com/spf13/viper` (already included) |
| Validation | `github.com/go-playground/validator/v10` |
## 11. Environment Variables
```env
PANTRY_SERVER_PORT=8080
PANTRY_DATABASE_URL=postgres://user:pass@localhost:5432/pantry?sslmode=disable
PANTRY_LOG_LEVEL=info
PANTRY_LOG_FORMAT=json
```
---
When the scaffolding is complete, let me know and we'll start implementing the business
logic layer by layer.
Session 1 — Scaffolding with Hexago MCP
I prompted Claude Code to follow the instruction from the file we just created
──────────────────────────────────────────────────────────────────────────────────
❯ follow the instructions in pantry-init-prompt-natural-en.md
──────────────────────────────────────────────────────────────────────────────────
Claude Code read the file and used the Hexago MCP server to generate the entire structure in order:
1. Project initialization
hexago_init
name: pantry
module: github.com/padiazg/pantry
framework: chi
project_type: http-server
with_docker: true
with_migrations: true
with_observability: true
This generated: main.go, cmd/root.go, Dockerfile, compose.yaml, Makefile,
pkg/logger/, pkg/server/, internal/config/, internal/observability/ and the
base folder structure.
2. Domain entities
hexago_add_domain_entity name: Product fields: ean13:string,name:string,...
hexago_add_domain_entity name: Category fields: id:string,name:string,...
hexago_add_domain_entity name: Movement fields: id:string,productEan13:string,...
3. Value objects
hexago_add_domain_valueobject name: EAN13 fields: value:string
hexago_add_domain_valueobject name: MovementType fields: value:string
hexago_add_domain_valueobject name: StockLevel fields: value:float64
4. Business services
hexago_add_service name: ManageProduct
hexago_add_service name: GetProduct
hexago_add_service name: ManageCategory
hexago_add_service name: RecordMovement
hexago_add_service name: GetMovements
hexago_add_service name: GetStockReport
5. Secondary adapters (Repositories)
hexago_add_adapter direction: secondary adapter_type: database name: ProductRepository
hexago_add_adapter direction: secondary adapter_type: database name: CategoryRepository
hexago_add_adapter direction: secondary adapter_type: database name: MovementRepository
6. Primary adapters (HTTP Handlers)
hexago_add_adapter direction: primary adapter_type: http name: ProductHandler
hexago_add_adapter direction: primary adapter_type: http name: CategoryHandler
hexago_add_adapter direction: primary adapter_type: http name: MovementHandler
7. SQL migrations
hexago_add_migration name: create_categories_table migration_type: sql
hexago_add_migration name: create_products_table migration_type: sql
hexago_add_migration name: create_movements_table migration_type: sql
hexago_add_migration name: add_stock_indexes migration_type: sql
The .up.sql files were filled in with the schema from the spec:
-- 000001_create_categories_table.up.sql
CREATE TABLE categories (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 000002_create_products_table.up.sql
CREATE TABLE products (
ean13 CHAR(13) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
description TEXT,
unit VARCHAR(30) NOT NULL,
min_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
current_stock DECIMAL(10,3) NOT NULL DEFAULT 0,
category_id VARCHAR(36) REFERENCES categories(id),
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 000003_create_movements_table.up.sql
CREATE TABLE movements (
id VARCHAR(36) PRIMARY KEY,
product_ean13 CHAR(13) NOT NULL REFERENCES products(ean13),
type VARCHAR(3) NOT NULL CHECK (type IN ('in', 'out')),
quantity DECIMAL(10,3) NOT NULL CHECK (quantity > 0),
reason VARCHAR(200),
notes TEXT,
created_by VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- 000004_add_stock_indexes.up.sql
CREATE INDEX idx_movements_product ON movements(product_ean13);
CREATE INDEX idx_movements_created ON movements(created_at);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_active ON products(active);
8. Infrastructure
hexago_add_tool tool_type: validator name: PantryValidator
hexago_add_tool tool_type: mapper name: ProductMapper
hexago_add_tool tool_type: mapper name: MovementMapper
9. Architecture validation
hexago_validate
Result: 11/11 checks passed ✓
✓ Domain layer has no external dependencies
✓ Services only depend on domain interfaces
✓ Adapters depend on domain, not on each other
✓ No circular dependencies
... (8 more checks)
Session 2 — Layer-by-layer implementation
Now that the scaffolding was completed we asked to implement the bussiness logic. Here is where Claude needs to fill the gaps and construct around the scaffolding. This is where the created structure and project validation feature from our tool becomes valuable by forcing the generator to stay within the established limits.
Infrastructure
- Validator: PantryValidator
- Mappers: ProductMapper, MovementMapper
Architecture validation: 11/11 checks passed — no dependency violations between layers.
Ready to start implementing the business logic layer by layer. Where would you like to begin?
✻ Sautéed for 1m 52s
──────────────────────────────────────────────────────────────────────────────────
❯ implement the business logic layer by layer
──────────────────────────────────────────────────────────────────────────────────
With the structure generated, Claude Code implemented the real logic, starting from the inside (domain) outward (HTTP).
Value objects
EAN13 with GS1 validation (check digit algorithm):
// internal/core/domain/e_a_n13.go
type EAN13 struct{ Value string }
func NewEAN13(code string) (EAN13, error) {
v := EAN13{Value: code}
return v, v.Validate()
}
func (v EAN13) Validate() error {
if len(v.Value) != 13 {
return fmt.Errorf("EAN-13 must be 13 digits, got %d", len(v.Value))
}
for _, c := range v.Value {
if c < '0' || c > '9' {
return fmt.Errorf("EAN-13 must contain only digits")
}
}
// GS1 check digit: alternating weights 1/3 on first 12 digits
sum := 0
for i := 0; i < 12; i++ {
d := int(v.Value[i] - '0')
if i%2 == 0 {
sum += d
} else {
sum += d * 3
}
}
expected := (10 - (sum % 10)) % 10
actual := int(v.Value[12] - '0')
if expected != actual {
return fmt.Errorf("invalid EAN-13 check digit: expected %d, got %d", expected, actual)
}
return nil
}
MovementType as typed constants (not a struct):
// internal/core/domain/movement_type.go
type MovementType string
const (
MovementTypeIn MovementType = "in"
MovementTypeOut MovementType = "out"
)
func NewMovementType(value string) (MovementType, error) {
mt := MovementType(value)
return mt, mt.Validate()
}
func (v MovementType) Validate() error {
switch v {
case MovementTypeIn, MovementTypeOut:
return nil
}
return fmt.Errorf("invalid movement type %q: must be %q or %q",
v, MovementTypeIn, MovementTypeOut)
}
Product entity with business methods
// internal/core/domain/product.go
func (e *Product) IsLowStock() bool {
return e.CurrentStock <= e.MinStock
}
func (e *Product) Deactivate() {
e.Active = false
e.UpdatedAt = time.Now()
}
func (e *Product) ApplyMovement(movType MovementType, quantity float64) error {
switch movType {
case MovementTypeIn:
e.CurrentStock += quantity
case MovementTypeOut:
if e.CurrentStock-quantity < 0 {
return fmt.Errorf("insufficient stock: available %.3f, requested %.3f",
e.CurrentStock, quantity)
}
e.CurrentStock -= quantity
}
e.UpdatedAt = time.Now()
return nil
}
Ports (repository interfaces)
// internal/core/domain/ports.go
var ErrNotFound = errors.New("not found")
type ProductRepository interface {
Create(ctx context.Context, product *Product) error
FindByEAN13(ctx context.Context, ean13 string) (*Product, error)
Update(ctx context.Context, product *Product) error
List(ctx context.Context, filter ProductFilter) ([]*Product, error)
}
type MovementRepository interface {
Create(ctx context.Context, movement *Movement) error
FindByID(ctx context.Context, id string) (*Movement, error)
List(ctx context.Context, filter MovementFilter) ([]*Movement, error)
// Atomic update: movement + stock in a single transaction
CreateWithStockUpdate(ctx context.Context, movement *Movement, newStock float64) error
}
RecordMovement — The core service
Implements the business rules: validate type, validate positive quantity, check sufficient stock, record the movement, and update stock atomically.
// internal/core/services/record_movement.go
func (s *RecordMovementService) Execute(
ctx context.Context,
input RecordMovementInput,
) (*RecordMovementOutput, error) {
// Rule 1: valid movement type
movType, err := domain.NewMovementType(input.Type)
if err != nil {
return nil, fmt.Errorf("invalid movement type: %w", err)
}
// Rule 2: quantity must be positive
if _, err := domain.NewStockLevel(input.Quantity); err != nil {
return nil, fmt.Errorf("invalid quantity: %w", err)
}
// Fetch product
product, err := s.products.FindByEAN13(ctx, input.ProductEan13)
if err != nil {
return nil, fmt.Errorf("product not found: %w", err)
}
// Rule 3: check sufficient stock (for outbound movements)
if err := product.ApplyMovement(movType, input.Quantity); err != nil {
return nil, err
}
// Rule 4: UUID v4 generated in the service layer
movement, err := domain.NewMovement(uuid.NewString(), input.ProductEan13,
string(movType), input.Quantity, input.Reason, input.Notes, input.CreatedBy)
if err != nil {
return nil, err
}
// Rule 5: atomic database update
if err := s.movements.CreateWithStockUpdate(ctx, movement, product.CurrentStock); err != nil {
return nil, fmt.Errorf("recording movement: %w", err)
}
return &RecordMovementOutput{Movement: movement, Product: product}, nil
}
MovementRepository — Atomic transaction
// internal/adapters/secondary/database/movement_repository.go
func (r *MovementRepository) CreateWithStockUpdate(
ctx context.Context,
m *domain.Movement,
newStock float64,
) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx,
`INSERT INTO movements (id, product_ean13, type, quantity, reason, notes, created_by, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`,
m.Id, m.ProductEan13, m.Type, m.Quantity, m.Reason, m.Notes, m.CreatedBy, m.CreatedAt,
)
if err != nil {
return fmt.Errorf("inserting movement: %w", err)
}
_, err = tx.ExecContext(ctx,
`UPDATE products SET current_stock=$1, updated_at=$2 WHERE ean13=$3`,
newStock, time.Now(), m.ProductEan13,
)
if err != nil {
return fmt.Errorf("updating product stock: %w", err)
}
return tx.Commit()
}
ProductRepository — Dynamic filters
// internal/adapters/secondary/database/product_repository.go
func (r *ProductRepository) List(
ctx context.Context,
filter domain.ProductFilter,
) ([]*domain.Product, error) {
q := `SELECT ean13, name, description, unit, min_stock, current_stock,
category_id, active, created_at, updated_at
FROM products WHERE 1=1`
args := []any{}
idx := 1
if filter.CategoryID != "" {
q += fmt.Sprintf(" AND category_id = $%d", idx)
args = append(args, filter.CategoryID)
idx++
}
if filter.Active != nil {
q += fmt.Sprintf(" AND active = $%d", idx)
args = append(args, *filter.Active)
idx++
}
if filter.LowStock {
q += " AND current_stock <= min_stock"
}
q += " ORDER BY name"
rows, err := r.db.QueryContext(ctx, q, args...)
// ... scan rows
}
Shared HTTP Helpers
// internal/adapters/primary/http/helpers.go
package httpserver
func respondJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondHTTPError(w http.ResponseWriter, err error) {
if errors.Is(err, domain.ErrNotFound) {
respondError(w, http.StatusNotFound, err.Error())
return
}
respondError(w, http.StatusInternalServerError, "internal server error")
}
Dependency injection in cmd/run.go
// cmd/run.go — manual wiring, no DI framework
db, err := sql.Open("postgres", cfg.Database.URL)
// Secondary adapters
productRepo := database.NewProductRepository(db)
categoryRepo := database.NewCategoryRepository(db)
movementRepo := database.NewMovementRepository(db)
// Domain services
manageProduct := services.NewManageProductService(productRepo)
getProduct := services.NewGetProductService(productRepo)
manageCategory := services.NewManageCategoryService(categoryRepo)
recordMovement := services.NewRecordMovementService(productRepo, movementRepo)
getMovements := services.NewGetMovementsService(movementRepo)
getStockReport := services.NewGetStockReportService(productRepo)
// Primary adapters
productHandler := httpserver.NewProductHandler(manageProduct, getProduct, getMovements, getStockReport)
categoryHandler := httpserver.NewCategoryHandler(manageCategory)
movementHandler := httpserver.NewMovementHandler(recordMovement, getMovements)
// HTTP Server
srv := httpserver.New(&httpserver.ServerConfig{
Config: cfg,
Logger: log,
Metrics: metrics,
Products: productHandler,
Categories: categoryHandler,
Movements: movementHandler,
})
Bootstrapping
Before trying to start the new app we must bootstrap it, meaning we need to create a database, a configuration file, and run the migrations to create the tables it will use.
Database
I already have Postgres and MariaDB instances running in my kubernetes cluster, so I just needed to create the database and an user to access it.
CREATE DATABASE pantry;
CREATE USER pantry WITH PASSWORD 'secr3t';
GRANT ALL ON DATABASE pantry TO pantry_user;
GRANT ALL ON SCHEMA public TO pantry_user;
Configurarion file
Created a .pantry.yaml file, as stated in the generated readme.md
server:
port: 8080
host: 0.0.0.0
readtimeout: 15s
writetimeout: 15s
idletimeout: 60s
shutdowntimeout: 30s
database:
url: postgres://pantry_user:secr3t@postgres.kube.patodiaz.dev:5432/pantry?sslmode=disable
loglevel: info
logformat: json
Migration
Now we should’ve run the migration from the app, but it was not possible as the coded was there but not connected to any way to trigger it, which led us to the session 3 with Claude.
Session 3 — CLI command for migrations
Claude knew how to implement the migration procedure but had no clue that it needed to be linked to a cli command from our app, or perhaps an API endpoint. So let’s ask Claude to expose it as a need command from or app.
──────────────────────────────────────────────────────────────────────────────────
❯ create a command to run migrations? e.g.: pantry migrate
──────────────────────────────────────────────────────────────────────────────────
Claude Code created cmd/migrate.go adding three subcommands to the CLI:
// cmd/migrate.go
var migrateCmd = &cobra.Command{
Use: "migrate",
Short: "Database migration management",
Long: `Run, rollback or inspect database migrations.`,
}
var migrateUpCmd = &cobra.Command{
Use: "up",
Short: "Apply all pending migrations",
RunE: func(cmd *cobra.Command, args []string) error {
return withMigrator(func(m *infra.Migrator) error {
return m.Up()
})
},
}
var migrateDownCmd = &cobra.Command{
Use: "down",
Short: "Roll back the last applied migration",
RunE: func(cmd *cobra.Command, args []string) error {
return withMigrator(func(m *infra.Migrator) error {
return m.Down()
})
},
}
var migrateVersionCmd = &cobra.Command{
Use: "version",
Short: "Show the current migration version",
RunE: func(cmd *cobra.Command, args []string) error {
return withMigrator(func(m *infra.Migrator) error {
version, dirty, err := m.Version()
if err != nil {
return err
}
dirtyFlag := ""
if dirty {
dirtyFlag = " (dirty)"
}
fmt.Printf("Current migration version: %d%s\n", version, dirtyFlag)
return nil
})
},
}
// withMigrator opens the DB, creates the Migrator, calls fn, then closes the DB.
func withMigrator(fn func(*infra.Migrator) error) error {
cfg := GetConfig()
log := logger.New(&logger.Config{Level: cfg.LogLevel, Format: cfg.LogFormat})
db, err := sql.Open("postgres", cfg.Database.URL)
if err != nil {
return fmt.Errorf("opening database: %w", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
return fmt.Errorf("connecting to database: %w", err)
}
return fn(infra.NewMigrator(db, log))
}
func init() {
migrateCmd.AddCommand(migrateUpCmd)
migrateCmd.AddCommand(migrateDownCmd)
migrateCmd.AddCommand(migrateVersionCmd)
rootCmd.AddCommand(migrateCmd)
}
Usage:
# Apply all pending migrations
pantry migrate up
# Roll back the last migration
pantry migrate down
# Show the current version
pantry migrate version
# Current migration version: 4
Back to finish the bootstrapping
After resolving the missing migration command issue I just run the migration and created the tables. No issues.
./pantry migrate up
2026/03/05 14:12:29 [INFO] Running migrations...
2026/03/05 14:12:30 [INFO] Migrations completed successfully
$ psql pantry pantry_user
psql (17.6)
Type "help" for help.
pantry=> \dt
List of relations
Schema | Name | Type | Owner
--------+-------------------+-------+-------------
public | categories | table | pantry_user
public | movements | table | pantry_user
public | products | table | pantry_user
public | schema_migrations | table | pantry_user
(4 rows)
pantry=>
Tests
I built and ran the app, then in another terminal:
# add Grains & Pastas category
$ curl -X POST --data '{"name": "Grains & Pastas"}' http://localhost:8080/api/v1/categories
{"id":"30e3c996-48ca-452c-8c3a-22cd32db3e45","name":"Grains \u0026 Pastas","description":"","created_at":"2026-03-05T14:19:01.677334141-03:00"}
# add Canned & Jarred Goods category
$ curl -X POST --data '{"name": "Canned & Jarred Goods"}' http://localhost:8080/api/v1/categories
{"id":"b4415623-ab49-4558-88e5-ae7604eef512","name":"Canned \u0026 Jarred Goods","description":"","created_at":"2026-03-05T14:19:32.83746188-03:00"}
# list categories
$ curl -s http://localhost:8080/api/v1/categories | jq
[
{
"id": "b4415623-ab49-4558-88e5-ae7604eef512",
"name": "Canned & Jarred Goods",
"description": "",
"created_at": "2026-03-05T14:19:32.837462Z"
},
{
"id": "30e3c996-48ca-452c-8c3a-22cd32db3e45",
"name": "Grains & Pastas",
"description": "",
"created_at": "2026-03-05T14:19:01.677334Z"
}
]
# register a product
$ curl -X POST --data '{"name": "Pasta Express", "unit": "gr", "min_stock": 2, "category_id": "30e3c996-48ca-452c-8c3a-22cd32db3e45"}' http://localhost:8080/api/v1/products/0752356783662 | jq
{
"ean13": "0752356783662",
"name": "Pasta Express",
"description": "",
"unit": "gr",
"min_stock": 2,
"current_stock": 0,
"category_id": "30e3c996-48ca-452c-8c3a-22cd32db3e45",
"active": true,
"created_at": "2026-03-05T14:40:26.823634649-03:00",
"updated_at": "2026-03-05T14:40:26.823634649-03:00"
}
# registrer a re-stocking
$ curl -s -X POST --data '{"product_ean13":"0752356783662", "type":"in", "quantity": 10, "reason":"re-stocking", "created_by": "pato"}' http://localhost:8080/api/v1/movements | jq
{
"id": "bc69126b-d306-4bff-ade6-87e54dc68a37",
"product_ean13": "0752356783662",
"type": "in",
"quantity": 10,
"reason": "re-stocking",
"created_by": "pato",
"created_at": "2026-03-05T14:49:46.350019631-03:00"
}
# get product's details
$ curl -s http://localhost:8080/api/v1/products/0752356783662 | jq
{
"ean13": "0752356783662",
"name": "Pasta Express",
"description": "",
"unit": "gr",
"min_stock": 2,
"current_stock": 10,
"category_id": "30e3c996-48ca-452c-8c3a-22cd32db3e45",
"active": true,
"created_at": "2026-03-05T14:40:26.823635Z",
"updated_at": "2026-03-05T14:49:46.367465Z"
}
# register a usage
$ curl -s -X POST --data '{"product_ean13":"0752356783662", "type":"out", "quantity": 1, "reason":"Dinner", "created_by": "sonia"}' http://localhost:8080/api/v1/movements | jq
{
"id": "490e73cc-5566-4ef6-a38c-f91994673a67",
"product_ean13": "0752356783662",
"type": "out",
"quantity": 1,
"reason": "Dinner",
"created_by": "sonia",
"created_at": "2026-03-05T14:53:54.084178902-03:00"
}
# check current stock
$ curl -s http://localhost:8080/api/v1/products/0752356783662/stock | jq
{
"current_stock": 9,
"ean13": "0752356783662",
"is_low_stock": false,
"min_stock": 2
}
Errors encountered and how they were resolved
1. Package name conflict
The scaffold generated handlers with package http, but server.go already used
package httpserver. Solution: rewrite all files in the directory with package httpserver.
// Before (conflicts with stdlib net/http)
package http
// After
package httpserver
2. Scaffold tests with outdated API
The scaffold generated tests referencing methods like service.Execute and
types like services.ManageProductInput that had been replaced by specific methods
(Create, Update, SetActive). Solution: replace the test files with stubs
using t.Skip() until mocks are implemented.
func TestManageProductService(t *testing.T) {
t.Skip("implement with mock repositories")
}
3. Dependencies not registered in go.mod
When using github.com/google/uuid without having added it previously:
go get github.com/google/uuid github.com/stretchr/testify github.com/lib/pq
go mod tidy
4. Compile-time interface check
To ensure repositories correctly implement their interfaces:
// At the top of each repository file
var _ domain.ProductRepository = (*ProductRepository)(nil)
If any method is missing, the compiler fails immediately with a clear error.
Resulting REST API
GET /ping # health check
# Categories
GET /api/v1/categories # list all
POST /api/v1/categories # create
GET /api/v1/categories/{id} # get by ID
PUT /api/v1/categories/{id} # update
# Products
GET /api/v1/products # list (?category=&active=&low_stock=)
POST /api/v1/products/{ean13} # create
GET /api/v1/products/{ean13} # get by EAN-13
PUT /api/v1/products/{ean13} # update
DELETE /api/v1/products/{ean13} # deactivate (soft delete)
GET /api/v1/products/{ean13}/stock # current stock
GET /api/v1/products/{ean13}/movements # product movement history
# Movements
GET /api/v1/movements # history (?ean13=&type=&from=&to=)
POST /api/v1/movements # record a movement
GET /api/v1/movements/{id} # detail
# Reports
GET /api/v1/reports/stock # current stock summary
GET /api/v1/reports/low-stock # products below minimum
Final project structure
pantry/
├── cmd/
│ ├── root.go # Cobra root + Viper config
│ ├── run.go # "run" command: DI wiring + start server
│ └── migrate.go # "migrate" command: up/down/version
├── internal/
│ ├── config/
│ │ └── config.go # Config struct + load from env
│ ├── core/
│ │ ├── domain/
│ │ │ ├── product.go
│ │ │ ├── category.go
│ │ │ ├── movement.go
│ │ │ ├── e_a_n13.go # EAN-13 value object with GS1 validation
│ │ │ ├── movement_type.go # Typed string constants
│ │ │ ├── stock_level.go # Value object > 0
│ │ │ └── ports.go # Repository interfaces
│ │ └── services/
│ │ ├── manage_product.go
│ │ ├── get_product.go
│ │ ├── manage_category.go
│ │ ├── record_movement.go # <- Core service with business rules
│ │ ├── get_movements.go
│ │ └── get_stock_report.go
│ ├── adapters/
│ │ ├── primary/http/
│ │ │ ├── server.go # Chi router + route setup
│ │ │ ├── helpers.go # respondJSON, respondHTTPError, ...
│ │ │ ├── product_handler.go
│ │ │ ├── category_handler.go
│ │ │ └── movement_handler.go
│ │ └── secondary/database/
│ │ ├── product_repository.go
│ │ ├── category_repository.go
│ │ └── movement_repository.go # <- CreateWithStockUpdate (transaction)
│ ├── infrastructure/
│ │ ├── database/
│ │ │ └── migrator.go # golang-migrate wrapper
│ │ └── mapper/
│ │ ├── product_mapper.go
│ │ └── movement_mapper.go
│ └── observability/
│ ├── health.go
│ ├── metrics.go
│ └── server.go
├── migrations/
│ ├── 000001_create_categories_table.up.sql
│ ├── 000001_create_categories_table.down.sql
│ ├── 000002_create_products_table.up.sql
│ ├── 000002_create_products_table.down.sql
│ ├── 000003_create_movements_table.up.sql
│ ├── 000003_create_movements_table.down.sql
│ ├── 000004_add_stock_indexes.up.sql
│ └── 000004_add_stock_indexes.down.sql
├── pkg/
│ ├── logger/
│ └── server/
├── Dockerfile
├── compose.yaml
├── Makefile
├── go.mod
└── main.go
Conclusions
What worked really well
The natural-language prompt was enough: a single markdown file with the project spec was sufficient for Claude Code + Hexago to generate the entire structure without ambiguity.
Hexago MCP keeps the architecture honest: all 11 validations passed after scaffolding; the hexagonal dependency model was respected throughout the implementation.
Layer-by-layer implementation: starting from the domain outward (value objects → entities → ports → services → repos → handlers → DI) avoided circular dependencies and made it easy to reason about each piece in isolation.
Business rules explicit in code:
ApplyMovement,IsLowStock,Deactivatelive in the domain — not scattered between the handler and the repository.
What required manual adjustment
- The scaffold generated some files with APIs that didn’t exactly match the chosen
mplementation (generic
Executemethods vs. semantically named methods). Resolved by replacing the stubs with correct implementations. - The handler package needed renaming from
httptohttpserverto avoid the conflict with the standard library. - Not an issue but something to improve in next versions. I changed the
Newprocedure atinternal\adapters\primary\http\server.gofrom:
// server is the Chi-backed HTTP server.
type server struct {
router chi.Router
httpSrv *http.Server
cfg *config.Config
log logger.Logger
metrics *observability.PrometheusMetrics
products *ProductHandler
categories *CategoryHandler
movements *MovementHandler
}
// New creates and configures a new Chi HTTP server with all handlers wired in.
func New(cfg *config.Config, log logger.Logger, metrics *observability.PrometheusMetrics, productHandler *ProductHandler, categoryHandler *CategoryHandler, movementHandler *MovementHandler) srv.Server {...
to
// server is the Chi-backed HTTP server.
type server struct {
router chi.Router
httpSrv *http.Server
cfg *config.Config
log logger.Logger
metrics *observability.PrometheusMetrics
products *ProductHandler
categories *CategoryHandler
movements *MovementHandler
}
type ServerConfig struct {
Config *config.Config
Logger logger.Logger
Metrics *observability.PrometheusMetrics
Products *ProductHandler
Categories *CategoryHandler
Movements *MovementHandler
}
// New creates and configures a new Chi HTTP server with all handlers wired in.
func New(cfg *ServerConfig) srv.Server {...
When implementing the Factory pattern it is a good practice to define a configuration struct type which will be the incomming parameter for the New function. This way the code is easier to read and also easier to implement unit-tests.
Natural Next Steps
These could be good subjects for next iterations with Claude Code
- Unit tests with mocks for the services.
- Integration tests for the repositories (using
testcontainers-go). - Implementation of
ProductMapperandMovementMapperonce persistence DTOs are defined.
Final thoughts
I’m quite happy with the outcome of this experiment, with just 3 iterations with Claude Code we ended up with a fully functional API. I’m aware that the result doesn’t implement any kind of security, but the structure is solid and adding it shouldn’t be a problem at all.
By the way, the project is here if anybody wants to check it.