Build a Double-Entry Ledger API in Go

Build a fintech ledger API in Go with ACID transfers and audit trails.

Introduction

30 Second Summary

Every time you tap your card, send money to a friend, or get paid, software somewhere is making sure that money doesn't appear out of thin air or vanish into nothing. The system keeping score is called a ledger.

In this project, you will build a double-entry bookkeeping API in Go that processes fund transfers with atomic journal entries, idempotent retries, and deadlock-safe row locking.

What You'll Build

A REST API backed by PostgreSQL that implements double-entry bookkeeping for safe fund transfers, with balances derived entirely from an append-only journal.

By the end of this project, you'll have:

  • A Go REST API with 5 endpoints for creating accounts, transferring funds, and querying balances.
  • A PostgreSQL schema with ACID transactions, deterministic row locking, and an append-only audit trail.
  • A Dockerized development environment running Docker containers that can be torn down and recreated in seconds.
  • Secret Mission: Add JWT authentication to protect all endpoints.

Are there any prerequisites?

This project assumes you are comfortable with Go fundamentals (structs, interfaces, error handling, and modules) and have basic SQL knowledge. You do not need prior experience with Docker or fintech systems.

Set Up the Project and Database

Every financial API starts with two things: a clean project structure and a database that enforces the rules of money movement.

In this step, you'll scaffold a Go module, spin up PostgreSQL in a Docker container, and create the schema that enforces double-entry bookkeeping constraints at the database level.

In this step, get ready to:
  • Create a Go module with the database driver and UUID dependencies.
  • Start a PostgreSQL 16 container using Docker Compose.
  • Apply SQL migrations to create the ledger schema.
Create the project directory and install dependencies

Before writing any code, you need a Go module with the right dependencies. The pgx library is the de facto PostgreSQL driver for Go in fintech projects. It provides direct access to PostgreSQL-specific features and better performance than the generic database/sql interface.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ledger-api
cd ledger-api
  • Initialize the Go module by running:
go mod init ledger-api
  • Confirm the folder and module exist by running:
ls

You should see go.mod in the output.

  • Install the PostgreSQL driver and UUID library by running these commands:
go get github.com/jackc/pgx/v5
go get github.com/google/uuid

What do these dependencies do?

  • github.com/jackc/pgx/v5 is a PostgreSQL driver with connection pooling, prepared statements, and direct access to PostgreSQL features like LISTEN/NOTIFY.
  • github.com/google/uuid generates UUIDs for account IDs and transaction IDs.
  • Verify the installation succeeded by running:
go build ./...

This should exit silently with no errors. There is no Go source code to compile yet, so the build simply confirms your module and dependencies are configured correctly.

Seeing a module error?

  • Make sure you ran go mod init ledger-api before the go get commands. Without a go.mod file, Go does not know where to store dependencies.
  • Check that your terminal is inside the ledger-api folder. Run pwd to confirm.

help me fix a Go module or dependency error in my ledger-api project

Create the Docker Compose file and start PostgreSQL

Docker Compose lets you define your database as a configuration file. One command spins it up, and another tears it down. No manual PostgreSQL installation needed.

  • Create docker-compose.yml in your ledger-api folder with the following content:
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: ledger
      POSTGRES_PASSWORD: ledger
      POSTGRES_DB: ledger
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

What does this configuration do?

  • image: postgres:16 pulls the official PostgreSQL 16 image from Docker Hub.
  • The environment block creates a database called ledger with a user and password both set to ledger.
  • Port 5432:5432 maps the container's PostgreSQL port to your host machine so your Go API can connect.
  • The pgdata volume persists database data between container restarts.
  • Save docker-compose.yml.
  • Start PostgreSQL by running:
docker compose up -d
  • Verify the container is running by running:
docker compose ps

You should see a postgres container with status Up and port 5432 exposed.

Container not starting?

  • Make sure Docker Desktop is running. Check the Docker icon in your menu bar.
  • If port 5432 is already in use, another PostgreSQL instance may be running. Stop it first or change the host port in docker-compose.yml (e.g., 5433:5432).
  • Run docker compose logs postgres to see what went wrong.

help me troubleshoot my Docker Compose PostgreSQL container

✔️ Awesome, I've got everything!

Great. Make sure you have saved docker-compose.yml before continuing.

ⓧ I'd like to double check the full code

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: ledger
      POSTGRES_PASSWORD: ledger
      POSTGRES_DB: ledger
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
Create the SQL migration files and apply them

Your ledger needs four tables: one for accounts, one for transactions, one for journal entries, and one for idempotency keys. Each table gets its own migration file so the schema evolves in small, ordered steps.

  • Create the migrations directory by running:
mkdir migrations
  • Create migrations/001_create_accounts.sql with the following content:
CREATE TABLE accounts (
    id UUID PRIMARY KEY,
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
  • Create migrations/002_create_transactions.sql with the following content:
CREATE TABLE transactions (
    id UUID PRIMARY KEY,
    source_account_id UUID NOT NULL REFERENCES accounts(id),
    destination_account_id UUID NOT NULL REFERENCES accounts(id),
    amount BIGINT NOT NULL CHECK (amount > 0),
    status TEXT NOT NULL DEFAULT 'completed',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
  • Create migrations/003_create_journal_entries.sql with the following content:
CREATE TABLE journal_entries (
    id UUID PRIMARY KEY,
    transaction_id UUID NOT NULL REFERENCES transactions(id),
    account_id UUID NOT NULL REFERENCES accounts(id),
    amount BIGINT NOT NULL CHECK (amount > 0),
    direction TEXT NOT NULL CHECK (direction IN ('debit', 'credit')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_journal_entries_account_id ON journal_entries(account_id);
  • Create migrations/004_create_idempotency_keys.sql with the following content:
CREATE TABLE idempotency_keys (
    key TEXT PRIMARY KEY,
    response JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Why this schema design?

The accounts table has no balance column. This is intentional. Balances are derived by summing journal entries, so the ledger is always mathematically verifiable.

The journal_entries table records every debit and credit. It is append-only. Entries are never updated or deleted, giving you a complete audit trail.

The idempotency_keys table stores cached responses so that retried requests return the same result instead of creating duplicate transfers.

  • Confirm all four files exist by running:
ls migrations/

You should see 001_create_accounts.sql, 002_create_transactions.sql, 003_create_journal_entries.sql, and 004_create_idempotency_keys.sql in the output.

  • Apply all four migrations to the running PostgreSQL container by running these commands:
docker compose exec -T postgres psql -U ledger -d ledger < migrations/001_create_accounts.sql
docker compose exec -T postgres psql -U ledger -d ledger < migrations/002_create_transactions.sql
docker compose exec -T postgres psql -U ledger -d ledger < migrations/003_create_journal_entries.sql
docker compose exec -T postgres psql -U ledger -d ledger < migrations/004_create_idempotency_keys.sql

What does this command do?

docker compose exec -T postgres psql runs the psql client inside your running PostgreSQL container. The -T flag disables TTY allocation so stdin redirection works correctly. The < operator feeds each SQL file into psql for execution.

  • Verify the tables were created by connecting to the database:
docker compose exec postgres psql -U ledger -d ledger -c '\dt'

You should see a table listing four entries: accounts, transactions, journal_entries, and idempotency_keys.

Seeing a connection error or missing tables?

  • Run docker compose ps to confirm the postgres container is still running. If it crashed, check logs with docker compose logs postgres.
  • Make sure you applied the migrations in order (001 first, then 002, etc.). The transactions table references accounts, so accounts must exist first.
  • If you see a "relation already exists" error, the migration already ran successfully. This is safe to ignore.

help me troubleshoot my PostgreSQL migration errors

✔️ Awesome, I've got everything!

Great. Double check you have saved all four migration files in the migrations/ directory.

ⓧ I'd like to double check the full code

CREATE TABLE accounts (
    id UUID PRIMARY KEY,
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE transactions (
    id UUID PRIMARY KEY,
    source_account_id UUID NOT NULL REFERENCES accounts(id),
    destination_account_id UUID NOT NULL REFERENCES accounts(id),
    amount BIGINT NOT NULL CHECK (amount > 0),
    status TEXT NOT NULL DEFAULT 'completed',
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE journal_entries (
    id UUID PRIMARY KEY,
    transaction_id UUID NOT NULL REFERENCES transactions(id),
    account_id UUID NOT NULL REFERENCES accounts(id),
    amount BIGINT NOT NULL CHECK (amount > 0),
    direction TEXT NOT NULL CHECK (direction IN ('debit', 'credit')),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_journal_entries_account_id ON journal_entries(account_id);
CREATE TABLE idempotency_keys (
    key TEXT PRIMARY KEY,
    response JSONB NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Your database is ready and your schema enforces the fundamental rule of double-entry bookkeeping: every debit has a matching credit. Next up, you'll build the HTTP server and account management endpoints.

Build the HTTP Server and Account Endpoints

With your database running and schema in place, you need a way for the outside world to interact with it. An HTTP API is the standard interface for financial systems.

In this step, you will build the Go application layer that sits between HTTP requests and your PostgreSQL database. You will create the connection pool, define your data models, write request handlers, and wire everything together into a running server.

In this step, get ready to:
  • Create the database connection pool and data models.
  • Implement health check and account management handlers.
  • Wire up routes and verify the API responds with curl.
Create the data layer and models

Before writing any HTTP handlers, you need two things: a way to talk to PostgreSQL and Go structs that represent your database tables.

  • Create a file called db.go inside your ledger-api folder by running:
touch db.go
  • Add the database connection pool function to db.go by pasting the following code:
package main

import (
	"context"

	"github.com/jackc/pgx/v5/pgxpool"
)

// NewDBPool creates a connection pool to PostgreSQL
func NewDBPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
	config, err := pgxpool.ParseConfig(databaseURL)
	if err != nil {
		return nil, err
	}
	// Limit concurrent connections to avoid overwhelming the database
	config.MaxConns = 10

	pool, err := pgxpool.NewWithConfig(ctx, config)
	if err != nil {
		return nil, err
	}

	// Verify the connection works before returning
	if err := pool.Ping(ctx); err != nil {
		pool.Close()
		return nil, err
	}

	return pool, nil
}

What does this code do?

pgxpool.ParseConfig reads the connection string and builds a pool configuration. Setting MaxConns = 10 limits how many simultaneous database connections your API can hold open.

The Ping call verifies the database is reachable before the application starts serving requests. If the ping fails, the pool is closed immediately to avoid leaking resources.

  • Save db.go.

Why pgxpool instead of database/sql?

pgx is the de facto PostgreSQL driver in Go fintech projects. It provides direct access to PostgreSQL-specific features like prepared statements and better performance compared to the generic database/sql interface.

Next, you need Go structs that mirror your database tables. These structs will serialize to JSON for API responses.

  • Create a file called models.go by running:
touch models.go
  • Add all the data models to models.go by pasting the following code:
package main

import (
	"time"

	"github.com/google/uuid"
)

// Account represents a ledger account (e.g. Alice, Bob, System)
type Account struct {
	ID        uuid.UUID `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

// Transaction records a fund transfer between two accounts
type Transaction struct {
	ID                   uuid.UUID      `json:"id"`
	SourceAccountID      uuid.UUID      `json:"source_account_id"`
	DestinationAccountID uuid.UUID      `json:"destination_account_id"`
	Amount               int64          `json:"amount"`
	Status               string         `json:"status"`
	CreatedAt            time.Time      `json:"created_at"`
	JournalEntries       []JournalEntry `json:"journal_entries,omitempty"`
}

// JournalEntry is one side of a double-entry booking (debit or credit)
type JournalEntry struct {
	ID            uuid.UUID `json:"id"`
	TransactionID uuid.UUID `json:"transaction_id"`
	AccountID     uuid.UUID `json:"account_id"`
	Amount        int64     `json:"amount"`
	Direction     string    `json:"direction"`
	CreatedAt     time.Time `json:"created_at"`
}

// CreateAccountRequest is the JSON body for POST /v1/accounts
type CreateAccountRequest struct {
	Name string `json:"name"`
}

// CreateTransactionRequest is the JSON body for POST /v1/transactions
type CreateTransactionRequest struct {
	IdempotencyKey       string    `json:"idempotency_key"`
	SourceAccountID      uuid.UUID `json:"source_account_id"`
	DestinationAccountID uuid.UUID `json:"destination_account_id"`
	Amount               int64     `json:"amount"`
}

// BalanceResponse is the JSON response for GET /v1/accounts/{id}/balance
type BalanceResponse struct {
	AccountID uuid.UUID `json:"account_id"`
	Balance   int64     `json:"balance"`
}

// ErrorResponse is the standard error JSON shape
type ErrorResponse struct {
	Error string `json:"error"`
	Code  string `json:"code"`
}

What does this code do?

Each struct maps directly to a database table or an API request/response shape. The json:"..." struct tags control how Go serializes these into JSON for API responses.

Notice that Amount is int64 (not a float). Financial systems always store money as integers in the smallest currency unit (cents) to avoid floating-point rounding errors.

  • Save models.go.

Seeing import errors in your editor?

Your editor might show red underlines on the imports because no code uses these types yet. This is expected and will resolve once you add the handlers in the next substep.

help me fix import errors in my Go project

✔️ Awesome, I've got everything!

Great. Make sure both db.go and models.go are saved.

ⓧ I'd like to double check the full code

Here is the full db.go:

package main

import (
	"context"

	"github.com/jackc/pgx/v5/pgxpool"
)

func NewDBPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
	config, err := pgxpool.ParseConfig(databaseURL)
	if err != nil {
		return nil, err
	}
	config.MaxConns = 10

	pool, err := pgxpool.NewWithConfig(ctx, config)
	if err != nil {
		return nil, err
	}

	if err := pool.Ping(ctx); err != nil {
		pool.Close()
		return nil, err
	}

	return pool, nil
}

Here is the full models.go:

package main

import (
	"time"

	"github.com/google/uuid"
)

type Account struct {
	ID        uuid.UUID `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

type Transaction struct {
	ID                   uuid.UUID      `json:"id"`
	SourceAccountID      uuid.UUID      `json:"source_account_id"`
	DestinationAccountID uuid.UUID      `json:"destination_account_id"`
	Amount               int64          `json:"amount"`
	Status               string         `json:"status"`
	CreatedAt            time.Time      `json:"created_at"`
	JournalEntries       []JournalEntry `json:"journal_entries,omitempty"`
}

type JournalEntry struct {
	ID            uuid.UUID `json:"id"`
	TransactionID uuid.UUID `json:"transaction_id"`
	AccountID     uuid.UUID `json:"account_id"`
	Amount        int64     `json:"amount"`
	Direction     string    `json:"direction"`
	CreatedAt     time.Time `json:"created_at"`
}

type CreateAccountRequest struct {
	Name string `json:"name"`
}

type CreateTransactionRequest struct {
	IdempotencyKey       string    `json:"idempotency_key"`
	SourceAccountID      uuid.UUID `json:"source_account_id"`
	DestinationAccountID uuid.UUID `json:"destination_account_id"`
	Amount               int64     `json:"amount"`
}

type BalanceResponse struct {
	AccountID uuid.UUID `json:"account_id"`
	Balance   int64     `json:"balance"`
}

type ErrorResponse struct {
	Error string `json:"error"`
	Code  string `json:"code"`
}
Build the request handlers

Handlers are functions that receive an HTTP request and write back a response. You will group them in a single file and attach them to an App struct that holds the database pool.

  • Create a file called handlers.go by running:
touch handlers.go
  • Add the package declaration, imports, App struct, and health check handler to handlers.go by pasting the following code:
package main

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

// App holds shared dependencies like the database pool
type App struct {
	DB *pgxpool.Pool
}

// handleHealth verifies the database is reachable
func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
	if err := a.DB.Ping(r.Context()); err != nil {
		writeError(w, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "database unreachable")
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

What does this code do?

The App struct is the central place to store shared dependencies (the database pool today, and potentially caches or config later). Attaching handlers as methods on App gives each handler access to the pool without global variables.

handleHealth pings the database and returns a 200 if it responds. Load balancers and orchestrators use this endpoint to decide if your service is ready to receive traffic.

  • You won't see a change yet. The writeError function referenced in handleHealth will be added at the bottom of this file shortly.
  • Add the handleCreateAccount function below handleHealth in handlers.go:
// handleCreateAccount inserts a new account and returns it as JSON
func (a *App) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
	var req CreateAccountRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
		return
	}
	if req.Name == "" {
		writeError(w, http.StatusBadRequest, "MISSING_NAME", "name is required")
		return
	}

	// Build the account with a new UUID and current timestamp
	account := Account{
		ID:        uuid.New(),
		Name:      req.Name,
		CreatedAt: time.Now().UTC(),
	}

	_, err := a.DB.Exec(r.Context(),
		"INSERT INTO accounts (id, name, created_at) VALUES ($1, $2, $3)",
		account.ID, account.Name, account.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to create account")
		return
	}

	writeJSON(w, http.StatusCreated, account)
}

What does this code do?

This handler decodes the JSON request body into a CreateAccountRequest struct, validates the name is present, generates a UUID, and inserts the account into PostgreSQL.

The $1, $2, $3 placeholders are parameterized queries. They prevent SQL injection by keeping user input separate from the SQL statement.

  • Add the handleGetAccount function below handleCreateAccount in handlers.go:
// handleGetAccount fetches a single account by its UUID
func (a *App) handleGetAccount(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid account ID")
		return
	}

	var account Account
	err = a.DB.QueryRow(r.Context(),
		"SELECT id, name, created_at FROM accounts WHERE id = $1", id,
	).Scan(&account.ID, &account.Name, &account.CreatedAt)
	if err == pgx.ErrNoRows {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "account not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch account")
		return
	}

	writeJSON(w, http.StatusOK, account)
}

What does this code do?

r.PathValue("id") extracts the account ID from the URL path. This is a feature of Go's ServeMux that matches patterns like GET /v1/accounts/{id}.

The pgx.ErrNoRows sentinel error lets you distinguish between "no account with that ID" (404) and an actual database failure (500).

  • Add the writeJSON and writeError helper functions at the bottom of handlers.go:
// writeJSON serializes data to JSON and writes it with the given status code
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

// writeError sends a structured error response
func writeError(w http.ResponseWriter, status int, code, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorResponse{Error: message, Code: code})
}

What do these helpers do?

Every API response needs a Content-Type: application/json header and a status code. These two functions centralize that boilerplate so handlers stay focused on business logic.

writeError always returns a consistent error shape with a machine-readable code and a human-readable error message. API consumers can switch on the code field to handle errors programmatically.

  • Save handlers.go.

Seeing an "unused import" error for context?

The context import is used by functions added in later steps. If your editor complains, you can temporarily add var _ = context.Background below the imports. You will remove it once the later handlers are added.

help me fix unused import errors in my Go handlers file

✔️ Awesome, I've got everything!

Great. Make sure handlers.go is saved.

ⓧ I'd like to double check the full code

Here is the full handlers.go:

package main

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type App struct {
	DB *pgxpool.Pool
}

func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
	if err := a.DB.Ping(r.Context()); err != nil {
		writeError(w, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "database unreachable")
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

func (a *App) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
	var req CreateAccountRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
		return
	}
	if req.Name == "" {
		writeError(w, http.StatusBadRequest, "MISSING_NAME", "name is required")
		return
	}

	account := Account{
		ID:        uuid.New(),
		Name:      req.Name,
		CreatedAt: time.Now().UTC(),
	}

	_, err := a.DB.Exec(r.Context(),
		"INSERT INTO accounts (id, name, created_at) VALUES ($1, $2, $3)",
		account.ID, account.Name, account.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to create account")
		return
	}

	writeJSON(w, http.StatusCreated, account)
}

func (a *App) handleGetAccount(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid account ID")
		return
	}

	var account Account
	err = a.DB.QueryRow(r.Context(),
		"SELECT id, name, created_at FROM accounts WHERE id = $1", id,
	).Scan(&account.ID, &account.Name, &account.CreatedAt)
	if err == pgx.ErrNoRows {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "account not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch account")
		return
	}

	writeJSON(w, http.StatusOK, account)
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, code, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorResponse{Error: message, Code: code})
}
Wire up routes and test the API

The handlers exist but nothing calls them yet. You need a main.go that creates the database pool, registers routes on a ServeMux, and starts the HTTP server.

  • Create main.go by running:
touch main.go
  • Add the server setup code to main.go by pasting the following:
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx := context.Background()

	// Use DATABASE_URL env var if set, otherwise default to local Docker Compose
	dbURL := os.Getenv("DATABASE_URL")
	if dbURL == "" {
		dbURL = "postgres://ledger:ledger@localhost:5432/ledger?sslmode=disable"
	}

	pool, err := NewDBPool(ctx, dbURL)
	if err != nil {
		log.Fatalf("failed to connect to database: %v", err)
	}
	defer pool.Close()

	app := &App{DB: pool}

	// Register routes using Go 1.22+ method-based patterns
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", app.handleHealth)
	mux.HandleFunc("POST /v1/accounts", app.handleCreateAccount)
	mux.HandleFunc("GET /v1/accounts/{id}", app.handleGetAccount)

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	// Start the server in a goroutine so we can listen for shutdown signals
	go func() {
		fmt.Println("ledger-api listening on :8080")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("server error: %v", err)
		}
	}()

	// Wait for SIGINT or SIGTERM, then shut down gracefully
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	server.Shutdown(shutdownCtx)
	fmt.Println("server shut down")
}

What does this code do?

The main function connects to PostgreSQL using the pool from db.go, creates an App instance, and registers three routes. Go's ServeMux uses method-based patterns like GET /health to match both the HTTP method and path in one string.

The graceful shutdown block waits for a SIGINT or SIGTERM signal (sent when you press Ctrl+C). It then gives in-flight requests 5 seconds to finish before closing connections.

  • Save main.go.

Why no framework?

Go's standard library net/http is production-ready. Companies like Stripe and Cloudflare run it in production without frameworks like Gin or Echo. Go 1.22+ added method-based routing to ServeMux, which removed the main reason developers reached for third-party routers.

✔️ Awesome, I've got everything!

Great. Make sure main.go is saved.

ⓧ I'd like to double check the full code

Here is the full main.go:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx := context.Background()

	dbURL := os.Getenv("DATABASE_URL")
	if dbURL == "" {
		dbURL = "postgres://ledger:ledger@localhost:5432/ledger?sslmode=disable"
	}

	pool, err := NewDBPool(ctx, dbURL)
	if err != nil {
		log.Fatalf("failed to connect to database: %v", err)
	}
	defer pool.Close()

	app := &App{DB: pool}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", app.handleHealth)
	mux.HandleFunc("POST /v1/accounts", app.handleCreateAccount)
	mux.HandleFunc("GET /v1/accounts/{id}", app.handleGetAccount)

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	go func() {
		fmt.Println("ledger-api listening on :8080")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("server error: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	server.Shutdown(shutdownCtx)
	fmt.Println("server shut down")
}

Now verify the project compiles and the server starts.

  • Build and start the server by running:
go run .
  • You should see ledger-api listening on :8080 printed in your terminal.

Seeing a compilation error?

If you see an error about unused imports, check that handlers.go includes the context, pgx, and pgxpool imports. These are needed by the functions you will add in the next step, but Go requires all imported packages to be referenced in the current file.

If you see a connection error, confirm your PostgreSQL container is still running with docker compose ps.

help me fix a compilation or connection error in my Go ledger-api project

Open a second terminal tab so you can send requests while the server keeps running.

  • Create a new account by running this curl command in the second terminal:
curl -s -X POST localhost:8080/v1/accounts -d '{"name":"Alice"}' | jq
  • You should see a JSON response with an id (UUID), name ("Alice"), and created_at timestamp.
  • Copy the id value from the response, then retrieve that account by running:
curl -s localhost:8080/v1/accounts/[[ACCOUNT_ID="paste the UUID from the previous response"]] | jq
  • You should see the same Alice account returned.
  • Verify the health endpoint by running:
curl -s localhost:8080/health | jq
  • You should see {"status":"ok"} in the response.

Getting "connection refused" from curl?

Make sure go run . is still running in your first terminal tab. The server must be active to accept requests.

If you see a database error in the server output, verify PostgreSQL is running with docker compose ps and that port 5432 is not blocked by another process.

help me debug why curl can't connect to my Go server on port 8080

Your API is alive and serving requests. Next up, you will implement the core transfer logic that creates atomic debit and credit journal entries.

Implement Fund Transfers with Double-Entry Bookkeeping

Your API can create and retrieve accounts. Now it is time to implement the operation that makes a ledger a ledger: moving money between accounts.

In double-entry bookkeeping, every transfer creates two entries: a debit from the source and a credit to the destination. The sum of all debits always equals the sum of all credits. If they ever diverge, you know something is broken. This step builds that guarantee into your API using ACID transactions, deterministic row locking, and idempotent request handling.

In this step, get ready to:
  • Implement the transfer handler with atomic debit/credit journal entries.
  • Add balance derivation helpers that compute balances from journal entries.
  • Register the route and verify transfers work with idempotency.
Add the balance derivation helpers

Before building the transfer handler, you need a way to compute an account's current balance. Instead of storing a mutable balance column, you derive it by summing journal entries. This makes the ledger mathematically verifiable at any point in time.

  • In handlers.go, add these two helper functions below the writeError function:
func getBalance(ctx context.Context, pool *pgxpool.Pool, accountID uuid.UUID) (int64, error) {
	var credits, debits int64
	err := pool.QueryRow(ctx,
		"SELECT COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0) FROM journal_entries WHERE account_id = $1",
		accountID,
	).Scan(&credits, &debits)
	if err != nil {
		return 0, err
	}
	return credits - debits, nil
}

func getBalanceTx(ctx context.Context, tx pgx.Tx, accountID uuid.UUID) (int64, error) {
	var credits, debits int64
	err := tx.QueryRow(ctx,
		"SELECT COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0) FROM journal_entries WHERE account_id = $1",
		accountID,
	).Scan(&credits, &debits)
	if err != nil {
		return 0, err
	}
	return credits - debits, nil
}

What does this code do?

Both functions run the same SQL query that sums all credit amounts and all debit amounts for a given account, then returns the difference (credits minus debits).

  • getBalance uses the connection pool directly. You will use this for standalone balance queries later.
  • getBalanceTx uses a pgx.Tx (database transaction). The transfer handler needs this version so the balance check happens inside the same transaction as the transfer itself.
  • COALESCE(..., 0) ensures a new account with no journal entries returns 0 instead of NULL.

Why derive balances instead of storing them?

A mutable balance column can drift out of sync with journal history. Bugs, partial failures, or manual edits create irreconcilable differences. Deriving the balance from journal entries means the ledger is always self-consistent. If you sum all debits and credits across the entire system, the result is always zero.

At production scale you would add materialized views or checkpoint tables for performance. For this project the SUM query is fast enough.

  • Save handlers.go.

Seeing a compile error?

  • Make sure both functions are placed outside any existing function body (below writeError, not inside it).
  • Check that your import block at the top of handlers.go still includes "context", "github.com/jackc/pgx/v5", and "github.com/jackc/pgx/v5/pgxpool".
  • help me debug a compile error in my balance helper functions
Build the transfer handler with double-entry bookkeeping

This is the core of the ledger. The handler performs the entire transfer inside a single database transaction so it either fully succeeds or fully rolls back. It also handles idempotency (safe retries) and prevents deadlocks by locking accounts in a deterministic order.

  • In handlers.go, add the handleCreateTransaction method on the App struct. Place it below handleGetAccount and above getBalance:
func (a *App) handleCreateTransaction(w http.ResponseWriter, r *http.Request) {
	var req CreateTransactionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
		return
	}

	// Validate the request fields
	if req.IdempotencyKey == "" {
		writeError(w, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY", "idempotency_key is required")
		return
	}
	if req.Amount <= 0 {
		writeError(w, http.StatusBadRequest, "INVALID_AMOUNT", "amount must be positive")
		return
	}
	if req.SourceAccountID == req.DestinationAccountID {
		writeError(w, http.StatusBadRequest, "SAME_ACCOUNT", "source and destination must differ")
		return
	}

	ctx := r.Context()

	// Check if this request was already processed (idempotency)
	var cachedResponse []byte
	err := a.DB.QueryRow(ctx,
		"SELECT response FROM idempotency_keys WHERE key = $1", req.IdempotencyKey,
	).Scan(&cachedResponse)
	if err == nil {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		w.Write(cachedResponse)
		return
	}

	// Begin a database transaction for atomicity
	tx, err := a.DB.Begin(ctx)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to begin transaction")
		return
	}
	defer tx.Rollback(ctx)

	// Lock accounts in deterministic UUID order to prevent deadlocks
	firstID, secondID := req.SourceAccountID, req.DestinationAccountID
	if req.SourceAccountID.String() > req.DestinationAccountID.String() {
		firstID, secondID = req.DestinationAccountID, req.SourceAccountID
	}

	var firstExists, secondExists bool
	err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE)", firstID).Scan(&firstExists)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to lock account")
		return
	}
	err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE)", secondID).Scan(&secondExists)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to lock account")
		return
	}

	if !firstExists || !secondExists {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "one or both accounts not found")
		return
	}

	// Check source account has sufficient funds
	balance, err := getBalanceTx(ctx, tx, req.SourceAccountID)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to compute balance")
		return
	}
	if balance < req.Amount {
		writeError(w, http.StatusUnprocessableEntity, "INSUFFICIENT_BALANCE", "insufficient balance")
		return
	}

	// Insert the transaction record
	txn := Transaction{
		ID:                   uuid.New(),
		SourceAccountID:      req.SourceAccountID,
		DestinationAccountID: req.DestinationAccountID,
		Amount:               req.Amount,
		Status:               "completed",
		CreatedAt:            time.Now().UTC(),
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO transactions (id, source_account_id, destination_account_id, amount, status, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		txn.ID, txn.SourceAccountID, txn.DestinationAccountID, txn.Amount, txn.Status, txn.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert transaction")
		return
	}

	// Create the two journal entries (debit from source, credit to destination)
	debitEntry := JournalEntry{
		ID:            uuid.New(),
		TransactionID: txn.ID,
		AccountID:     req.SourceAccountID,
		Amount:        req.Amount,
		Direction:     "debit",
		CreatedAt:     txn.CreatedAt,
	}
	creditEntry := JournalEntry{
		ID:            uuid.New(),
		TransactionID: txn.ID,
		AccountID:     req.DestinationAccountID,
		Amount:        req.Amount,
		Direction:     "credit",
		CreatedAt:     txn.CreatedAt,
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO journal_entries (id, transaction_id, account_id, amount, direction, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		debitEntry.ID, debitEntry.TransactionID, debitEntry.AccountID, debitEntry.Amount, debitEntry.Direction, debitEntry.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert journal entry")
		return
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO journal_entries (id, transaction_id, account_id, amount, direction, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		creditEntry.ID, creditEntry.TransactionID, creditEntry.AccountID, creditEntry.Amount, creditEntry.Direction, creditEntry.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert journal entry")
		return
	}

	// Cache the response for idempotency
	txn.JournalEntries = []JournalEntry{debitEntry, creditEntry}
	responseBytes, _ := json.Marshal(txn)

	_, err = tx.Exec(ctx,
		"INSERT INTO idempotency_keys (key, response) VALUES ($1, $2)",
		req.IdempotencyKey, responseBytes,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to store idempotency key")
		return
	}

	// Commit the entire transaction atomically
	if err := tx.Commit(ctx); err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to commit transaction")
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	w.Write(responseBytes)
}

What does this handler do?

This is the most critical function in the entire API. It performs six operations inside a single database transaction:

  • Request validation rejects requests missing an idempotency key, with a non-positive amount, or targeting the same account for source and destination.
  • Idempotency check looks up the idempotency key in the database. If found, it returns the cached response immediately without re-processing.
  • Deterministic locking sorts the two account UUIDs by string value and locks the lower one first. This prevents deadlocks when two concurrent transfers involve the same pair of accounts in opposite directions.
  • Balance verification derives the source account's balance from journal entries and rejects the transfer if funds are insufficient.
  • Double-entry writes inserts the transaction record plus two journal entries (one debit, one credit) of equal amounts.
  • Idempotency caching stores the serialized response so future requests with the same key return the cached result.

Why lock rows in UUID order?

Imagine two requests arrive simultaneously: one transfers from Alice to Bob, and another transfers from Bob to Alice. Without deterministic ordering, request 1 locks Alice and waits for Bob, while request 2 locks Bob and waits for Alice. Neither can proceed. This is a deadlock.

By always locking the lower UUID first, both requests acquire locks in the same sequence. One completes and releases its locks before the other proceeds.

  • Save handlers.go.
  • Confirm the code compiles by running:
go build ./...

The build should complete with no output (no errors).

Build failing?

  • If you see an "undefined" error referencing getBalanceTx, make sure the helper functions from the previous substep are present in handlers.go.
  • If you see a duplicate method error, make sure you did not accidentally paste the function inside another function body.
  • Check that the import block includes "encoding/json" and "time" (both were already there from the previous step).
  • help me fix my Go build error in handlers.go
Register the route and test transfers

The handler is ready but the server does not know about it yet. You need to register the route, then verify the full transfer flow works end-to-end.

  • In main.go, find this line:
mux.HandleFunc("GET /v1/accounts/{id}", app.handleGetAccount)
  • Add the following line directly below it:
mux.HandleFunc("POST /v1/transactions", app.handleCreateTransaction)
  • Save main.go.
  • Stop any running server (Ctrl+C) and restart it by running:
go run .

You should see ledger-api listening on :8080 in your terminal.

Now test the full transfer flow. Open a second terminal window to run the following commands.

  • Create a treasury account (this acts as the money source) by running:
curl -s -X POST localhost:8080/v1/accounts -d '{"name":"Treasury"}' | jq
  • Copy the id value from the response. You will use it in the next commands.
  • Create an Alice account by running:
curl -s -X POST localhost:8080/v1/accounts -d '{"name":"Alice"}' | jq
  • Copy Alice's id value as well.
  • Seed the Treasury with an initial balance by inserting a genesis credit directly into the database:
docker compose exec postgres psql -U ledger -d ledger -c "
INSERT INTO transactions (id, source_account_id, destination_account_id, amount, status, created_at)
VALUES (gen_random_uuid(), '[[TREASURY_ID="your Treasury account UUID"]]', '[[TREASURY_ID="your Treasury account UUID"]]', 100000, 'completed', NOW());

INSERT INTO journal_entries (id, transaction_id, account_id, amount, direction, created_at)
VALUES (gen_random_uuid(), (SELECT id FROM transactions ORDER BY created_at DESC LIMIT 1), '[[TREASURY_ID="your Treasury account UUID"]]', 100000, 'credit', NOW());
"

Why seed the Treasury manually?

Every real ledger system has a genesis operation that bootstraps initial funds. In production, this would be a privileged admin operation or a bank integration. For testing, you insert directly into the database to give the Treasury account a starting balance of 100000 units.

  • Transfer 1000 units from Treasury to Alice by running:
curl -s -X POST localhost:8080/v1/transactions \
  -H "Content-Type: application/json" \
  -d '{
    "idempotency_key": "seed-alice-001",
    "source_account_id": "[[TREASURY_ID="your Treasury account UUID"]]",
    "destination_account_id": "[[ALICE_ID="your Alice account UUID"]]",
    "amount": 1000
  }' | jq

You should see a JSON response with a transaction ID, both account IDs, and two journal entries (one debit, one credit).

  • Verify idempotency by running the exact same command again with the same idempotency_key:
curl -s -X POST localhost:8080/v1/transactions \
  -H "Content-Type: application/json" \
  -d '{
    "idempotency_key": "seed-alice-001",
    "source_account_id": "[[TREASURY_ID="your Treasury account UUID"]]",
    "destination_account_id": "[[ALICE_ID="your Alice account UUID"]]",
    "amount": 1000
  }' | jq

The response is identical to the first request. The transfer was not duplicated. This proves your idempotency logic works.

What is idempotency?

An idempotent operation produces the same result whether you run it once or ten times. Network failures, timeouts, and retries are common in distributed systems. Without idempotency, a retry could transfer money twice. The idempotency key ensures each unique transfer intent is processed exactly once.

Seeing an error response?

  • If you see ACCOUNT_NOT_FOUND, double-check that you replaced the UUID placeholders with the actual IDs returned from the account creation commands.
  • If you see INSUFFICIENT_BALANCE, the Treasury seeding command may not have run successfully. Re-run the docker compose exec command and check for SQL errors in the output.
  • If the server is not responding, make sure go run . is still running in your first terminal and that Docker is also running.
  • help me debug my transfer endpoint

✔️ Awesome, I've got everything!

Great. Double check you have saved both handlers.go and main.go.

ⓧ I'd like to double check the full code

Here is the complete handlers.go at this point:

package main

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type App struct {
	DB *pgxpool.Pool
}

func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
	if err := a.DB.Ping(r.Context()); err != nil {
		writeError(w, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "database unreachable")
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

func (a *App) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
	var req CreateAccountRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
		return
	}
	if req.Name == "" {
		writeError(w, http.StatusBadRequest, "MISSING_NAME", "name is required")
		return
	}

	account := Account{
		ID:        uuid.New(),
		Name:      req.Name,
		CreatedAt: time.Now().UTC(),
	}

	_, err := a.DB.Exec(r.Context(),
		"INSERT INTO accounts (id, name, created_at) VALUES ($1, $2, $3)",
		account.ID, account.Name, account.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to create account")
		return
	}

	writeJSON(w, http.StatusCreated, account)
}

func (a *App) handleGetAccount(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid account ID")
		return
	}

	var account Account
	err = a.DB.QueryRow(r.Context(),
		"SELECT id, name, created_at FROM accounts WHERE id = $1", id,
	).Scan(&account.ID, &account.Name, &account.CreatedAt)
	if err == pgx.ErrNoRows {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "account not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch account")
		return
	}

	writeJSON(w, http.StatusOK, account)
}

func (a *App) handleCreateTransaction(w http.ResponseWriter, r *http.Request) {
	var req CreateTransactionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
		return
	}

	if req.IdempotencyKey == "" {
		writeError(w, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY", "idempotency_key is required")
		return
	}
	if req.Amount <= 0 {
		writeError(w, http.StatusBadRequest, "INVALID_AMOUNT", "amount must be positive")
		return
	}
	if req.SourceAccountID == req.DestinationAccountID {
		writeError(w, http.StatusBadRequest, "SAME_ACCOUNT", "source and destination must differ")
		return
	}

	ctx := r.Context()

	var cachedResponse []byte
	err := a.DB.QueryRow(ctx,
		"SELECT response FROM idempotency_keys WHERE key = $1", req.IdempotencyKey,
	).Scan(&cachedResponse)
	if err == nil {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		w.Write(cachedResponse)
		return
	}

	tx, err := a.DB.Begin(ctx)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to begin transaction")
		return
	}
	defer tx.Rollback(ctx)

	firstID, secondID := req.SourceAccountID, req.DestinationAccountID
	if req.SourceAccountID.String() > req.DestinationAccountID.String() {
		firstID, secondID = req.DestinationAccountID, req.SourceAccountID
	}

	var firstExists, secondExists bool
	err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE)", firstID).Scan(&firstExists)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to lock account")
		return
	}
	err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE)", secondID).Scan(&secondExists)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to lock account")
		return
	}

	if !firstExists || !secondExists {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "one or both accounts not found")
		return
	}

	balance, err := getBalanceTx(ctx, tx, req.SourceAccountID)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to compute balance")
		return
	}
	if balance < req.Amount {
		writeError(w, http.StatusUnprocessableEntity, "INSUFFICIENT_BALANCE", "insufficient balance")
		return
	}

	txn := Transaction{
		ID:                   uuid.New(),
		SourceAccountID:      req.SourceAccountID,
		DestinationAccountID: req.DestinationAccountID,
		Amount:               req.Amount,
		Status:               "completed",
		CreatedAt:            time.Now().UTC(),
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO transactions (id, source_account_id, destination_account_id, amount, status, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		txn.ID, txn.SourceAccountID, txn.DestinationAccountID, txn.Amount, txn.Status, txn.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert transaction")
		return
	}

	debitEntry := JournalEntry{
		ID:            uuid.New(),
		TransactionID: txn.ID,
		AccountID:     req.SourceAccountID,
		Amount:        req.Amount,
		Direction:     "debit",
		CreatedAt:     txn.CreatedAt,
	}
	creditEntry := JournalEntry{
		ID:            uuid.New(),
		TransactionID: txn.ID,
		AccountID:     req.DestinationAccountID,
		Amount:        req.Amount,
		Direction:     "credit",
		CreatedAt:     txn.CreatedAt,
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO journal_entries (id, transaction_id, account_id, amount, direction, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		debitEntry.ID, debitEntry.TransactionID, debitEntry.AccountID, debitEntry.Amount, debitEntry.Direction, debitEntry.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert journal entry")
		return
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO journal_entries (id, transaction_id, account_id, amount, direction, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		creditEntry.ID, creditEntry.TransactionID, creditEntry.AccountID, creditEntry.Amount, creditEntry.Direction, creditEntry.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert journal entry")
		return
	}

	txn.JournalEntries = []JournalEntry{debitEntry, creditEntry}
	responseBytes, _ := json.Marshal(txn)

	_, err = tx.Exec(ctx,
		"INSERT INTO idempotency_keys (key, response) VALUES ($1, $2)",
		req.IdempotencyKey, responseBytes,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to store idempotency key")
		return
	}

	if err := tx.Commit(ctx); err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to commit transaction")
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	w.Write(responseBytes)
}

func getBalance(ctx context.Context, pool *pgxpool.Pool, accountID uuid.UUID) (int64, error) {
	var credits, debits int64
	err := pool.QueryRow(ctx,
		"SELECT COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0) FROM journal_entries WHERE account_id = $1",
		accountID,
	).Scan(&credits, &debits)
	if err != nil {
		return 0, err
	}
	return credits - debits, nil
}

func getBalanceTx(ctx context.Context, tx pgx.Tx, accountID uuid.UUID) (int64, error) {
	var credits, debits int64
	err := tx.QueryRow(ctx,
		"SELECT COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0) FROM journal_entries WHERE account_id = $1",
		accountID,
	).Scan(&credits, &debits)
	if err != nil {
		return 0, err
	}
	return credits - debits, nil
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, code, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorResponse{Error: message, Code: code})
}

And here is the complete main.go:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx := context.Background()

	dbURL := os.Getenv("DATABASE_URL")
	if dbURL == "" {
		dbURL = "postgres://ledger:ledger@localhost:5432/ledger?sslmode=disable"
	}

	pool, err := NewDBPool(ctx, dbURL)
	if err != nil {
		log.Fatalf("failed to connect to database: %v", err)
	}
	defer pool.Close()

	app := &App{DB: pool}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", app.handleHealth)
	mux.HandleFunc("POST /v1/accounts", app.handleCreateAccount)
	mux.HandleFunc("GET /v1/accounts/{id}", app.handleGetAccount)
	mux.HandleFunc("POST /v1/transactions", app.handleCreateTransaction)

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	go func() {
		fmt.Println("ledger-api listening on :8080")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("server error: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	server.Shutdown(shutdownCtx)
	fmt.Println("server shut down")
}

Your ledger now enforces the fundamental rule of double-entry bookkeeping: every debit has a matching credit, and they are written atomically. Next up, you will expose read endpoints so you can query transaction details and account balances directly through the API.

Add Transaction and Balance Query Endpoints

Your ledger can now create accounts and move funds between them with full ACID guarantees. But a ledger that can only write is only half useful.

In this step, you will expose read endpoints so clients can query transaction details and account balances. The balance endpoint reuses the same journal-entry derivation your transfer logic already relies on internally.

In this step, get ready to:
  • Implement a transaction detail endpoint that returns journal entries alongside the transaction record.
  • Implement a balance query endpoint that derives balances from journal entries.
  • Register the new routes and verify everything works with curl.
Implement the get-transaction handler

When a client fetches a transaction, they need to see the full audit trail. That means returning the transaction record joined with its debit and credit journal entries in a single response.

  • In handlers.go, add the following handleGetTransaction function below the handleCreateTransaction function (above getBalance):
func (a *App) handleGetTransaction(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid transaction ID")
		return
	}

	var txn Transaction
	err = a.DB.QueryRow(r.Context(),
		"SELECT id, source_account_id, destination_account_id, amount, status, created_at FROM transactions WHERE id = $1", id,
	).Scan(&txn.ID, &txn.SourceAccountID, &txn.DestinationAccountID, &txn.Amount, &txn.Status, &txn.CreatedAt)
	if err == pgx.ErrNoRows {
		writeError(w, http.StatusNotFound, "TRANSACTION_NOT_FOUND", "transaction not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch transaction")
		return
	}

	// Fetch the journal entries that belong to this transaction
	rows, err := a.DB.Query(r.Context(),
		"SELECT id, transaction_id, account_id, amount, direction, created_at FROM journal_entries WHERE transaction_id = $1 ORDER BY created_at", id,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch journal entries")
		return
	}
	defer rows.Close()

	// Append each journal entry to the transaction response
	for rows.Next() {
		var je JournalEntry
		rows.Scan(&je.ID, &je.TransactionID, &je.AccountID, &je.Amount, &je.Direction, &je.CreatedAt)
		txn.JournalEntries = append(txn.JournalEntries, je)
	}

	writeJSON(w, http.StatusOK, txn)
}

What does this code do?

This handler performs a two-query join. First it fetches the transaction row itself. Then it queries the journal_entries table for all entries matching that transaction ID.

  • pgx.ErrNoRows catches the case where the UUID is valid but no matching transaction exists.
  • ORDER BY created_at ensures the debit entry always appears before the credit entry in the response.
  • The JournalEntries field on Transaction uses the omitempty JSON tag, so it only appears in the response when entries are present.

You won't see a change yet. The next substep adds the balance handler, then we will register both routes and test.

Implement the get-balance handler

The balance for any account is never stored as a mutable column. It is always derived by summing journal entries. This handler exposes that derivation as a public endpoint, reusing the getBalance helper you wrote in the previous step.

  • In handlers.go, add the following handleGetBalance function below handleGetAccount and above handleCreateTransaction:
func (a *App) handleGetBalance(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid account ID")
		return
	}

	// Verify the account exists before computing balance
	var exists bool
	a.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1)", id).Scan(&exists)
	if !exists {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "account not found")
		return
	}

	// Derive balance from journal entries (credits minus debits)
	balance, err := getBalance(r.Context(), a.DB, id)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to compute balance")
		return
	}

	writeJSON(w, http.StatusOK, BalanceResponse{AccountID: id, Balance: balance})
}

What does this code do?

  • The existence check prevents a confusing balance: 0 response for UUIDs that don't belong to any account.
  • getBalance sums all credits and subtracts all debits from the journal_entries table. This is the same derivation your transfer handler uses internally to check sufficient funds.
  • The response uses the BalanceResponse struct (already defined in models.go) which returns the account ID and computed balance.

Why derive balance instead of storing it?

A stored balance column drifts from reality the moment a bug skips updating it. Deriving from journal entries means the balance is always mathematically verifiable. If your credits minus debits ever disagrees with a stored column, you have a reconciliation nightmare.

At scale, you would add materialized views or periodic checkpoints for performance. For this project, the SUM query is fast enough.

  • Save handlers.go.

Seeing a compile error?

  • Make sure handleGetBalance is placed between handleGetAccount and handleCreateTransaction. Placing it inside another function body causes a syntax error.
  • Confirm that handleGetTransaction is placed after handleCreateTransaction and before the getBalance helper function.

Help me fix a compile error in handlers.go

Register the new routes and test

The handlers exist but the HTTP server does not know about them yet. You need to register both new routes on the mux.

  • In main.go, find the route registration block. It currently looks like this:
mux.HandleFunc("GET /health", app.handleHealth)
mux.HandleFunc("POST /v1/accounts", app.handleCreateAccount)
mux.HandleFunc("GET /v1/accounts/{id}", app.handleGetAccount)
mux.HandleFunc("POST /v1/transactions", app.handleCreateTransaction)
  • Replace that block with the following, which adds the two new routes:
mux.HandleFunc("GET /health", app.handleHealth)
mux.HandleFunc("POST /v1/accounts", app.handleCreateAccount)
mux.HandleFunc("GET /v1/accounts/{id}", app.handleGetAccount)
mux.HandleFunc("GET /v1/accounts/{id}/balance", app.handleGetBalance)
mux.HandleFunc("POST /v1/transactions", app.handleCreateTransaction)
mux.HandleFunc("GET /v1/transactions/{id}", app.handleGetTransaction)

Why does the balance route come before the account route?

Go's ServeMux picks the most specific pattern that matches. GET /v1/accounts/{id}/balance is more specific than GET /v1/accounts/{id}, so the order in code does not matter for routing. Grouping them together is purely for readability.

  • Save main.go.
  • Stop the running server (press Ctrl+C in the terminal) and rebuild by running:
go run .

You should see ledger-api listening on :8080 printed in the terminal.

  • Open a second terminal tab and test the balance endpoint by running:
curl localhost:8080/v1/accounts/00000000-0000-0000-0000-000000000000/balance | jq

You should see a 404 response with ACCOUNT_NOT_FOUND. This confirms the handler is wired up correctly and the existence check works.

  • Test the transaction endpoint by running:
curl localhost:8080/v1/transactions/00000000-0000-0000-0000-000000000000 | jq

You should see a 404 response with TRANSACTION_NOT_FOUND. Both read endpoints are live and returning proper error responses for missing resources.

Getting connection refused?

  • Make sure go run . is running in your first terminal tab without errors.
  • Confirm Docker is still running (docker compose ps should show postgres as running).
  • If you see a build error about an undefined function, double check that the handler names in main.go match exactly: app.handleGetBalance and app.handleGetTransaction.

Help me debug my ledger API routes

✔️ Awesome, I've got everything!

Great. Double check you have saved both handlers.go and main.go.

ⓧ I'd like to double check the full code

Here is the complete handlers.go at this point:

package main

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

type App struct {
	DB *pgxpool.Pool
}

func (a *App) handleHealth(w http.ResponseWriter, r *http.Request) {
	if err := a.DB.Ping(r.Context()); err != nil {
		writeError(w, http.StatusServiceUnavailable, "DATABASE_UNAVAILABLE", "database unreachable")
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

func (a *App) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
	var req CreateAccountRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
		return
	}
	if req.Name == "" {
		writeError(w, http.StatusBadRequest, "MISSING_NAME", "name is required")
		return
	}

	account := Account{
		ID:        uuid.New(),
		Name:      req.Name,
		CreatedAt: time.Now().UTC(),
	}

	_, err := a.DB.Exec(r.Context(),
		"INSERT INTO accounts (id, name, created_at) VALUES ($1, $2, $3)",
		account.ID, account.Name, account.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to create account")
		return
	}

	writeJSON(w, http.StatusCreated, account)
}

func (a *App) handleGetAccount(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid account ID")
		return
	}

	var account Account
	err = a.DB.QueryRow(r.Context(),
		"SELECT id, name, created_at FROM accounts WHERE id = $1", id,
	).Scan(&account.ID, &account.Name, &account.CreatedAt)
	if err == pgx.ErrNoRows {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "account not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch account")
		return
	}

	writeJSON(w, http.StatusOK, account)
}

func (a *App) handleGetBalance(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid account ID")
		return
	}

	var exists bool
	a.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1)", id).Scan(&exists)
	if !exists {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "account not found")
		return
	}

	balance, err := getBalance(r.Context(), a.DB, id)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to compute balance")
		return
	}

	writeJSON(w, http.StatusOK, BalanceResponse{AccountID: id, Balance: balance})
}

func (a *App) handleCreateTransaction(w http.ResponseWriter, r *http.Request) {
	var req CreateTransactionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body")
		return
	}

	if req.IdempotencyKey == "" {
		writeError(w, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY", "idempotency_key is required")
		return
	}
	if req.Amount <= 0 {
		writeError(w, http.StatusBadRequest, "INVALID_AMOUNT", "amount must be positive")
		return
	}
	if req.SourceAccountID == req.DestinationAccountID {
		writeError(w, http.StatusBadRequest, "SAME_ACCOUNT", "source and destination must differ")
		return
	}

	ctx := r.Context()

	var cachedResponse []byte
	err := a.DB.QueryRow(ctx,
		"SELECT response FROM idempotency_keys WHERE key = $1", req.IdempotencyKey,
	).Scan(&cachedResponse)
	if err == nil {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		w.Write(cachedResponse)
		return
	}

	tx, err := a.DB.Begin(ctx)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to begin transaction")
		return
	}
	defer tx.Rollback(ctx)

	firstID, secondID := req.SourceAccountID, req.DestinationAccountID
	if req.SourceAccountID.String() > req.DestinationAccountID.String() {
		firstID, secondID = req.DestinationAccountID, req.SourceAccountID
	}

	var firstExists, secondExists bool
	err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE)", firstID).Scan(&firstExists)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to lock account")
		return
	}
	err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM accounts WHERE id = $1 FOR UPDATE)", secondID).Scan(&secondExists)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to lock account")
		return
	}

	if !firstExists || !secondExists {
		writeError(w, http.StatusNotFound, "ACCOUNT_NOT_FOUND", "one or both accounts not found")
		return
	}

	balance, err := getBalanceTx(ctx, tx, req.SourceAccountID)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to compute balance")
		return
	}
	if balance < req.Amount {
		writeError(w, http.StatusUnprocessableEntity, "INSUFFICIENT_BALANCE", "insufficient balance")
		return
	}

	txn := Transaction{
		ID:                   uuid.New(),
		SourceAccountID:      req.SourceAccountID,
		DestinationAccountID: req.DestinationAccountID,
		Amount:               req.Amount,
		Status:               "completed",
		CreatedAt:            time.Now().UTC(),
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO transactions (id, source_account_id, destination_account_id, amount, status, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		txn.ID, txn.SourceAccountID, txn.DestinationAccountID, txn.Amount, txn.Status, txn.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert transaction")
		return
	}

	debitEntry := JournalEntry{
		ID:            uuid.New(),
		TransactionID: txn.ID,
		AccountID:     req.SourceAccountID,
		Amount:        req.Amount,
		Direction:     "debit",
		CreatedAt:     txn.CreatedAt,
	}
	creditEntry := JournalEntry{
		ID:            uuid.New(),
		TransactionID: txn.ID,
		AccountID:     req.DestinationAccountID,
		Amount:        req.Amount,
		Direction:     "credit",
		CreatedAt:     txn.CreatedAt,
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO journal_entries (id, transaction_id, account_id, amount, direction, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		debitEntry.ID, debitEntry.TransactionID, debitEntry.AccountID, debitEntry.Amount, debitEntry.Direction, debitEntry.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert journal entry")
		return
	}

	_, err = tx.Exec(ctx,
		"INSERT INTO journal_entries (id, transaction_id, account_id, amount, direction, created_at) VALUES ($1, $2, $3, $4, $5, $6)",
		creditEntry.ID, creditEntry.TransactionID, creditEntry.AccountID, creditEntry.Amount, creditEntry.Direction, creditEntry.CreatedAt,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to insert journal entry")
		return
	}

	txn.JournalEntries = []JournalEntry{debitEntry, creditEntry}
	responseBytes, _ := json.Marshal(txn)

	_, err = tx.Exec(ctx,
		"INSERT INTO idempotency_keys (key, response) VALUES ($1, $2)",
		req.IdempotencyKey, responseBytes,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to store idempotency key")
		return
	}

	if err := tx.Commit(ctx); err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to commit transaction")
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	w.Write(responseBytes)
}

func (a *App) handleGetTransaction(w http.ResponseWriter, r *http.Request) {
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "INVALID_ID", "invalid transaction ID")
		return
	}

	var txn Transaction
	err = a.DB.QueryRow(r.Context(),
		"SELECT id, source_account_id, destination_account_id, amount, status, created_at FROM transactions WHERE id = $1", id,
	).Scan(&txn.ID, &txn.SourceAccountID, &txn.DestinationAccountID, &txn.Amount, &txn.Status, &txn.CreatedAt)
	if err == pgx.ErrNoRows {
		writeError(w, http.StatusNotFound, "TRANSACTION_NOT_FOUND", "transaction not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch transaction")
		return
	}

	rows, err := a.DB.Query(r.Context(),
		"SELECT id, transaction_id, account_id, amount, direction, created_at FROM journal_entries WHERE transaction_id = $1 ORDER BY created_at", id,
	)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "DB_ERROR", "failed to fetch journal entries")
		return
	}
	defer rows.Close()

	for rows.Next() {
		var je JournalEntry
		rows.Scan(&je.ID, &je.TransactionID, &je.AccountID, &je.Amount, &je.Direction, &je.CreatedAt)
		txn.JournalEntries = append(txn.JournalEntries, je)
	}

	writeJSON(w, http.StatusOK, txn)
}

func getBalance(ctx context.Context, pool *pgxpool.Pool, accountID uuid.UUID) (int64, error) {
	var credits, debits int64
	err := pool.QueryRow(ctx,
		"SELECT COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0) FROM journal_entries WHERE account_id = $1",
		accountID,
	).Scan(&credits, &debits)
	if err != nil {
		return 0, err
	}
	return credits - debits, nil
}

func getBalanceTx(ctx context.Context, tx pgx.Tx, accountID uuid.UUID) (int64, error) {
	var credits, debits int64
	err := tx.QueryRow(ctx,
		"SELECT COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0) FROM journal_entries WHERE account_id = $1",
		accountID,
	).Scan(&credits, &debits)
	if err != nil {
		return 0, err
	}
	return credits - debits, nil
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, code, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(ErrorResponse{Error: message, Code: code})
}

Here is the complete main.go at this point:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	ctx := context.Background()

	dbURL := os.Getenv("DATABASE_URL")
	if dbURL == "" {
		dbURL = "postgres://ledger:ledger@localhost:5432/ledger?sslmode=disable"
	}

	pool, err := NewDBPool(ctx, dbURL)
	if err != nil {
		log.Fatalf("failed to connect to database: %v", err)
	}
	defer pool.Close()

	app := &App{DB: pool}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", app.handleHealth)
	mux.HandleFunc("POST /v1/accounts", app.handleCreateAccount)
	mux.HandleFunc("GET /v1/accounts/{id}", app.handleGetAccount)
	mux.HandleFunc("GET /v1/accounts/{id}/balance", app.handleGetBalance)
	mux.HandleFunc("POST /v1/transactions", app.handleCreateTransaction)
	mux.HandleFunc("GET /v1/transactions/{id}", app.handleGetTransaction)

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	go func() {
		fmt.Println("ledger-api listening on :8080")
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("server error: %v", err)
		}
	}()

	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	<-quit

	shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	server.Shutdown(shutdownCtx)
	fmt.Println("server shut down")
}

Your API now has all five endpoints live: create accounts, retrieve accounts, transfer funds, query balances, and inspect transactions with their full journal entry audit trail. Next up, you will protect these endpoints with JWT authentication in the Secret Mission.

Secret mission

Add JWT Authentication

Your ledger API is wide open. Anyone who can reach port 8080 can create accounts, move funds, and read balances. In production, every fintech API gates access behind authentication. Add JWT-based authentication to protect all /v1/* endpoints while keeping the health check public.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally via Docker, so there are no ongoing cloud costs.

Resources you used:

  • Docker container running PostgreSQL (local, free).
  • The ledger-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with your ledger API.

  • Your PostgreSQL container continues running in the background.
  • Your project files remain on your Desktop.
  • You can restart the API at any time with go run . from inside the ledger-api folder.

Pause - I'll come back to this later

Shut down running processes to free up system memory while keeping your data intact.

  • Stop the Go server by pressing Ctrl+C in the terminal where it is running.
  • Stop the PostgreSQL container (without deleting your data) by running:
cd ~/Desktop/ledger-api
docker compose stop
  • When you are ready to resume, start the container again with docker compose up -d from the same folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Stop the Go server by pressing Ctrl+C in the terminal where it is running.
  • Remove the Docker container and its PostgreSQL data volume by running:
cd ~/Desktop/ledger-api
docker compose down -v
  • Delete the project folder by running:
cd ~/Desktop
rm -rf ledger-api
  • On Windows, use this instead:
cd ~\Desktop
rmdir /s /q ledger-api
  • Confirm the folder is gone by running ls on your Desktop. You should no longer see ledger-api listed.

Nice Work!

Nice Work!

Nice work! You just built a production-pattern double-entry ledger API from scratch in Go.

This is the same foundational primitive that powers every serious fintech backend. You now have a working proof that you understand how money moves safely in software.

You've learned how to:

  • Build a REST API in Go using only the standard library (net/http) with method-based routing and no external framework.
  • Implement double-entry bookkeeping where every transfer atomically creates balanced debit and credit journal entries, with balances derived from the append-only ledger instead of a mutable column.
  • Make transfers idempotent using idempotency keys with cached responses, and prevent deadlocks by locking account rows in deterministic UUID order.
  • Secret Mission: Protected all endpoints with JWT authentication middleware that validates Bearer tokens on every /v1/* request.

Ready to quiz yourself?