Go 1.25 Production API: DDD, TDD, BDD, PostgreSQL, Redis — Complete Project Setup

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.

By Omar Flores

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/http without replacing it. Your handlers are standard http.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 than if 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

#go #golang #rest-api #chi #ddd #domain-driven-design #tdd #bdd #testing #postgresql #redis #clean-code #architecture #backend #best-practices #performance