Go 1.25 Production API: DDD, TDD, BDD, PostgreSQL, Redis — Complete Project Setup
Build a production-ready Go 1.25 REST API from scratch. Full DDD architecture, TDD/BDD testing, PostgreSQL, Redis caching, Chi router, and every file explained with clean code patterns.
Why Another Go API Guide
Every Go tutorial teaches you the same thing: a flat main.go with handlers that call the database directly. It works for demos. It fails in production.
Think of building software like building a hospital. You would never put the pharmacy inside the operating room. You would never route patients through the kitchen. Every department has a clear boundary, a clear responsibility, and a clear way to communicate with other departments.
Production software works the same way. The domain logic does not know about HTTP. The database layer does not know about business rules. The cache layer does not know about either. Each layer has a boundary, a contract, and a reason to exist.
This guide builds a complete invoice management API from the first go mod init to a deployable binary. Every file is shown. Every decision is explained. Every package is chosen for a reason. No magic. No shortcuts. No “left as an exercise for the reader.”
You will end with a project that has:
- Domain-Driven Design with clear bounded contexts
- Test-Driven Development with table-driven tests
- Behavior-Driven Development with godog and Gherkin
- PostgreSQL with migrations and connection pooling
- Redis for caching and rate limiting
- Chi router with structured middleware
- Structured logging with slog
- Configuration management with environment variables
- Docker and Docker Compose for local development
- A Makefile that ties everything together
Part 1: Project Structure — Every File Has a Home
Before writing a single line of code, design the structure. This is the decision that determines whether the project scales or collapses.
invoice-api/
├── cmd/
│ └── api/
│ └── main.go # Entry point — wires everything
├── internal/
│ ├── domain/
│ │ ├── invoice.go # Entity + value objects
│ │ ├── invoice_status.go # Status enum + transitions
│ │ ├── invoice_repository.go # Repository interface (port)
│ │ ├── invoice_cache.go # Cache interface (port)
│ │ ├── invoice_service.go # Domain service (business rules)
│ │ └── errors.go # Domain-specific errors
│ ├── application/
│ │ ├── create_invoice.go # Use case: create
│ │ ├── get_invoice.go # Use case: get by ID
│ │ ├── list_invoices.go # Use case: list with filters
│ │ ├── update_status.go # Use case: status transitions
│ │ └── dto.go # Data transfer objects
│ ├── infrastructure/
│ │ ├── postgres/
│ │ │ ├── connection.go # Connection pool setup
│ │ │ ├── invoice_repo.go # PostgreSQL repository impl
│ │ │ └── migrations.go # Schema migrations
│ │ ├── redis/
│ │ │ ├── connection.go # Redis client setup
│ │ │ └── invoice_cache.go # Redis cache impl
│ │ └── config/
│ │ └── config.go # Environment variable loading
│ └── interfaces/
│ └── http/
│ ├── router.go # Chi router + middleware
│ ├── invoice_handler.go # HTTP handlers
│ ├── middleware.go # Auth, logging, recovery
│ └── response.go # Standardized JSON responses
├── test/
│ ├── integration/
│ │ └── invoice_test.go # Full integration tests
│ └── bdd/
│ ├── features/
│ │ └── invoice.feature # Gherkin scenarios
│ └── invoice_steps_test.go # Step definitions
├── migrations/
│ ├── 001_create_invoices.up.sql
│ └── 001_create_invoices.down.sql
├── docker-compose.yml
├── Dockerfile
├── Makefile
├── go.mod
└── .env.example
Every directory exists for a reason:
- cmd/ contains the entry point. Nothing else. It wires dependencies and starts the server.
- internal/domain/ is the core. It has zero external imports. No database. No HTTP. No framework. Pure Go with pure business logic.
- internal/application/ orchestrates use cases. It calls the domain and coordinates infrastructure through interfaces.
- internal/infrastructure/ implements the interfaces defined by the domain. PostgreSQL, Redis, configuration. This is where external dependencies live.
- internal/interfaces/http/ translates HTTP to application commands and back. It knows about Chi and JSON but nothing about PostgreSQL.
- test/ contains integration tests and BDD features. Unit tests live next to the code they test.
Part 2: Packages — What to Install and Why
Every dependency must justify its existence. Here is the complete go.mod with the rationale for each package.
// go.mod
module github.com/yourorg/invoice-api
go 1.25
require (
// HTTP Router — lightweight, idiomatic, stdlib-compatible
github.com/go-chi/chi/v5 v5.2.1
// PostgreSQL driver — pure Go, production-proven
github.com/jackc/pgx/v5 v5.7.4
// Redis client — full-featured, connection pooling built-in
github.com/redis/go-redis/v9 v9.7.3
// UUID generation — RFC 4122 compliant
github.com/google/uuid v1.6.0
// Environment variables — zero-dependency config loading
github.com/caarlos0/env/v11 v11.3.1
// Database migrations — SQL-based, no ORM
github.com/golang-migrate/migrate/v4 v4.18.2
// Validation — struct tag based, extensible
github.com/go-playground/validator/v10 v10.24.0
// BDD testing framework
github.com/cucumber/godog v0.15.0
// Test assertions — readable test failures
github.com/stretchr/testify v1.10.0
)
Why these packages specifically:
- chi over gin/fiber: Chi is the only router that wraps
net/httpwithout replacing it. Your handlers are standardhttp.HandlerFunc. No framework lock-in. - pgx over database/sql + lib/pq: pgx is faster, supports PostgreSQL-specific types natively (JSONB, arrays, UUID), and has built-in connection pooling via pgxpool.
- go-redis over redigo: Native context support, connection pooling, pipelining, and Lua scripting built in.
- google/uuid over other UUID libraries: Maintained by Google, RFC compliant, no external dependencies.
- caarlos0/env over viper: Viper is 15,000 lines for what env does in 500. Parse struct tags from environment variables. Nothing more.
- golang-migrate over goose/atlas: SQL files only. No Go code in migrations. Your DBA can read and review them.
- go-playground/validator over custom validation: Battle-tested, struct-tag based, supports custom validators.
- godog for BDD: The official Cucumber implementation for Go. Gherkin parsing, step definitions, hooks.
- testify for assertions:
assert.Equal(t, expected, actual)is more readable thanif got != want { t.Errorf(...) }in table-driven tests.
Part 3: The Domain Layer — Business Logic Without Dependencies
The domain is the most important layer. It contains zero imports from external packages. No database driver. No HTTP library. No framework. Only the Go standard library and your business rules.
The Invoice Entity
An invoice is not a database row. It is a business concept with rules about how it can be created, modified, and transitioned through states.
// internal/domain/invoice.go
package domain
import (
"time"
"github.com/google/uuid"
)
// Invoice represents a financial document issued to a client.
// All business rules about an invoice are enforced here.
type Invoice struct {
ID uuid.UUID
Number string
ClientName string
ClientEmail string
Items []LineItem
Status InvoiceStatus
Currency string
Notes string
IssuedAt time.Time
DueAt time.Time
PaidAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// LineItem represents a single line on an invoice.
type LineItem struct {
Description string
Quantity int
UnitPrice int64 // Cents to avoid floating point
TaxRate int // Basis points (e.g., 1600 = 16%)
}
// TotalCents returns the line item total in cents.
func (li LineItem) TotalCents() int64 {
subtotal := int64(li.Quantity) * li.UnitPrice
tax := subtotal * int64(li.TaxRate) / 10000
return subtotal + tax
}
// NewInvoice creates a validated invoice.
// The domain enforces its own rules at the point of creation.
func NewInvoice(
number, clientName, clientEmail, currency, notes string,
items []LineItem,
dueAt time.Time,
) (*Invoice, error) {
if number == "" {
return nil, ErrInvoiceNumberRequired
}
if clientName == "" {
return nil, ErrClientNameRequired
}
if clientEmail == "" {
return nil, ErrClientEmailRequired
}
if len(items) == 0 {
return nil, ErrItemsRequired
}
if dueAt.Before(time.Now()) {
return nil, ErrDueDateInPast
}
for _, item := range items {
if item.Quantity <= 0 {
return nil, ErrInvalidQuantity
}
if item.UnitPrice <= 0 {
return nil, ErrInvalidPrice
}
}
now := time.Now()
return &Invoice{
ID: uuid.New(),
Number: number,
ClientName: clientName,
ClientEmail: clientEmail,
Items: items,
Status: StatusDraft,
Currency: currency,
Notes: notes,
IssuedAt: now,
DueAt: dueAt,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// TotalCents returns the total of all line items in cents.
func (inv *Invoice) TotalCents() int64 {
var total int64
for _, item := range inv.Items {
total += item.TotalCents()
}
return total
}
// SubtotalCents returns the subtotal before tax.
func (inv *Invoice) SubtotalCents() int64 {
var subtotal int64
for _, item := range inv.Items {
subtotal += int64(item.Quantity) * item.UnitPrice
}
return subtotal
}
// TaxCents returns the total tax amount.
func (inv *Invoice) TaxCents() int64 {
return inv.TotalCents() - inv.SubtotalCents()
}
// IsOverdue returns true if the invoice is past due and not paid.
func (inv *Invoice) IsOverdue() bool {
return inv.Status != StatusPaid && time.Now().After(inv.DueAt)
}
Notice two critical design decisions. First, money is stored in cents as int64, never as float64. Floating-point arithmetic causes rounding errors in financial calculations. An invoice for $100.10 stored as float could become $100.09999999. In cents, it is always 10010. Second, tax rates use basis points (hundredths of a percent). A 16% tax rate is stored as 1600. This gives precision without floating-point math.
Status Transitions
An invoice cannot go from “paid” back to “draft.” Status transitions are business rules, not database constraints.
// internal/domain/invoice_status.go
package domain
// InvoiceStatus represents the lifecycle state of an invoice.
type InvoiceStatus string
const (
StatusDraft InvoiceStatus = "draft"
StatusSent InvoiceStatus = "sent"
StatusViewed InvoiceStatus = "viewed"
StatusPaid InvoiceStatus = "paid"
StatusOverdue InvoiceStatus = "overdue"
StatusCancelled InvoiceStatus = "cancelled"
)
// validTransitions defines which status changes are allowed.
// The map key is the current status. The value is a set of allowed next states.
var validTransitions = map[InvoiceStatus]map[InvoiceStatus]bool{
StatusDraft: {StatusSent: true, StatusCancelled: true},
StatusSent: {StatusViewed: true, StatusPaid: true, StatusOverdue: true, StatusCancelled: true},
StatusViewed: {StatusPaid: true, StatusOverdue: true, StatusCancelled: true},
StatusOverdue: {StatusPaid: true, StatusCancelled: true},
}
// TransitionTo changes the invoice status if the transition is valid.
func (inv *Invoice) TransitionTo(next InvoiceStatus) error {
allowed, exists := validTransitions[inv.Status]
if !exists {
return ErrStatusTransitionForbidden
}
if !allowed[next] {
return ErrStatusTransitionForbidden
}
inv.Status = next
inv.UpdatedAt = time.Now()
if next == StatusPaid {
now := time.Now()
inv.PaidAt = &now
}
return nil
}
This is the kind of logic that belongs in the domain, not in an HTTP handler or a database trigger. When a junior developer tries to mark a cancelled invoice as paid, the domain rejects it before any database query runs.
Ports — The Interfaces the Domain Needs
The domain defines what it needs from the outside world through interfaces. It does not know or care who implements them.
// internal/domain/invoice_repository.go
package domain
import (
"context"
"github.com/google/uuid"
)
// InvoiceRepository defines the contract for invoice persistence.
// The domain defines the interface. Infrastructure implements it.
type InvoiceRepository interface {
Save(ctx context.Context, invoice *Invoice) error
FindByID(ctx context.Context, id uuid.UUID) (*Invoice, error)
FindByNumber(ctx context.Context, number string) (*Invoice, error)
FindAll(ctx context.Context, filter InvoiceFilter) ([]*Invoice, int, error)
Update(ctx context.Context, invoice *Invoice) error
Delete(ctx context.Context, id uuid.UUID) error
}
// InvoiceFilter represents query parameters for listing invoices.
type InvoiceFilter struct {
Status *InvoiceStatus
ClientName *string
FromDate *time.Time
ToDate *time.Time
Page int
PageSize int
}
// internal/domain/invoice_cache.go
package domain
import (
"context"
"time"
"github.com/google/uuid"
)
// InvoiceCache defines the contract for invoice caching.
type InvoiceCache interface {
Get(ctx context.Context, id uuid.UUID) (*Invoice, error)
Set(ctx context.Context, invoice *Invoice, ttl time.Duration) error
Delete(ctx context.Context, id uuid.UUID) error
DeleteByPattern(ctx context.Context, pattern string) error
}
// internal/domain/errors.go
package domain
import "errors"
var (
ErrInvoiceNumberRequired = errors.New("invoice number is required")
ErrClientNameRequired = errors.New("client name is required")
ErrClientEmailRequired = errors.New("client email is required")
ErrItemsRequired = errors.New("at least one line item is required")
ErrDueDateInPast = errors.New("due date cannot be in the past")
ErrInvalidQuantity = errors.New("quantity must be positive")
ErrInvalidPrice = errors.New("unit price must be positive")
ErrInvoiceNotFound = errors.New("invoice not found")
ErrInvoiceAlreadyExists = errors.New("invoice with this number already exists")
ErrStatusTransitionForbidden = errors.New("this status transition is not allowed")
)
The domain layer is now complete. It has:
- An entity with validation and business methods
- A state machine for status transitions
- Interfaces (ports) for persistence and caching
- Typed errors for every failure case
Zero external dependencies. Zero framework code. Pure business logic.
Part 4: The Application Layer — Use Cases
The application layer orchestrates the domain. Each file represents one use case. Each use case does one thing.
Create Invoice
// internal/application/create_invoice.go
package application
import (
"context"
"fmt"
"log/slog"
"github.com/yourorg/invoice-api/internal/domain"
)
// CreateInvoiceCommand represents the input for creating an invoice.
type CreateInvoiceCommand struct {
Number string
ClientName string
ClientEmail string
Currency string
Notes string
Items []LineItemDTO
DueAt string // ISO 8601 date
}
// CreateInvoiceHandler handles the create invoice use case.
type CreateInvoiceHandler struct {
repo domain.InvoiceRepository
cache domain.InvoiceCache
logger *slog.Logger
}
// NewCreateInvoiceHandler constructs the handler with its dependencies.
func NewCreateInvoiceHandler(
repo domain.InvoiceRepository,
cache domain.InvoiceCache,
logger *slog.Logger,
) *CreateInvoiceHandler {
return &CreateInvoiceHandler{
repo: repo,
cache: cache,
logger: logger,
}
}
// Handle executes the create invoice use case.
func (h *CreateInvoiceHandler) Handle(ctx context.Context, cmd CreateInvoiceCommand) (*domain.Invoice, error) {
// Check for duplicate invoice number
existing, _ := h.repo.FindByNumber(ctx, cmd.Number)
if existing != nil {
return nil, domain.ErrInvoiceAlreadyExists
}
// Parse the due date
dueAt, err := parseDate(cmd.DueAt)
if err != nil {
return nil, fmt.Errorf("invalid due date: %w", err)
}
// Convert DTOs to domain line items
items := make([]domain.LineItem, len(cmd.Items))
for i, dto := range cmd.Items {
items[i] = domain.LineItem{
Description: dto.Description,
Quantity: dto.Quantity,
UnitPrice: dto.UnitPriceCents,
TaxRate: dto.TaxRateBasisPoints,
}
}
// Create the invoice through the domain (validation happens here)
invoice, err := domain.NewInvoice(
cmd.Number, cmd.ClientName, cmd.ClientEmail,
cmd.Currency, cmd.Notes, items, dueAt,
)
if err != nil {
return nil, err
}
// Persist
if err := h.repo.Save(ctx, invoice); err != nil {
return nil, fmt.Errorf("saving invoice: %w", err)
}
// Invalidate list cache
_ = h.cache.DeleteByPattern(ctx, "invoices:list:*")
h.logger.Info("invoice created",
slog.String("id", invoice.ID.String()),
slog.String("number", invoice.Number),
slog.Int64("total_cents", invoice.TotalCents()),
)
return invoice, nil
}
Get Invoice (with Cache)
The get use case shows the cache-aside pattern: check cache first, fall through to database, populate cache on miss.
// internal/application/get_invoice.go
package application
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/yourorg/invoice-api/internal/domain"
)
type GetInvoiceHandler struct {
repo domain.InvoiceRepository
cache domain.InvoiceCache
logger *slog.Logger
}
func NewGetInvoiceHandler(
repo domain.InvoiceRepository,
cache domain.InvoiceCache,
logger *slog.Logger,
) *GetInvoiceHandler {
return &GetInvoiceHandler{repo: repo, cache: cache, logger: logger}
}
func (h *GetInvoiceHandler) Handle(ctx context.Context, id uuid.UUID) (*domain.Invoice, error) {
// Check cache first
cached, err := h.cache.Get(ctx, id)
if err == nil && cached != nil {
h.logger.Debug("cache hit", slog.String("id", id.String()))
return cached, nil
}
// Cache miss — fetch from database
invoice, err := h.repo.FindByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("finding invoice: %w", err)
}
// Populate cache for next request (5 minute TTL)
_ = h.cache.Set(ctx, invoice, 5*time.Minute)
h.logger.Debug("cache miss, fetched from db", slog.String("id", id.String()))
return invoice, nil
}
List Invoices with Pagination
// internal/application/list_invoices.go
package application
import (
"context"
"log/slog"
"github.com/yourorg/invoice-api/internal/domain"
)
type ListInvoicesQuery struct {
Status *domain.InvoiceStatus
ClientName *string
FromDate *string
ToDate *string
Page int
PageSize int
}
type ListInvoicesResult struct {
Invoices []*domain.Invoice
Total int
Page int
PageSize int
}
type ListInvoicesHandler struct {
repo domain.InvoiceRepository
logger *slog.Logger
}
func NewListInvoicesHandler(
repo domain.InvoiceRepository,
logger *slog.Logger,
) *ListInvoicesHandler {
return &ListInvoicesHandler{repo: repo, logger: logger}
}
func (h *ListInvoicesHandler) Handle(ctx context.Context, query ListInvoicesQuery) (*ListInvoicesResult, error) {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 || query.PageSize > 100 {
query.PageSize = 20
}
filter := domain.InvoiceFilter{
Status: query.Status,
Page: query.Page,
PageSize: query.PageSize,
}
if query.ClientName != nil {
filter.ClientName = query.ClientName
}
if query.FromDate != nil {
from, err := parseDate(*query.FromDate)
if err == nil {
filter.FromDate = &from
}
}
if query.ToDate != nil {
to, err := parseDate(*query.ToDate)
if err == nil {
filter.ToDate = &to
}
}
invoices, total, err := h.repo.FindAll(ctx, filter)
if err != nil {
return nil, err
}
return &ListInvoicesResult{
Invoices: invoices,
Total: total,
Page: query.Page,
PageSize: query.PageSize,
}, nil
}
Update Status
// internal/application/update_status.go
package application
import (
"context"
"fmt"
"log/slog"
"github.com/google/uuid"
"github.com/yourorg/invoice-api/internal/domain"
)
type UpdateStatusCommand struct {
InvoiceID uuid.UUID
NewStatus domain.InvoiceStatus
}
type UpdateStatusHandler struct {
repo domain.InvoiceRepository
cache domain.InvoiceCache
logger *slog.Logger
}
func NewUpdateStatusHandler(
repo domain.InvoiceRepository,
cache domain.InvoiceCache,
logger *slog.Logger,
) *UpdateStatusHandler {
return &UpdateStatusHandler{repo: repo, cache: cache, logger: logger}
}
func (h *UpdateStatusHandler) Handle(ctx context.Context, cmd UpdateStatusCommand) (*domain.Invoice, error) {
invoice, err := h.repo.FindByID(ctx, cmd.InvoiceID)
if err != nil {
return nil, fmt.Errorf("finding invoice: %w", err)
}
// Domain enforces valid transitions
if err := invoice.TransitionTo(cmd.NewStatus); err != nil {
return nil, err
}
if err := h.repo.Update(ctx, invoice); err != nil {
return nil, fmt.Errorf("updating invoice: %w", err)
}
// Invalidate both the specific cache entry and list caches
_ = h.cache.Delete(ctx, cmd.InvoiceID)
_ = h.cache.DeleteByPattern(ctx, "invoices:list:*")
h.logger.Info("invoice status updated",
slog.String("id", cmd.InvoiceID.String()),
slog.String("new_status", string(cmd.NewStatus)),
)
return invoice, nil
}
Shared DTOs and Helpers
// internal/application/dto.go
package application
import "time"
// LineItemDTO is the external representation of a line item.
type LineItemDTO struct {
Description string `json:"description" validate:"required"`
Quantity int `json:"quantity" validate:"required,gt=0"`
UnitPriceCents int64 `json:"unit_price_cents" validate:"required,gt=0"`
TaxRateBasisPoints int `json:"tax_rate_basis_points" validate:"gte=0,lte=5000"`
}
func parseDate(s string) (time.Time, error) {
return time.Parse("2006-01-02", s)
}
Part 5: Infrastructure — PostgreSQL
Now we implement the repository interface using pgx and PostgreSQL.
Connection Pool
// internal/infrastructure/postgres/connection.go
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// NewPool creates a production-ready PostgreSQL connection pool.
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parsing dsn: %w", err)
}
// Production pool settings
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = 1 * time.Hour
config.MaxConnIdleTime = 30 * time.Minute
config.HealthCheckPeriod = 1 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("creating pool: %w", err)
}
// Verify connectivity
if err := pool.Ping(ctx); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
return pool, nil
}
Why these pool settings: MaxConns of 25 works for most applications behind a load balancer. MinConns of 5 keeps warm connections ready. MaxConnLifetime of 1 hour prevents connections from going stale. HealthCheckPeriod of 1 minute detects dead connections before your application tries to use them.
Database Migrations
-- migrations/001_create_invoices.up.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
number VARCHAR(50) NOT NULL UNIQUE,
client_name VARCHAR(255) NOT NULL,
client_email VARCHAR(255) NOT NULL,
items JSONB NOT NULL DEFAULT '[]',
status VARCHAR(20) NOT NULL DEFAULT 'draft',
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
notes TEXT NOT NULL DEFAULT '',
subtotal_cents BIGINT NOT NULL DEFAULT 0,
tax_cents BIGINT NOT NULL DEFAULT 0,
total_cents BIGINT NOT NULL DEFAULT 0,
issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
due_at TIMESTAMP WITH TIME ZONE NOT NULL,
paid_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Indexes for common query patterns
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_client_name ON invoices(client_name);
CREATE INDEX idx_invoices_due_at ON invoices(due_at);
CREATE INDEX idx_invoices_created_at ON invoices(created_at DESC);
CREATE INDEX idx_invoices_number ON invoices(number);
-- Composite index for list queries with status + date filtering
CREATE INDEX idx_invoices_status_created ON invoices(status, created_at DESC);
-- migrations/001_create_invoices.down.sql
DROP TABLE IF EXISTS invoices;
Notice that line items are stored as JSONB. This is a deliberate choice. Line items belong to the invoice — they are value objects, not independent entities. Storing them as JSONB avoids a JOIN for every invoice query, simplifies the schema, and keeps the read path fast. PostgreSQL’s JSONB is indexed and queryable, so you lose nothing.
PostgreSQL Repository Implementation
// internal/infrastructure/postgres/invoice_repo.go
package postgres
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/yourorg/invoice-api/internal/domain"
)
type InvoiceRepository struct {
pool *pgxpool.Pool
}
func NewInvoiceRepository(pool *pgxpool.Pool) *InvoiceRepository {
return &InvoiceRepository{pool: pool}
}
func (r *InvoiceRepository) Save(ctx context.Context, inv *domain.Invoice) error {
itemsJSON, err := json.Marshal(inv.Items)
if err != nil {
return fmt.Errorf("marshaling items: %w", err)
}
const query = `
INSERT INTO invoices (
id, number, client_name, client_email, items, status,
currency, notes, subtotal_cents, tax_cents, total_cents,
issued_at, due_at, paid_at, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13, $14, $15, $16
)`
_, err = r.pool.Exec(ctx, query,
inv.ID, inv.Number, inv.ClientName, inv.ClientEmail,
itemsJSON, string(inv.Status),
inv.Currency, inv.Notes,
inv.SubtotalCents(), inv.TaxCents(), inv.TotalCents(),
inv.IssuedAt, inv.DueAt, inv.PaidAt,
inv.CreatedAt, inv.UpdatedAt,
)
return err
}
func (r *InvoiceRepository) FindByID(ctx context.Context, id uuid.UUID) (*domain.Invoice, error) {
const query = `
SELECT id, number, client_name, client_email, items, status,
currency, notes, issued_at, due_at, paid_at, created_at, updated_at
FROM invoices
WHERE id = $1`
return r.scanInvoice(r.pool.QueryRow(ctx, query, id))
}
func (r *InvoiceRepository) FindByNumber(ctx context.Context, number string) (*domain.Invoice, error) {
const query = `
SELECT id, number, client_name, client_email, items, status,
currency, notes, issued_at, due_at, paid_at, created_at, updated_at
FROM invoices
WHERE number = $1`
return r.scanInvoice(r.pool.QueryRow(ctx, query, number))
}
func (r *InvoiceRepository) FindAll(ctx context.Context, filter domain.InvoiceFilter) ([]*domain.Invoice, int, error) {
// Build dynamic query
where := "WHERE 1=1"
args := []any{}
argIdx := 1
if filter.Status != nil {
where += fmt.Sprintf(" AND status = $%d", argIdx)
args = append(args, string(*filter.Status))
argIdx++
}
if filter.ClientName != nil {
where += fmt.Sprintf(" AND client_name ILIKE $%d", argIdx)
args = append(args, "%"+*filter.ClientName+"%")
argIdx++
}
if filter.FromDate != nil {
where += fmt.Sprintf(" AND created_at >= $%d", argIdx)
args = append(args, *filter.FromDate)
argIdx++
}
if filter.ToDate != nil {
where += fmt.Sprintf(" AND created_at <= $%d", argIdx)
args = append(args, *filter.ToDate)
argIdx++
}
// Count total matching rows
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM invoices %s", where)
var total int
if err := r.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
// Fetch paginated results
offset := (filter.Page - 1) * filter.PageSize
dataQuery := fmt.Sprintf(`
SELECT id, number, client_name, client_email, items, status,
currency, notes, issued_at, due_at, paid_at, created_at, updated_at
FROM invoices %s
ORDER BY created_at DESC
LIMIT $%d OFFSET $%d`, where, argIdx, argIdx+1)
args = append(args, filter.PageSize, offset)
rows, err := r.pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var invoices []*domain.Invoice
for rows.Next() {
inv, err := r.scanInvoiceFromRows(rows)
if err != nil {
return nil, 0, err
}
invoices = append(invoices, inv)
}
return invoices, total, rows.Err()
}
func (r *InvoiceRepository) Update(ctx context.Context, inv *domain.Invoice) error {
itemsJSON, err := json.Marshal(inv.Items)
if err != nil {
return fmt.Errorf("marshaling items: %w", err)
}
const query = `
UPDATE invoices SET
client_name = $2, client_email = $3, items = $4, status = $5,
currency = $6, notes = $7,
subtotal_cents = $8, tax_cents = $9, total_cents = $10,
due_at = $11, paid_at = $12, updated_at = $13
WHERE id = $1`
_, err = r.pool.Exec(ctx, query,
inv.ID, inv.ClientName, inv.ClientEmail,
itemsJSON, string(inv.Status),
inv.Currency, inv.Notes,
inv.SubtotalCents(), inv.TaxCents(), inv.TotalCents(),
inv.DueAt, inv.PaidAt, inv.UpdatedAt,
)
return err
}
func (r *InvoiceRepository) Delete(ctx context.Context, id uuid.UUID) error {
_, err := r.pool.Exec(ctx, "DELETE FROM invoices WHERE id = $1", id)
return err
}
// scanInvoice maps a database row to a domain Invoice.
func (r *InvoiceRepository) scanInvoice(row pgx.Row) (*domain.Invoice, error) {
var inv domain.Invoice
var itemsJSON []byte
var status string
err := row.Scan(
&inv.ID, &inv.Number, &inv.ClientName, &inv.ClientEmail,
&itemsJSON, &status,
&inv.Currency, &inv.Notes,
&inv.IssuedAt, &inv.DueAt, &inv.PaidAt,
&inv.CreatedAt, &inv.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, domain.ErrInvoiceNotFound
}
if err != nil {
return nil, err
}
inv.Status = domain.InvoiceStatus(status)
if err := json.Unmarshal(itemsJSON, &inv.Items); err != nil {
return nil, fmt.Errorf("unmarshaling items: %w", err)
}
return &inv, nil
}
func (r *InvoiceRepository) scanInvoiceFromRows(rows pgx.Rows) (*domain.Invoice, error) {
var inv domain.Invoice
var itemsJSON []byte
var status string
err := rows.Scan(
&inv.ID, &inv.Number, &inv.ClientName, &inv.ClientEmail,
&itemsJSON, &status,
&inv.Currency, &inv.Notes,
&inv.IssuedAt, &inv.DueAt, &inv.PaidAt,
&inv.CreatedAt, &inv.UpdatedAt,
)
if err != nil {
return nil, err
}
inv.Status = domain.InvoiceStatus(status)
if err := json.Unmarshal(itemsJSON, &inv.Items); err != nil {
return nil, fmt.Errorf("unmarshaling items: %w", err)
}
return &inv, nil
}
Part 6: Infrastructure — Redis
Redis serves two purposes in this project: caching individual invoices to reduce database load, and pattern-based cache invalidation when data changes.
Redis Connection
// internal/infrastructure/redis/connection.go
package redis
import (
"context"
"fmt"
goredis "github.com/redis/go-redis/v9"
)
// NewClient creates a production-ready Redis client.
func NewClient(ctx context.Context, addr, password string, db int) (*goredis.Client, error) {
client := goredis.NewClient(&goredis.Options{
Addr: addr,
Password: password,
DB: db,
PoolSize: 20,
MinIdleConns: 5,
MaxRetries: 3,
})
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("pinging redis: %w", err)
}
return client, nil
}
Redis Cache Implementation
// internal/infrastructure/redis/invoice_cache.go
package redis
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
goredis "github.com/redis/go-redis/v9"
"github.com/yourorg/invoice-api/internal/domain"
)
type InvoiceCache struct {
client *goredis.Client
}
func NewInvoiceCache(client *goredis.Client) *InvoiceCache {
return &InvoiceCache{client: client}
}
func (c *InvoiceCache) Get(ctx context.Context, id uuid.UUID) (*domain.Invoice, error) {
key := fmt.Sprintf("invoice:%s", id.String())
data, err := c.client.Get(ctx, key).Bytes()
if err == goredis.Nil {
return nil, nil // Cache miss, not an error
}
if err != nil {
return nil, err
}
var invoice domain.Invoice
if err := json.Unmarshal(data, &invoice); err != nil {
return nil, err
}
return &invoice, nil
}
func (c *InvoiceCache) Set(ctx context.Context, invoice *domain.Invoice, ttl time.Duration) error {
key := fmt.Sprintf("invoice:%s", invoice.ID.String())
data, err := json.Marshal(invoice)
if err != nil {
return err
}
return c.client.Set(ctx, key, data, ttl).Err()
}
func (c *InvoiceCache) Delete(ctx context.Context, id uuid.UUID) error {
key := fmt.Sprintf("invoice:%s", id.String())
return c.client.Del(ctx, key).Err()
}
func (c *InvoiceCache) DeleteByPattern(ctx context.Context, pattern string) error {
iter := c.client.Scan(ctx, 0, pattern, 100).Iterator()
var keys []string
for iter.Next(ctx) {
keys = append(keys, iter.Val())
}
if err := iter.Err(); err != nil {
return err
}
if len(keys) > 0 {
return c.client.Del(ctx, keys...).Err()
}
return nil
}
Part 7: The HTTP Layer — Chi Router and Handlers
Router Setup
// internal/interfaces/http/router.go
package http
import (
"log/slog"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
)
// NewRouter creates a Chi router with production middleware.
func NewRouter(handler *InvoiceHandler, logger *slog.Logger) *chi.Mux {
r := chi.NewRouter()
// Middleware stack — order matters
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(NewStructuredLogger(logger))
r.Use(chimw.Recoverer)
r.Use(chimw.Timeout(30 * time.Second))
r.Use(chimw.Compress(5))
// Health check — no auth required
r.Get("/health", handler.HealthCheck)
// API v1 routes
r.Route("/api/v1", func(r chi.Router) {
r.Route("/invoices", func(r chi.Router) {
r.Post("/", handler.CreateInvoice)
r.Get("/", handler.ListInvoices)
r.Route("/{invoiceID}", func(r chi.Router) {
r.Get("/", handler.GetInvoice)
r.Patch("/status", handler.UpdateStatus)
r.Delete("/", handler.DeleteInvoice)
})
})
})
return r
}
HTTP Handlers
// internal/interfaces/http/invoice_handler.go
package http
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/yourorg/invoice-api/internal/application"
"github.com/yourorg/invoice-api/internal/domain"
)
type InvoiceHandler struct {
createHandler *application.CreateInvoiceHandler
getHandler *application.GetInvoiceHandler
listHandler *application.ListInvoicesHandler
statusHandler *application.UpdateStatusHandler
repo domain.InvoiceRepository
validate *validator.Validate
}
func NewInvoiceHandler(
createHandler *application.CreateInvoiceHandler,
getHandler *application.GetInvoiceHandler,
listHandler *application.ListInvoicesHandler,
statusHandler *application.UpdateStatusHandler,
repo domain.InvoiceRepository,
) *InvoiceHandler {
return &InvoiceHandler{
createHandler: createHandler,
getHandler: getHandler,
listHandler: listHandler,
statusHandler: statusHandler,
repo: repo,
validate: validator.New(),
}
}
func (h *InvoiceHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "healthy"})
}
func (h *InvoiceHandler) CreateInvoice(w http.ResponseWriter, r *http.Request) {
var cmd application.CreateInvoiceCommand
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
invoice, err := h.createHandler.Handle(r.Context(), cmd)
if err != nil {
handleDomainError(w, err)
return
}
writeJSON(w, http.StatusCreated, toInvoiceResponse(invoice))
}
func (h *InvoiceHandler) GetInvoice(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "invoiceID"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid invoice ID")
return
}
invoice, err := h.getHandler.Handle(r.Context(), id)
if err != nil {
handleDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, toInvoiceResponse(invoice))
}
func (h *InvoiceHandler) ListInvoices(w http.ResponseWriter, r *http.Request) {
query := application.ListInvoicesQuery{
Page: intFromQuery(r, "page", 1),
PageSize: intFromQuery(r, "page_size", 20),
}
if s := r.URL.Query().Get("status"); s != "" {
status := domain.InvoiceStatus(s)
query.Status = &status
}
if c := r.URL.Query().Get("client"); c != "" {
query.ClientName = &c
}
result, err := h.listHandler.Handle(r.Context(), query)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list invoices")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"invoices": toInvoiceResponses(result.Invoices),
"total": result.Total,
"page": result.Page,
"page_size": result.PageSize,
})
}
func (h *InvoiceHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "invoiceID"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid invoice ID")
return
}
var body struct {
Status string `json:"status" validate:"required"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
cmd := application.UpdateStatusCommand{
InvoiceID: id,
NewStatus: domain.InvoiceStatus(body.Status),
}
invoice, err := h.statusHandler.Handle(r.Context(), cmd)
if err != nil {
handleDomainError(w, err)
return
}
writeJSON(w, http.StatusOK, toInvoiceResponse(invoice))
}
func (h *InvoiceHandler) DeleteInvoice(w http.ResponseWriter, r *http.Request) {
id, err := uuid.Parse(chi.URLParam(r, "invoiceID"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid invoice ID")
return
}
if err := h.repo.Delete(r.Context(), id); err != nil {
handleDomainError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
Standardized Responses
// internal/interfaces/http/response.go
package http
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/yourorg/invoice-api/internal/domain"
)
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func handleDomainError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrInvoiceNotFound):
writeError(w, http.StatusNotFound, err.Error())
case errors.Is(err, domain.ErrInvoiceAlreadyExists):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, domain.ErrStatusTransitionForbidden):
writeError(w, http.StatusUnprocessableEntity, err.Error())
case errors.Is(err, domain.ErrInvoiceNumberRequired),
errors.Is(err, domain.ErrClientNameRequired),
errors.Is(err, domain.ErrClientEmailRequired),
errors.Is(err, domain.ErrItemsRequired),
errors.Is(err, domain.ErrDueDateInPast),
errors.Is(err, domain.ErrInvalidQuantity),
errors.Is(err, domain.ErrInvalidPrice):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, "internal server error")
}
}
type invoiceResponse struct {
ID string `json:"id"`
Number string `json:"number"`
ClientName string `json:"client_name"`
ClientEmail string `json:"client_email"`
Status string `json:"status"`
Currency string `json:"currency"`
Notes string `json:"notes"`
Subtotal string `json:"subtotal"`
Tax string `json:"tax"`
Total string `json:"total"`
IssuedAt string `json:"issued_at"`
DueAt string `json:"due_at"`
PaidAt string `json:"paid_at,omitempty"`
IsOverdue bool `json:"is_overdue"`
Items []any `json:"items"`
}
func toInvoiceResponse(inv *domain.Invoice) invoiceResponse {
resp := invoiceResponse{
ID: inv.ID.String(),
Number: inv.Number,
ClientName: inv.ClientName,
ClientEmail: inv.ClientEmail,
Status: string(inv.Status),
Currency: inv.Currency,
Notes: inv.Notes,
Subtotal: formatCents(inv.SubtotalCents()),
Tax: formatCents(inv.TaxCents()),
Total: formatCents(inv.TotalCents()),
IssuedAt: inv.IssuedAt.Format("2006-01-02T15:04:05Z"),
DueAt: inv.DueAt.Format("2006-01-02"),
IsOverdue: inv.IsOverdue(),
}
if inv.PaidAt != nil {
resp.PaidAt = inv.PaidAt.Format("2006-01-02T15:04:05Z")
}
items := make([]any, len(inv.Items))
for i, item := range inv.Items {
items[i] = map[string]any{
"description": item.Description,
"quantity": item.Quantity,
"unit_price": formatCents(item.UnitPrice),
"tax_rate": float64(item.TaxRate) / 100.0,
"total": formatCents(item.TotalCents()),
}
}
resp.Items = items
return resp
}
func toInvoiceResponses(invoices []*domain.Invoice) []invoiceResponse {
resp := make([]invoiceResponse, len(invoices))
for i, inv := range invoices {
resp[i] = toInvoiceResponse(inv)
}
return resp
}
func formatCents(cents int64) string {
whole := cents / 100
fraction := cents % 100
if fraction < 0 {
fraction = -fraction
}
return strconv.FormatInt(whole, 10) + "." + strconv.FormatInt(fraction, 10)
}
func intFromQuery(r *http.Request, key string, fallback int) int {
s := r.URL.Query().Get(key)
if s == "" {
return fallback
}
v, err := strconv.Atoi(s)
if err != nil {
return fallback
}
return v
}
Part 8: Configuration
// internal/infrastructure/config/config.go
package config
import (
"fmt"
"github.com/caarlos0/env/v11"
)
// Config holds all application configuration from environment variables.
type Config struct {
// Server
ServerPort int `env:"SERVER_PORT" envDefault:"8080"`
ServerHost string `env:"SERVER_HOST" envDefault:"0.0.0.0"`
// PostgreSQL
DatabaseURL string `env:"DATABASE_URL,required"`
// Redis
RedisAddr string `env:"REDIS_ADDR" envDefault:"localhost:6379"`
RedisPassword string `env:"REDIS_PASSWORD" envDefault:""`
RedisDB int `env:"REDIS_DB" envDefault:"0"`
// Logging
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
}
// Load reads configuration from environment variables.
func Load() (*Config, error) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return cfg, nil
}
# .env.example
SERVER_PORT=8080
SERVER_HOST=0.0.0.0
DATABASE_URL=postgres://user:password@localhost:5432/invoices?sslmode=disable
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=
REDIS_DB=0
LOG_LEVEL=debug
Part 9: The Entry Point — Wiring It All Together
// cmd/api/main.go
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/yourorg/invoice-api/internal/application"
"github.com/yourorg/invoice-api/internal/infrastructure/config"
"github.com/yourorg/invoice-api/internal/infrastructure/postgres"
redisclient "github.com/yourorg/invoice-api/internal/infrastructure/redis"
httpinterface "github.com/yourorg/invoice-api/internal/interfaces/http"
)
func main() {
ctx := context.Background()
// 1. Load configuration
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", slog.String("error", err.Error()))
os.Exit(1)
}
// 2. Set up structured logging
logLevel := slog.LevelInfo
switch cfg.LogLevel {
case "debug":
logLevel = slog.LevelDebug
case "warn":
logLevel = slog.LevelWarn
case "error":
logLevel = slog.LevelError
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.Options{Level: logLevel}))
// 3. Connect to PostgreSQL
pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
if err != nil {
logger.Error("failed to connect to database", slog.String("error", err.Error()))
os.Exit(1)
}
defer pool.Close()
logger.Info("connected to PostgreSQL")
// 4. Connect to Redis
redisClient, err := redisclient.NewClient(ctx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
if err != nil {
logger.Error("failed to connect to redis", slog.String("error", err.Error()))
os.Exit(1)
}
defer redisClient.Close()
logger.Info("connected to Redis")
// 5. Create infrastructure implementations
invoiceRepo := postgres.NewInvoiceRepository(pool)
invoiceCache := redisclient.NewInvoiceCache(redisClient)
// 6. Create application handlers (use cases)
createHandler := application.NewCreateInvoiceHandler(invoiceRepo, invoiceCache, logger)
getHandler := application.NewGetInvoiceHandler(invoiceRepo, invoiceCache, logger)
listHandler := application.NewListInvoicesHandler(invoiceRepo, logger)
statusHandler := application.NewUpdateStatusHandler(invoiceRepo, invoiceCache, logger)
// 7. Create HTTP handler and router
httpHandler := httpinterface.NewInvoiceHandler(
createHandler, getHandler, listHandler, statusHandler, invoiceRepo,
)
router := httpinterface.NewRouter(httpHandler, logger)
// 8. Create HTTP server with graceful shutdown
addr := fmt.Sprintf("%s:%d", cfg.ServerHost, cfg.ServerPort)
server := &http.Server{
Addr: addr,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
// 9. Start server in a goroutine
go func() {
logger.Info("server starting", slog.String("addr", addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed", slog.String("error", err.Error()))
os.Exit(1)
}
}()
// 10. Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Info("shutting down server")
shutdownCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("forced shutdown", slog.String("error", err.Error()))
}
logger.Info("server stopped")
}
Read this file carefully. Every dependency flows inward. The domain has no knowledge of any external package. The application layer knows only the domain interfaces. The infrastructure implements those interfaces. The HTTP layer translates between HTTP and application commands. The entry point wires all the pieces together.
Part 10: Testing — TDD with Table-Driven Tests
Domain Unit Tests
// internal/domain/invoice_test.go
package domain
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewInvoice(t *testing.T) {
validItems := []LineItem{
{Description: "Consulting", Quantity: 10, UnitPrice: 15000, TaxRate: 1600},
}
tomorrow := time.Now().Add(24 * time.Hour)
tests := []struct {
name string
number string
clientName string
clientEmail string
items []LineItem
dueAt time.Time
wantErr error
}{
{
name: "valid invoice",
number: "INV-001",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: validItems,
dueAt: tomorrow,
wantErr: nil,
},
{
name: "missing number",
number: "",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: validItems,
dueAt: tomorrow,
wantErr: ErrInvoiceNumberRequired,
},
{
name: "missing client name",
number: "INV-001",
clientName: "",
clientEmail: "billing@acme.com",
items: validItems,
dueAt: tomorrow,
wantErr: ErrClientNameRequired,
},
{
name: "no items",
number: "INV-001",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: []LineItem{},
dueAt: tomorrow,
wantErr: ErrItemsRequired,
},
{
name: "due date in past",
number: "INV-001",
clientName: "Acme Corp",
clientEmail: "billing@acme.com",
items: validItems,
dueAt: time.Now().Add(-24 * time.Hour),
wantErr: ErrDueDateInPast,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inv, err := NewInvoice(
tt.number, tt.clientName, tt.clientEmail,
"USD", "", tt.items, tt.dueAt,
)
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
assert.Nil(t, inv)
} else {
require.NoError(t, err)
assert.Equal(t, tt.number, inv.Number)
assert.Equal(t, StatusDraft, inv.Status)
}
})
}
}
func TestLineItemTotalCents(t *testing.T) {
tests := []struct {
name string
item LineItem
expected int64
}{
{
name: "10 hours at $150/hr with 16% tax",
item: LineItem{Quantity: 10, UnitPrice: 15000, TaxRate: 1600},
expected: 174000, // 150000 + 24000 tax
},
{
name: "1 item at $100 with no tax",
item: LineItem{Quantity: 1, UnitPrice: 10000, TaxRate: 0},
expected: 10000,
},
{
name: "5 items at $20 with 8% tax",
item: LineItem{Quantity: 5, UnitPrice: 2000, TaxRate: 800},
expected: 10800, // 10000 + 800 tax
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.item.TotalCents())
})
}
}
func TestStatusTransitions(t *testing.T) {
tests := []struct {
name string
from InvoiceStatus
to InvoiceStatus
wantErr bool
}{
{"draft to sent", StatusDraft, StatusSent, false},
{"draft to cancelled", StatusDraft, StatusCancelled, false},
{"draft to paid (invalid)", StatusDraft, StatusPaid, true},
{"sent to paid", StatusSent, StatusPaid, false},
{"sent to viewed", StatusSent, StatusViewed, false},
{"viewed to paid", StatusViewed, StatusPaid, false},
{"paid to draft (invalid)", StatusPaid, StatusDraft, true},
{"cancelled to sent (invalid)", StatusCancelled, StatusSent, true},
{"overdue to paid", StatusOverdue, StatusPaid, false},
{"overdue to cancelled", StatusOverdue, StatusCancelled, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
inv := &Invoice{Status: tt.from}
err := inv.TransitionTo(tt.to)
if tt.wantErr {
assert.ErrorIs(t, err, ErrStatusTransitionForbidden)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.to, inv.Status)
}
})
}
}
Table-driven tests are the standard in Go. Each test case is a row in a table. The test function iterates over them. Adding a new case is one line, not a new function.
Part 11: BDD with Gherkin and Godog
BDD tests describe behavior in natural language. They bridge the gap between business requirements and code.
Feature File
# test/bdd/features/invoice.feature
Feature: Invoice Management
As a business owner
I want to manage invoices
So that I can track payments from clients
Background:
Given the system is running
And the database is clean
Scenario: Create a valid invoice
When I create an invoice with:
| number | INV-001 |
| client | Acme Corp |
| email | billing@acme.com |
| currency | USD |
| due_date | 2026-12-31 |
And I add a line item:
| description | Consulting hours |
| quantity | 10 |
| unit_price | 15000 |
| tax_rate | 1600 |
Then the invoice should be created
And the status should be "draft"
And the total should be "1740.0"
Scenario: Transition invoice from draft to sent
Given an invoice "INV-002" exists with status "draft"
When I change the status to "sent"
Then the status should be "sent"
Scenario: Cannot transition from paid to draft
Given an invoice "INV-003" exists with status "paid"
When I change the status to "draft"
Then I should see an error "this status transition is not allowed"
Scenario: List invoices with pagination
Given 25 invoices exist
When I request page 1 with page size 10
Then I should receive 10 invoices
And the total count should be 25
Scenario: Invoice overdue detection
Given an invoice "INV-004" exists with due date in the past
And the status is "sent"
Then the invoice should be marked as overdue
Step Definitions
// test/bdd/invoice_steps_test.go
package bdd
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/cucumber/godog"
)
type invoiceTestContext struct {
server *httptest.Server
lastResponse *http.Response
lastBody map[string]any
invoiceID string
}
func (tc *invoiceTestContext) theSystemIsRunning() error {
// Server is already started in BeforeScenario
return nil
}
func (tc *invoiceTestContext) theDatabaseIsClean() error {
// Truncate tables via direct DB connection
return nil
}
func (tc *invoiceTestContext) iCreateAnInvoiceWith(table *godog.Table) error {
data := tableToMap(table)
body := fmt.Sprintf(`{
"number": "%s",
"client_name": "%s",
"client_email": "%s",
"currency": "%s",
"due_at": "%s",
"items": []
}`, data["number"], data["client"], data["email"], data["currency"], data["due_date"])
resp, err := http.Post(
tc.server.URL+"/api/v1/invoices",
"application/json",
strings.NewReader(body),
)
if err != nil {
return err
}
tc.lastResponse = resp
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
tc.lastBody = result
if id, ok := result["id"].(string); ok {
tc.invoiceID = id
}
return nil
}
func (tc *invoiceTestContext) theInvoiceShouldBeCreated() error {
if tc.lastResponse.StatusCode != http.StatusCreated {
return fmt.Errorf("expected 201, got %d", tc.lastResponse.StatusCode)
}
return nil
}
func (tc *invoiceTestContext) theStatusShouldBe(expected string) error {
status, ok := tc.lastBody["status"].(string)
if !ok || status != expected {
return fmt.Errorf("expected status %s, got %s", expected, status)
}
return nil
}
func InitializeScenario(ctx *godog.ScenarioContext) {
tc := &invoiceTestContext{}
ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) {
// Start test server and clean database
return ctx, nil
})
ctx.Step(`^the system is running$`, tc.theSystemIsRunning)
ctx.Step(`^the database is clean$`, tc.theDatabaseIsClean)
ctx.Step(`^I create an invoice with:$`, tc.iCreateAnInvoiceWith)
ctx.Step(`^the invoice should be created$`, tc.theInvoiceShouldBeCreated)
ctx.Step(`^the status should be "([^"]*)"$`, tc.theStatusShouldBe)
}
func TestFeatures(t *testing.T) {
suite := godog.TestSuite{
ScenarioInitializer: InitializeScenario,
Options: &godog.Options{
Format: "pretty",
Paths: []string{"features"},
TestingT: t,
},
}
if suite.Run() != 0 {
t.Fatal("BDD tests failed")
}
}
func tableToMap(table *godog.Table) map[string]string {
result := make(map[string]string)
for _, row := range table.Rows {
if len(row.Cells) >= 2 {
result[row.Cells[0].Value] = row.Cells[1].Value
}
}
return result
}
Part 12: Docker and Makefile
Docker Compose
# docker-compose.yml
services:
api:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://invoices:secret@db:5432/invoices?sslmode=disable
REDIS_ADDR: redis:6379
LOG_LEVEL: debug
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: invoices
POSTGRES_PASSWORD: secret
POSTGRES_DB: invoices
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U invoices"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
Dockerfile
# Dockerfile
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /api ./cmd/api
FROM alpine:3.21
RUN apk --no-cache add ca-certificates
COPY --from=builder /api /api
EXPOSE 8080
CMD ["/api"]
Makefile
# Makefile
.PHONY: dev test lint build migrate docker-up docker-down
dev:
go run ./cmd/api
build:
go build -o bin/api ./cmd/api
test:
go test ./internal/... -v -race -count=1
test-integration:
go test ./test/integration/... -v -tags=integration
test-bdd:
go test ./test/bdd/... -v
test-all: test test-integration test-bdd
lint:
golangci-lint run ./...
migrate-up:
migrate -path migrations -database "$$DATABASE_URL" up
migrate-down:
migrate -path migrations -database "$$DATABASE_URL" down 1
docker-up:
docker compose up -d
docker-down:
docker compose down
docker-logs:
docker compose logs -f api
The Architecture Visualized
graph TD
subgraph "External"
CLIENT[HTTP Client]
end
subgraph "Interfaces Layer"
ROUTER[Chi Router + Middleware]
HANDLER[Invoice Handler]
end
subgraph "Application Layer"
CREATE[Create Invoice]
GET[Get Invoice]
LIST[List Invoices]
STATUS[Update Status]
end
subgraph "Domain Layer"
ENTITY[Invoice Entity]
SERVICE[Domain Service]
REPO_PORT[Repository Interface]
CACHE_PORT[Cache Interface]
end
subgraph "Infrastructure Layer"
PG[PostgreSQL Repository]
REDIS[Redis Cache]
CONFIG[Config Loader]
end
CLIENT --> ROUTER
ROUTER --> HANDLER
HANDLER --> CREATE
HANDLER --> GET
HANDLER --> LIST
HANDLER --> STATUS
CREATE --> ENTITY
CREATE --> REPO_PORT
CREATE --> CACHE_PORT
GET --> REPO_PORT
GET --> CACHE_PORT
STATUS --> ENTITY
STATUS --> REPO_PORT
REPO_PORT -.-> PG
CACHE_PORT -.-> REDIS
Dependencies flow inward. The domain never imports from infrastructure. Infrastructure implements domain interfaces. The application layer coordinates between them. The HTTP layer translates between the outside world and application commands.
Why This Stack Works
The packages in this project were not chosen for popularity. They were chosen for production reliability.
Chi does not replace the standard library. It extends it. Your handlers are http.HandlerFunc. If you remove Chi tomorrow, your handler signatures do not change.
pgx gives you connection pooling, prepared statements, and PostgreSQL-native types without an ORM. You write SQL. You understand your queries. The driver does not hide complexity.
go-redis gives you pipelining, connection pooling, and Lua scripting. Redis is not just a cache in this project. It is a pattern-based invalidation system that keeps your API fast without serving stale data.
slog is the standard library’s structured logger. No external dependency. JSON output in production. Text output in development. Every log entry has structured fields that your monitoring system can index.
caarlos0/env parses environment variables into a typed struct. No YAML files. No JSON configuration. Environment variables work in Docker, Kubernetes, CI, and local development without changes.
golang-migrate uses raw SQL files. Your DBA can review migrations. No generated code. No ORM magic. Just SQL.
The best production stack is the one where you understand every line. Frameworks hide complexity. This project shows it. When something breaks at 3 AM, you want to read the code and know exactly what it does. That is what this architecture gives you.
Tags
Related Articles
Organizational Health Through Architecture: Building Alignment, Trust & Healthy Culture
Learn how architecture decisions shape organizational culture, health, and alignment. Discover how to use architecture as a tool for building trust, preventing silos, enabling transparency, and creating sustainable organizational growth.
Team Health & Burnout Prevention: How Architecture Decisions Impact Human Well-being
Master the human side of architecture. Learn to recognize burnout signals, architect sustainable systems, build psychological safety, and protect team health. Because healthy teams build better systems.
Difficult Conversations & Conflict Resolution: Navigating Disagreement, Politics & Defensive Teams
Master the art of having difficult conversations as an architect. Learn how to manage technical disagreements, handle defensive teams, say no effectively, and navigate organizational politics without damaging relationships.