Production-Ready REST API in Go
Build a full CRUD bookstore API with middleware, SQLite, and graceful shutdown.
Introduction
30 Second Summary
Every app you use daily talks to a server through an API. The difference between a hobby API and one that handles real traffic comes down to patterns that prevent crashes, manage load, and shut down cleanly.
In this project, you will build a production-ready Bookstore REST API in Go using the same patterns found at companies running high-traffic services.
What You'll Build
A full CRUD REST API for a bookstore, built with Go's standard library and production-grade middleware.
By the end of this project, you'll have:
- Full CRUD endpoints using Go 1.22+ enhanced routing with method patterns and path parameters.
- A middleware stack with structured JSON logging, panic recovery, and token bucket rate limiting.
- An SQLite database configured with WAL mode and production connection settings for graceful shutdown.
- Secret Mission: Add pagination and filtering to your GET /books endpoint to handle large datasets efficiently.
Are there any prerequisites?
This project assumes familiarity with Go basics including structs, interfaces, error handling, and packages. You should be comfortable reading and writing Go code before starting.
Initialize the Project
Every production Go service begins with a well-organized project. The directory layout and dependency management determine how cleanly your codebase scales as features grow.
In this step, you'll create the project directory, initialize a Go module, pull in two external packages, and lay out the folder structure that production Go services use.
In this step, get ready to:
- Create the bookstore project directory and initialize the Go module.
- Install modernc.org/sqlite and golang.org/x/time as external dependencies.
- Set up the cmd/ and internal/ folder structure following Go conventions.
Create the project directory and initialize the Go module
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir bookstore
cd bookstore
- Initialize a Go module by running this command:
go mod init bookstore
What does this command do?
go mod init bookstore creates a go.mod file that declares this directory as a Go module named bookstore. Every Go project needs a module file to manage dependencies and define import paths.
- Verify the module was created by running:
cat go.mod
You should see module bookstore on the first line and go 1.24.6 on the next.
Seeing 'go: command not found'?
This means Go is not in your system's PATH. Close and reopen your terminal, then try go version to confirm Go is accessible.
help me fix Go not being found in my PATH after installing Go 1.24.6
Install the external dependencies
Your API needs two external packages. One provides a pure-Go SQLite driver for the database. The other provides rate limiting from Go's official extended library.
- Install both packages by running these commands:
go get modernc.org/sqlite@v1.51.0
go get golang.org/x/time@v0.15.0
What do these packages do?
modernc.org/sqlite is a pure-Go SQLite driver. It requires no C compiler or external libraries, so it works on any platform without extra setup.
golang.org/x/time is Go's official extended library for time-related utilities. You'll use its rate package to implement token bucket rate limiting in your middleware.
- Verify the dependencies were added by running:
cat go.mod
You should see both modernc.org/sqlite v1.51.0 and golang.org/x/time v0.15.0 listed in the require block.
Seeing a network error?
The go get command downloads packages from the internet. Make sure you have a stable connection. If you're behind a corporate proxy, you may need to configure GOPROXY or HTTPS_PROXY environment variables.
help me fix go get failing with a network or timeout error
Set up the folder structure
Go projects follow a standard layout. The cmd/ directory holds entry points (your main.go), and internal/ holds private packages that only this project can import.
Why cmd/ and internal/?
The cmd/ directory separates your application's entry point from its library code. This lets you add multiple entry points later (like a CLI tool or a migration runner) without restructuring.
The internal/ directory is enforced by the Go compiler. No external module can import packages under internal/. This guarantees your implementation details stay private.
- Create all the project directories by running this command:
macOS
mkdir -p cmd/api internal/database internal/model internal/repository internal/service internal/handler internal/middleware
Windows
mkdir cmd/api, internal/database, internal/model, internal/repository, internal/service, internal/handler, internal/middleware
- Confirm the top-level folders were created by running:
ls
You should see cmd and internal listed alongside go.mod and go.sum.
- Verify the internal subdirectories by running:
ls internal
You should see six folders: database, handler, middleware, model, repository, and service.
Missing directories?
Make sure you ran the mkdir command from inside the bookstore folder. Run pwd (macOS) or cd (Windows) to check your current location. You should see a path ending in bookstore.
help me fix my project directory structure for a Go project
✔️ Awesome, I've got everything!
Great. Double check you're inside the bookstore directory and that go.mod and go.sum exist alongside your cmd/ and internal/ folders.
ⓧ I'd like to double check the full code
Here is what your go.mod file should look like. You will also see additional indirect dependencies listed below the direct require block.
module bookstore
go 1.24.6
require (
golang.org/x/time v0.15.0
modernc.org/sqlite v1.51.0
)
Your project scaffold is ready. Next up, you'll build the data layer with a SQLite database and a full repository implementation.
Build the Data Layer
Your project skeleton is ready with all the folders and dependencies in place. Now it's time to build the foundation that every other layer depends on: the data layer.
The data layer defines what a "book" looks like, how to connect to the database, and how to perform operations on book records. Getting this right means every layer above (services, handlers) can trust the data it receives.
In this step, get ready to:
- Define the Book model with JSON tags for API responses.
- Configure a production-ready SQLite connection with WAL mode.
- Implement a repository with full CRUD operations.
Define the Book model struct with JSON tags
Every API needs a clear definition of the data it works with. In Go, you define this with structs and JSON tags that control how data is serialized for API responses.
- Open internal/model/book.go in your editor and paste the following code:
package model
import "time"
// Book represents a single book record in the database
type Book struct {
ID int64 `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
PublishedYear int `json:"published_year,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateBookRequest defines the fields needed to create a new book
type CreateBookRequest struct {
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
PublishedYear int `json:"published_year,omitempty"`
}
// UpdateBookRequest uses pointers so we can distinguish "not provided" from "set to zero"
type UpdateBookRequest struct {
Title *string `json:"title,omitempty"`
Author *string `json:"author,omitempty"`
ISBN *string `json:"isbn,omitempty"`
PublishedYear *int `json:"published_year,omitempty"`
}
What does this code do?
This file defines three types that represent book data throughout the application:
- Book is the core entity with all fields including database-managed timestamps.
- CreateBookRequest holds only the fields a client sends when creating a book. No ID or timestamps needed because the database generates those.
- UpdateBookRequest uses pointer fields (*string, *int) so Go can tell the difference between "the client didn't send this field" (nil) and "the client set it to empty" (pointer to zero value).
- Save internal/model/book.go.
- Verify the file compiles by running:
go build ./internal/model/
If there's no output, the file compiled successfully.
Seeing a compile error?
- Check that the first line says package model exactly. The package name must match the folder name.
- Make sure import "time" is present. The Book struct uses time.Time for its timestamp fields.
- Confirm the file is saved inside the internal/model/ directory, not the project root.
Help me debug this compile error
Create the SQLite database connection with production settings
A database connection isn't just opening a file. Production services configure write-ahead logging, timeout handling, and connection limits to avoid crashes under load.
- Open internal/database/database.go in your editor and paste the New function with its imports:
package database
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// New opens a SQLite database with production-ready settings
func New(filepath string) (*sql.DB, error) {
// Configure pragmas for WAL mode, busy timeout, sync mode, and foreign keys
dsn := fmt.Sprintf("%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)", filepath)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
// SQLite is single-writer, so serialize access to avoid "database is locked" errors
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
if err := migrate(db); err != nil {
return nil, fmt.Errorf("running migrations: %w", err)
}
return db, nil
}
What do these production settings do?
- WAL mode (Write-Ahead Logging) enables concurrent reads while a write is happening. Without it, readers block on every write.
- busy_timeout(5000) waits 5 seconds for a database lock instead of immediately failing. This prevents spurious errors under concurrent access.
- SetMaxOpenConns(1) serializes all writes through a single connection. This is the single most important SQLite setting in Go because SQLite only supports one writer at a time.
- The blank import _ "modernc.org/sqlite" registers the SQLite driver so sql.Open("sqlite", ...) knows which driver to use.
The New function calls migrate(db) which you'll add next.
- Add the migrate function below the New function in the same file:
// migrate creates the books table if it doesn't already exist
func migrate(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
isbn TEXT UNIQUE NOT NULL,
published_year INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
_, err := db.Exec(query)
return err
}
What does this migration do?
The migrate function runs automatically when the database opens. It creates the books table with constraints:
- isbn TEXT UNIQUE NOT NULL ensures no two books share the same ISBN.
- CURRENT_TIMESTAMP defaults mean the database handles timestamps automatically on insert.
- CREATE TABLE IF NOT EXISTS makes the migration idempotent. Running it twice does nothing harmful.
- Save internal/database/database.go.
- Verify it compiles by running:
go build ./internal/database/
No output means success.
Seeing an import error?
- Make sure the blank import line reads _ "modernc.org/sqlite" with an underscore and a space before the quoted path.
- Confirm your go.mod file lists modernc.org/sqlite in the require block. If not, run go mod tidy to resolve it.
- The driver name in sql.Open must be "sqlite" (not "sqlite3"). The modernc.org/sqlite package registers as "sqlite".
Help me fix this import error
✔️ Awesome, I've got everything!
Great. Make sure you've saved internal/database/database.go before moving on.
ⓧ I'd like to double check the full code
package database
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
func New(filepath string) (*sql.DB, error) {
dsn := fmt.Sprintf("%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)", filepath)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
if err := migrate(db); err != nil {
return nil, fmt.Errorf("running migrations: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
query := `
CREATE TABLE IF NOT EXISTS books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
isbn TEXT UNIQUE NOT NULL,
published_year INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
_, err := db.Exec(query)
return err
}
Implement the repository layer with CRUD operations
The repository layer is the only code that touches the database directly. Every other layer asks the repository for data instead of writing SQL itself.
- Open internal/repository/book.go in your editor and paste the package declaration, imports, struct, constructor, and Create method:
package repository
import (
"context"
"database/sql"
"fmt"
"bookstore/internal/model"
)
// BookRepository handles all database operations for books
type BookRepository struct {
db *sql.DB
}
// NewBookRepository creates a new repository with the given database connection
func NewBookRepository(db *sql.DB) *BookRepository {
return &BookRepository{db: db}
}
// Create inserts a new book and returns the complete record
func (r *BookRepository) Create(ctx context.Context, req model.CreateBookRequest) (*model.Book, error) {
query := `INSERT INTO books (title, author, isbn, published_year) VALUES (?, ?, ?, ?) RETURNING id, title, author, isbn, published_year, created_at, updated_at`
var book model.Book
err := r.db.QueryRowContext(ctx, query, req.Title, req.Author, req.ISBN, req.PublishedYear).Scan(
&book.ID, &book.Title, &book.Author, &book.ISBN, &book.PublishedYear, &book.CreatedAt, &book.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("creating book: %w", err)
}
return &book, nil
}
What does this code do?
- BookRepository holds a reference to the database connection. All methods operate through this single connection.
- Every method takes a context.Context parameter. This allows the caller to cancel long-running queries (for example, during graceful shutdown).
- RETURNING in the INSERT query tells SQLite to send back the complete row, including the auto-generated id and timestamps. One query instead of two.
You won't see a change yet. The read methods come next.
- Add the GetByID and GetAll methods below the Create method:
// GetByID fetches a single book by its ID, returns nil if not found
func (r *BookRepository) GetByID(ctx context.Context, id int64) (*model.Book, error) {
query := `SELECT id, title, author, isbn, published_year, created_at, updated_at FROM books WHERE id = ?`
var book model.Book
err := r.db.QueryRowContext(ctx, query, id).Scan(
&book.ID, &book.Title, &book.Author, &book.ISBN, &book.PublishedYear, &book.CreatedAt, &book.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting book: %w", err)
}
return &book, nil
}
// GetAll returns every book, ordered by most recently created
func (r *BookRepository) GetAll(ctx context.Context) ([]model.Book, error) {
query := `SELECT id, title, author, isbn, published_year, created_at, updated_at FROM books ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("listing books: %w", err)
}
defer rows.Close()
var books []model.Book
for rows.Next() {
var book model.Book
if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.ISBN, &book.PublishedYear, &book.CreatedAt, &book.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning book: %w", err)
}
books = append(books, book)
}
return books, rows.Err()
}
What do these read methods do?
- GetByID returns nil, nil when no book is found. This lets the service layer distinguish "not found" from "database error" without special error types.
- GetAll iterates over multiple rows using rows.Next() and rows.Scan(). The defer rows.Close() ensures the database cursor is released even if scanning fails.
- rows.Err() at the end catches any error that occurred during iteration but wasn't surfaced by rows.Next().
You won't see a change yet. The Update and Delete methods complete the file.
- Add the Update and Delete methods below GetAll:
// Update applies partial changes to a book, returns nil if not found
func (r *BookRepository) Update(ctx context.Context, id int64, req model.UpdateBookRequest) (*model.Book, error) {
// First fetch the existing book
book, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
if book == nil {
return nil, nil
}
// Apply only the fields that were provided (non-nil pointers)
if req.Title != nil {
book.Title = *req.Title
}
if req.Author != nil {
book.Author = *req.Author
}
if req.ISBN != nil {
book.ISBN = *req.ISBN
}
if req.PublishedYear != nil {
book.PublishedYear = *req.PublishedYear
}
query := `UPDATE books SET title = ?, author = ?, isbn = ?, published_year = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? RETURNING id, title, author, isbn, published_year, created_at, updated_at`
var updated model.Book
err = r.db.QueryRowContext(ctx, query, book.Title, book.Author, book.ISBN, book.PublishedYear, id).Scan(
&updated.ID, &updated.Title, &updated.Author, &updated.ISBN, &updated.PublishedYear, &updated.CreatedAt, &updated.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("updating book: %w", err)
}
return &updated, nil
}
// Delete removes a book by ID, returns an error if not found
func (r *BookRepository) Delete(ctx context.Context, id int64) error {
query := `DELETE FROM books WHERE id = ?`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("deleting book: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("book not found")
}
return nil
}
What do these write methods do?
- Update uses the "fetch then patch" pattern. It reads the current book, applies only the non-nil fields from the request, then writes the merged result back. This enables partial updates without overwriting unchanged fields.
- Delete checks RowsAffected() to verify the book actually existed. If zero rows were deleted, it returns an error so the handler can respond with 404.
- Save internal/repository/book.go.
- Verify all three files compile together by running:
go build ./...
No output means every package in your module compiled successfully.
Seeing an error about undefined types or imports?
- If you see undefined: model.Book, check that the import path reads "bookstore/internal/model" in your repository file. This must match your module name in go.mod.
- If you see a syntax error, make sure every opening brace has a matching closing brace. Go is strict about this.
- Run go vet ./... for more detailed error messages that pinpoint the exact line.
Help me debug this compile error
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete internal/repository/book.go:
package repository
import (
"context"
"database/sql"
"fmt"
"bookstore/internal/model"
)
type BookRepository struct {
db *sql.DB
}
func NewBookRepository(db *sql.DB) *BookRepository {
return &BookRepository{db: db}
}
func (r *BookRepository) Create(ctx context.Context, req model.CreateBookRequest) (*model.Book, error) {
query := `INSERT INTO books (title, author, isbn, published_year) VALUES (?, ?, ?, ?) RETURNING id, title, author, isbn, published_year, created_at, updated_at`
var book model.Book
err := r.db.QueryRowContext(ctx, query, req.Title, req.Author, req.ISBN, req.PublishedYear).Scan(
&book.ID, &book.Title, &book.Author, &book.ISBN, &book.PublishedYear, &book.CreatedAt, &book.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("creating book: %w", err)
}
return &book, nil
}
func (r *BookRepository) GetByID(ctx context.Context, id int64) (*model.Book, error) {
query := `SELECT id, title, author, isbn, published_year, created_at, updated_at FROM books WHERE id = ?`
var book model.Book
err := r.db.QueryRowContext(ctx, query, id).Scan(
&book.ID, &book.Title, &book.Author, &book.ISBN, &book.PublishedYear, &book.CreatedAt, &book.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("getting book: %w", err)
}
return &book, nil
}
func (r *BookRepository) GetAll(ctx context.Context) ([]model.Book, error) {
query := `SELECT id, title, author, isbn, published_year, created_at, updated_at FROM books ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("listing books: %w", err)
}
defer rows.Close()
var books []model.Book
for rows.Next() {
var book model.Book
if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.ISBN, &book.PublishedYear, &book.CreatedAt, &book.UpdatedAt); err != nil {
return nil, fmt.Errorf("scanning book: %w", err)
}
books = append(books, book)
}
return books, rows.Err()
}
func (r *BookRepository) Update(ctx context.Context, id int64, req model.UpdateBookRequest) (*model.Book, error) {
book, err := r.GetByID(ctx, id)
if err != nil {
return nil, err
}
if book == nil {
return nil, nil
}
if req.Title != nil {
book.Title = *req.Title
}
if req.Author != nil {
book.Author = *req.Author
}
if req.ISBN != nil {
book.ISBN = *req.ISBN
}
if req.PublishedYear != nil {
book.PublishedYear = *req.PublishedYear
}
query := `UPDATE books SET title = ?, author = ?, isbn = ?, published_year = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? RETURNING id, title, author, isbn, published_year, created_at, updated_at`
var updated model.Book
err = r.db.QueryRowContext(ctx, query, book.Title, book.Author, book.ISBN, book.PublishedYear, id).Scan(
&updated.ID, &updated.Title, &updated.Author, &updated.ISBN, &updated.PublishedYear, &updated.CreatedAt, &updated.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("updating book: %w", err)
}
return &updated, nil
}
func (r *BookRepository) Delete(ctx context.Context, id int64) error {
query := `DELETE FROM books WHERE id = ?`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("deleting book: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("book not found")
}
return nil
}
Why layered architecture?
You've just built the bottom two layers of a production Go service: models define the shape of data, and the repository handles all database access. The service layer (next step) never writes SQL. The handler layer (also next step) never touches the database directly.
This separation means you can swap SQLite for PostgreSQL later by replacing only the repository layer. Everything above it stays the same.
Your data layer is complete. Next up, you'll build the service and handler layers that expose these database operations as HTTP endpoints.
Build the Service and Handler Layers
Your repository knows how to talk to the database, but you don't want HTTP handlers reaching directly into the data layer. A service layer sits between them, enforcing business rules before anything touches the database.
In this step, you'll build the two remaining layers of the architecture: a service that validates input, and HTTP handlers that parse requests and return consistent JSON responses. This is the same layered architecture pattern used in production Go services at scale.
In this step, get ready to:
- Create a service layer with input validation and sentinel errors.
- Build HTTP handlers using Go 1.22+ method-based routing patterns.
- Implement consistent JSON error responses across all endpoints.
Create the service layer with business logic
The service layer is where your business rules live. Instead of letting handlers talk to the repository directly, the service validates input and translates nil results into meaningful errors.
- Create the service file by running this command:
touch internal/service/book.go
- Open internal/service/book.go in your editor and add the package declaration, sentinel errors, struct, and constructor:
package service
import (
"context"
"errors"
"bookstore/internal/model"
"bookstore/internal/repository"
)
// Sentinel errors for the service layer
var (
ErrBookNotFound = errors.New("book not found")
ErrInvalidInput = errors.New("invalid input")
)
// BookService handles business logic between handlers and repository
type BookService struct {
repo *repository.BookRepository
}
// NewBookService creates a new service instance
func NewBookService(repo *repository.BookRepository) *BookService {
return &BookService{repo: repo}
}
What does this code do?
ErrBookNotFound and ErrInvalidInput are sentinel errors. Handlers can use errors.Is() to check which error occurred and return the correct HTTP status code.
The struct holds a pointer to BookRepository. This is dependency injection. The service doesn't create its own repository. It receives one from the caller.
- Below the constructor, add the CRUD methods that wrap repository calls with validation:
// Create validates required fields before creating a book
func (s *BookService) Create(ctx context.Context, req model.CreateBookRequest) (*model.Book, error) {
if req.Title == "" || req.Author == "" || req.ISBN == "" {
return nil, ErrInvalidInput
}
return s.repo.Create(ctx, req)
}
// GetByID returns ErrBookNotFound when the book doesn't exist
func (s *BookService) GetByID(ctx context.Context, id int64) (*model.Book, error) {
book, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if book == nil {
return nil, ErrBookNotFound
}
return book, nil
}
// GetAll returns all books from the repository
func (s *BookService) GetAll(ctx context.Context) ([]model.Book, error) {
return s.repo.GetAll(ctx)
}
// Update applies partial updates, returns ErrBookNotFound if missing
func (s *BookService) Update(ctx context.Context, id int64, req model.UpdateBookRequest) (*model.Book, error) {
book, err := s.repo.Update(ctx, id, req)
if err != nil {
return nil, err
}
if book == nil {
return nil, ErrBookNotFound
}
return book, nil
}
// Delete removes a book by ID
func (s *BookService) Delete(ctx context.Context, id int64) error {
return s.repo.Delete(ctx, id)
}
What does this code do?
Create checks that title, author, and ISBN are all non-empty before forwarding to the repository. This keeps validation logic out of both the handler and the database layer.
GetByID and Update translate a nil result from the repository into ErrBookNotFound. The repository returns nil when the row doesn't exist. The service converts that into a typed error the handler can act on.
- Save internal/service/book.go.
- Verify the project still compiles by running:
go build ./...
If everything compiled correctly, you'll see no output at all. That's Go's way of saying "all good."
Seeing a compilation error?
- Check that the package declaration at the top is package service (not package main).
- Make sure the import path is bookstore/internal/repository. This must match the module name in your go.mod.
- Confirm the file is saved inside internal/service/ and not in a different directory.
Help me debug a Go compilation error in my service layer.
✔️ Awesome, I've got everything!
Great. Double check you've saved internal/service/book.go before moving on.
ⓧ I'd like to double check the full code
package service
import (
"context"
"errors"
"bookstore/internal/model"
"bookstore/internal/repository"
)
var (
ErrBookNotFound = errors.New("book not found")
ErrInvalidInput = errors.New("invalid input")
)
type BookService struct {
repo *repository.BookRepository
}
func NewBookService(repo *repository.BookRepository) *BookService {
return &BookService{repo: repo}
}
func (s *BookService) Create(ctx context.Context, req model.CreateBookRequest) (*model.Book, error) {
if req.Title == "" || req.Author == "" || req.ISBN == "" {
return nil, ErrInvalidInput
}
return s.repo.Create(ctx, req)
}
func (s *BookService) GetByID(ctx context.Context, id int64) (*model.Book, error) {
book, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if book == nil {
return nil, ErrBookNotFound
}
return book, nil
}
func (s *BookService) GetAll(ctx context.Context) ([]model.Book, error) {
return s.repo.GetAll(ctx)
}
func (s *BookService) Update(ctx context.Context, id int64, req model.UpdateBookRequest) (*model.Book, error) {
book, err := s.repo.Update(ctx, id, req)
if err != nil {
return nil, err
}
if book == nil {
return nil, ErrBookNotFound
}
return book, nil
}
func (s *BookService) Delete(ctx context.Context, id int64) error {
return s.repo.Delete(ctx, id)
}
Build HTTP handlers with Go 1.22+ routing
Handlers are the outermost layer. They parse HTTP requests, call the service, and write JSON responses. Go 1.22+ introduced method-based routing, so you can register patterns like GET /api/books/{id} directly on the standard library's ServeMux.
- Create the handler file by running this command:
touch internal/handler/book.go
- Open internal/handler/book.go in your editor and add the package declaration, imports, struct, constructor, and response helpers:
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"bookstore/internal/model"
"bookstore/internal/service"
)
// BookHandler handles HTTP requests for book endpoints
type BookHandler struct {
service *service.BookService
}
// NewBookHandler creates a new handler with the given service
func NewBookHandler(svc *service.BookService) *BookHandler {
return &BookHandler{service: svc}
}
// writeJSON sends a JSON response with the given status code
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// writeError sends a consistent JSON error response
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
What does this code do?
writeJSON and writeError are private helper functions that every handler will use. They ensure every response sets Content-Type: application/json and encodes the body consistently.
writeError always returns a JSON object with an error key. Clients can rely on this shape for every error, regardless of which endpoint failed.
- Below the helper functions, add the Create handler that demonstrates JSON body decoding and error branching:
// Create handles POST /api/books
func (h *BookHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateBookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
book, err := h.service.Create(r.Context(), req)
if err != nil {
if errors.Is(err, service.ErrInvalidInput) {
writeError(w, http.StatusBadRequest, "title, author, and isbn are required")
return
}
slog.Error("failed to create book", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusCreated, book)
}
What does this code do?
The handler decodes the JSON body into a CreateBookRequest struct. If the JSON is malformed, it returns a 400 immediately.
After calling the service, it checks the error type with errors.Is(). A validation error returns 400. An unexpected error logs the details with slog and returns a generic 500 to avoid leaking internals to the client.
- Below the Create handler, add the GetByID handler that extracts path parameters using r.PathValue():
// GetByID handles GET /api/books/{id}
func (h *BookHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid book id")
return
}
book, err := h.service.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, service.ErrBookNotFound) {
writeError(w, http.StatusNotFound, "book not found")
return
}
slog.Error("failed to get book", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, book)
}
What is r.PathValue?
r.PathValue("id") extracts the {id} segment from the URL pattern. This is a Go 1.22+ feature that replaces the need for third-party routers like Chi or Gorilla Mux.
The value is always a string, so you parse it to int64 with strconv.ParseInt. If someone sends /api/books/abc, the parse fails and the handler returns a 400.
- Save internal/handler/book.go and verify compilation by running:
go build ./...
No output means the code compiles successfully.
Seeing an import error?
- Check that bookstore/internal/service is in your import block. The handler depends on the service package you just created.
- Make sure the internal/service/book.go file is saved. Go resolves imports from files on disk.
Help me fix an import error in my Go handler file.
Implement the remaining handlers with consistent error responses
The remaining three handlers follow the same patterns you've already seen. GetAll returns an empty array instead of null when no books exist. Update and Delete both extract the path ID and handle not-found errors.
- Below the GetByID handler in internal/handler/book.go, add the GetAll handler:
// GetAll handles GET /api/books
func (h *BookHandler) GetAll(w http.ResponseWriter, r *http.Request) {
books, err := h.service.GetAll(r.Context())
if err != nil {
slog.Error("failed to list books", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
if books == nil {
books = []model.Book{}
}
writeJSON(w, http.StatusOK, books)
}
Why convert nil to an empty array?
When no books exist, the repository returns a nil slice. Encoding nil as JSON produces null, which breaks clients expecting an array. The if books == nil check guarantees the response is always [] instead of null.
- Below the GetAll handler, add the Update handler:
// Update handles PUT /api/books/{id}
func (h *BookHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid book id")
return
}
var req model.UpdateBookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
book, err := h.service.Update(r.Context(), id, req)
if err != nil {
if errors.Is(err, service.ErrBookNotFound) {
writeError(w, http.StatusNotFound, "book not found")
return
}
slog.Error("failed to update book", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, book)
}
- Below the Update handler, add the Delete handler:
// Delete handles DELETE /api/books/{id}
func (h *BookHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid book id")
return
}
if err := h.service.Delete(r.Context(), id); err != nil {
slog.Error("failed to delete book", "error", err)
writeError(w, http.StatusNotFound, "book not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
Why does Delete return 204 with no body?
HTTP 204 No Content signals that the request succeeded but there's nothing to send back. The resource is gone. This is the standard REST convention for successful deletions.
- Save internal/handler/book.go.
- Confirm the full project compiles cleanly by running:
go build ./...
No output means all packages compiled successfully.
Seeing undeclared name or type errors?
- Make sure model.Book{} in the GetAll handler uses model.Book (not Book). You need the package prefix since Book is defined in the model package.
- Verify your import block includes both bookstore/internal/model and bookstore/internal/service.
- Check for typos in method receiver names. Every handler method uses (h *BookHandler) as the receiver.
Help me debug a type or undeclared name error in my handler file.
✔️ Awesome, I've got everything!
Great. Double check you've saved both internal/service/book.go and internal/handler/book.go before moving on.
ⓧ I'd like to double check the full code
Here is the complete internal/handler/book.go file:
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"bookstore/internal/model"
"bookstore/internal/service"
)
type BookHandler struct {
service *service.BookService
}
func NewBookHandler(svc *service.BookService) *BookHandler {
return &BookHandler{service: svc}
}
func (h *BookHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreateBookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
book, err := h.service.Create(r.Context(), req)
if err != nil {
if errors.Is(err, service.ErrInvalidInput) {
writeError(w, http.StatusBadRequest, "title, author, and isbn are required")
return
}
slog.Error("failed to create book", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusCreated, book)
}
func (h *BookHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid book id")
return
}
book, err := h.service.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, service.ErrBookNotFound) {
writeError(w, http.StatusNotFound, "book not found")
return
}
slog.Error("failed to get book", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, book)
}
func (h *BookHandler) GetAll(w http.ResponseWriter, r *http.Request) {
books, err := h.service.GetAll(r.Context())
if err != nil {
slog.Error("failed to list books", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
if books == nil {
books = []model.Book{}
}
writeJSON(w, http.StatusOK, books)
}
func (h *BookHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid book id")
return
}
var req model.UpdateBookRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
book, err := h.service.Update(r.Context(), id, req)
if err != nil {
if errors.Is(err, service.ErrBookNotFound) {
writeError(w, http.StatusNotFound, "book not found")
return
}
slog.Error("failed to update book", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, book)
}
func (h *BookHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid book id")
return
}
if err := h.service.Delete(r.Context(), id); err != nil {
slog.Error("failed to delete book", "error", err)
writeError(w, http.StatusNotFound, "book not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
Your service and handler layers are complete. Next up, you'll build the middleware stack that adds logging, panic recovery, and rate limiting to every request.
Add the Middleware Stack
Your handler and service layers are in place, giving the API clean separation between HTTP concerns and business logic.
Production APIs need cross-cutting concerns that apply to every request: logging, crash recovery, and rate limiting. Instead of scattering this logic across individual handlers, you wrap your router in a stack of middleware functions that each handle one job.
In this step, get ready to:
- Implement structured logging middleware using log/slog with JSON output.
- Add panic recovery middleware that catches crashes and returns 500.
- Create rate limiting middleware using a token bucket algorithm.
Implement structured logging middleware
Every production service needs visibility into what requests it handles and how long they take. Structured logging outputs each log entry as a JSON object, making it easy to search and filter in log aggregation tools.
- Create a new file at internal/middleware/middleware.go inside your bookstore folder.
- Add the package declaration, imports, and the middleware infrastructure by pasting this code:
package middleware
import (
"fmt"
"log/slog"
"net/http"
"runtime/debug"
"time"
"golang.org/x/time/rate"
)
// Middleware is a function that wraps an http.Handler
type Middleware func(http.Handler) http.Handler
// Chain composes multiple middlewares into a single wrapper
func Chain(middlewares ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
What does this code do?
The Middleware type defines the standard Go pattern: a function that takes a handler, wraps it with extra behaviour, and returns a new handler.
The Chain function lets you compose multiple middlewares in declaration order. The first middleware in the list wraps outermost, so it runs first on the way in and last on the way out.
Why the unused imports?
The import block includes packages used by the Recovery and RateLimit functions you will add shortly. The file will not compile until all three middlewares are in place, so do not run go build until the end of this step.
- Add the Logging middleware and its helper responseWriter struct below the Chain function:
// Logging records method, path, status, duration, and client address
func Logging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
"remote_addr", r.RemoteAddr,
)
})
}
}
// responseWriter wraps http.ResponseWriter to capture the status code
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
What does this code do?
- The responseWriter struct embeds the real http.ResponseWriter and intercepts WriteHeader calls to capture the status code. Without this wrapper, the logging middleware would have no way to know what status the handler returned.
- The Logging function records the start time, passes the wrapped writer to the next handler, then logs a structured JSON entry with the method, path, status, duration in milliseconds, and client address.
Why structured logging over fmt.Println?
Go's log/slog package outputs key-value pairs as JSON. This makes logs machine-readable so tools like Datadog, Grafana Loki, or CloudWatch can filter by status code, path, or duration without parsing free-text strings.
Add panic recovery middleware
If a handler panics (nil pointer dereference, out-of-bounds slice access), the entire server process would crash without recovery middleware. This middleware catches panics, logs the stack trace, and returns a clean 500 response.
- Add the Recovery function below the responseWriter struct in internal/middleware/middleware.go:
// Recovery catches panics so a single bad request doesn't crash the server
func Recovery(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.Error("panic recovered",
"error", fmt.Sprintf("%v", err),
"stack", string(debug.Stack()),
"path", r.URL.Path,
)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
What does this code do?
- The defer keyword ensures the anonymous function runs even if the handler panics. Go's recover() captures the panic value so execution can continue.
- The debug.Stack() call captures the full goroutine stack trace and logs it alongside the error. This gives you the exact line of code that panicked.
- The middleware returns a JSON 500 response to the client instead of dropping the connection.
Create rate limiting middleware
Rate limiting protects your service from being overwhelmed by too many requests. The token bucket algorithm allows bursts of traffic up to a maximum, then refills tokens at a steady rate.
- Add the RateLimit function below Recovery in internal/middleware/middleware.go:
// RateLimit restricts requests using a token bucket algorithm
func RateLimit(rps float64, burst int) Middleware {
limiter := rate.NewLimiter(rate.Limit(rps), burst)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
What does this code do?
- The rate.NewLimiter from Go's official golang.org/x/time/rate package creates a token bucket. The rps parameter sets the refill rate (tokens per second) and burst sets the maximum tokens the bucket can hold.
- The limiter.Allow() call tries to consume one token. If the bucket is empty, it returns false and the middleware responds with HTTP 429 (Too Many Requests).
Why golang.org/x/time/rate over a custom implementation?
This is Go's official extended library for rate limiting, maintained by the Go team. It implements a proper token bucket that is goroutine-safe and battle-tested across the ecosystem.
- Save internal/middleware/middleware.go.
- Confirm the file compiles by running this in your terminal:
go build ./...
You should see no output, which means the code compiled successfully.
Seeing a compile error?
- If you see an "imported and not used" error, make sure all three middleware functions (Logging, Recovery, RateLimit) are present in the file. Each one uses packages from the import block.
- If you see "cannot find package", make sure you ran go get golang.org/x/time in Step 1. Your go.mod should list golang.org/x/time under require.
- Check that your file is saved at exactly internal/middleware/middleware.go with package name middleware at the top.
Help me debug my middleware compile error.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
Here is the complete internal/middleware/middleware.go file:
package middleware
import (
"fmt"
"log/slog"
"net/http"
"runtime/debug"
"time"
"golang.org/x/time/rate"
)
type Middleware func(http.Handler) http.Handler
func Chain(middlewares ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
func Logging(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
"remote_addr", r.RemoteAddr,
)
})
}
}
func Recovery(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.Error("panic recovered",
"error", fmt.Sprintf("%v", err),
"stack", string(debug.Stack()),
"path", r.URL.Path,
)
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
}
func RateLimit(rps float64, burst int) Middleware {
limiter := rate.NewLimiter(rate.Limit(rps), burst)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, `{"error":"rate limit exceeded"}`, http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
Your middleware stack is ready. Next up, you will wire everything together in main.go and see the full API respond to real requests.
Wire It All Together with Graceful Shutdown
You have a database layer, a service layer, a handler layer, and a middleware stack. Each one works in isolation, but none of them can serve a single HTTP request yet.
In this step, you will create main.go as the composition root. This single file wires every layer together, configures production-safe server timeouts, and implements graceful shutdown so in-flight requests finish before the server stops.
In this step, get ready to:
- Wire all dependency layers together in the composition root.
- Configure production server timeouts and graceful shutdown.
- Test the complete API with curl.
Build the composition root
The composition root is where all your independent layers meet. It creates one instance of each dependency and connects them in the correct order: database first, then repository, then service, then handler.
- Create the file cmd/api/main.go by running these commands:
mkdir -p cmd/api
touch cmd/api/main.go
- Confirm the file exists by running:
ls cmd/api/
You should see main.go in the output.
- Open cmd/api/main.go in your editor and paste the following code:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"bookstore/internal/database"
"bookstore/internal/handler"
"bookstore/internal/middleware"
"bookstore/internal/repository"
"bookstore/internal/service"
)
func main() {
// Initialize structured JSON logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Initialize database
db, err := database.New("bookstore.db")
if err != nil {
logger.Error("failed to initialize database", "error", err)
os.Exit(1)
}
defer db.Close()
// Initialize layers
bookRepo := repository.NewBookRepository(db)
bookService := service.NewBookService(bookRepo)
bookHandler := handler.NewBookHandler(bookService)
// Setup router
mux := http.NewServeMux()
// Health check
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
// Book routes
mux.HandleFunc("GET /api/books", bookHandler.GetAll)
mux.HandleFunc("GET /api/books/{id}", bookHandler.GetByID)
mux.HandleFunc("POST /api/books", bookHandler.Create)
mux.HandleFunc("PUT /api/books/{id}", bookHandler.Update)
mux.HandleFunc("DELETE /api/books/{id}", bookHandler.Delete)
// Apply middleware stack
stack := middleware.Chain(
middleware.Recovery(logger),
middleware.Logging(logger),
middleware.RateLimit(100, 200),
)
}
What does this code do?
- The logger uses slog.NewJSONHandler to output structured JSON logs. Production systems parse JSON logs automatically for monitoring dashboards.
- database.New("bookstore.db") calls the database package you built earlier. It opens SQLite with WAL mode and returns a ready connection.
- Each layer is created in dependency order: repository needs the DB, service needs the repository, handler needs the service.
- The GET /healthz route is a standard health check endpoint that monitoring tools (like Kubernetes) use to verify your server is alive.
- middleware.Chain wraps Recovery outermost (catches all panics), then Logging (records every request), then RateLimit at 100 requests per second with a burst of 200.
You won't see a change yet. The next chunk adds the server configuration and completes the function.
Configure production timeouts and graceful shutdown
A production HTTP server needs timeouts to prevent slow clients from holding connections open forever. It also needs graceful shutdown so that when you press Ctrl+C, any request mid-flight gets a chance to finish instead of being dropped.
- In cmd/api/main.go, replace the closing } of func main() with the following code so it becomes the rest of the function:
// Configure server with production timeouts
server := &http.Server{
Addr: ":8080",
Handler: stack(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Start server in goroutine
go func() {
logger.Info("server starting", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed to start", "error", err)
os.Exit(1)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Graceful shutdown with 30-second timeout
logger.Info("server shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("server forced to shutdown", "error", err)
}
logger.Info("server stopped")
}
What does this code do?
- ReadTimeout (5s) limits how long the server waits for a client to send the full request. This prevents slowloris attacks.
- WriteTimeout (10s) limits how long the server takes to write a response. Stuck handlers won't hold connections forever.
- IdleTimeout (120s) keeps connections alive for reuse between requests. Keep-alive connections reduce TCP handshake overhead.
- The server starts in a goroutine so the main function can block on the signal channel. When you press Ctrl+C, SIGINT arrives on the quit channel.
- server.Shutdown(ctx) stops accepting new connections and waits up to 30 seconds for active requests to finish. After that, it forces a close.
Why graceful shutdown matters
Without graceful shutdown, pressing Ctrl+C kills the process immediately. Any client mid-request gets a broken connection.
In production, container orchestrators send SIGTERM before removing a pod from the load balancer. Graceful shutdown gives those in-flight requests time to complete.
- Save cmd/api/main.go.
- Verify the code compiles by running:
go build ./cmd/api
You should see no output if the build succeeds. If you see errors, check your imports and braces match the full code below.
Seeing an import error?
- Make sure every import path starts with bookstore/internal/ matching your module name in go.mod.
- Check that go.mod says module bookstore on its first line.
- Run go mod tidy to resolve any missing indirect dependencies.
Help me debug this build error
✔️ Awesome, I've got everything!
Great. Double check you have saved cmd/api/main.go.
ⓧ I'd like to double check the full code
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"bookstore/internal/database"
"bookstore/internal/handler"
"bookstore/internal/middleware"
"bookstore/internal/repository"
"bookstore/internal/service"
)
func main() {
// Initialize structured JSON logger
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
// Initialize database
db, err := database.New("bookstore.db")
if err != nil {
logger.Error("failed to initialize database", "error", err)
os.Exit(1)
}
defer db.Close()
// Initialize layers
bookRepo := repository.NewBookRepository(db)
bookService := service.NewBookService(bookRepo)
bookHandler := handler.NewBookHandler(bookService)
// Setup router
mux := http.NewServeMux()
// Health check
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
// Book routes
mux.HandleFunc("GET /api/books", bookHandler.GetAll)
mux.HandleFunc("GET /api/books/{id}", bookHandler.GetByID)
mux.HandleFunc("POST /api/books", bookHandler.Create)
mux.HandleFunc("PUT /api/books/{id}", bookHandler.Update)
mux.HandleFunc("DELETE /api/books/{id}", bookHandler.Delete)
// Apply middleware stack
stack := middleware.Chain(
middleware.Recovery(logger),
middleware.Logging(logger),
middleware.RateLimit(100, 200),
)
// Configure server with production timeouts
server := &http.Server{
Addr: ":8080",
Handler: stack(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Start server in goroutine
go func() {
logger.Info("server starting", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server failed to start", "error", err)
os.Exit(1)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
// Graceful shutdown with 30-second timeout
logger.Info("server shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("server forced to shutdown", "error", err)
}
logger.Info("server stopped")
}
Run and test the complete API
Your API is ready to serve requests. Time to start it and verify every endpoint works end-to-end.
- Start the server by running:
go run ./cmd/api
You should see a JSON log line containing "msg":"server starting" and "addr":":8080".
Port already in use?
- Another process might be using port 8080. On macOS/Linux, run lsof -i :8080 to find it. On Windows, run netstat -ano | findstr :8080.
- Kill the conflicting process, then run go run ./cmd/api again.
Help me free up port 8080
- Open a second terminal window. Keep the server running in the first one.
- Test the health check endpoint by running:
curl http://localhost:8080/healthz
You should see {"status":"ok"} in the response.
- Create a book by running:
curl -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"The Go Programming Language","author":"Donovan & Kernighan","isbn":"978-0134190440","published_year":2015}'
You should see a JSON response with the book data including an id, created_at, and updated_at timestamp.
- List all books by running:
curl http://localhost:8080/api/books
You should see a JSON array containing the book you just created.
- Get a single book by running:
curl http://localhost:8080/api/books/1
You should see the full book object for ID 1.
- Update the book by running:
curl -X PUT http://localhost:8080/api/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"The Go Programming Language (Updated)"}'
You should see the book returned with the updated title and a new updated_at timestamp.
- Delete the book by running:
curl -X DELETE http://localhost:8080/api/books/1 -v
You should see a 204 No Content status in the verbose output. That confirms the delete worked.
- Switch back to the first terminal and press Ctrl+C to stop the server.
You should see a JSON log line with "msg":"server shutting down gracefully..." followed by "msg":"server stopped".
What about the structured logs?
In your server terminal, you should also see JSON log lines for every curl request you made. Each line includes the method, path, status code, and duration in milliseconds. This is the logging middleware you built in the previous step doing its job.
Getting 404 on all routes?
- Make sure your route patterns include the HTTP method prefix like "GET /api/books". Without the method, Go's ServeMux won't match method-based patterns.
- Check that the server is actually running on port 8080. Look for the server starting log line in the first terminal.
Help me debug my 404 routes
Your production-ready REST API is fully operational. Every layer is connected, requests are logged, panics are caught, rate limiting is active, and shutdown is graceful. Next up, you will extend this API with pagination and filtering in the Secret Mission.
Secret mission
Add Pagination and Filtering
Your API returns every book in the database on every request. That works with 10 books, but what about 10,000? Real production APIs need pagination and filtering to handle large datasets without crushing memory or bandwidth.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The bookstore project folder on your Desktop (contains all source code, the SQLite database, and Go module files).
- Go module cache entries for modernc.org/sqlite and golang.org/x/time (stored globally in your Go module cache).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the API further.
- Your project folder stays on the Desktop, ready for future development.
- The bookstore.db file retains all the book records you created during the project.
- The Go module cache keeps your downloaded dependencies for fast future builds.
Pause - I'll come back to this later
Shut down any running processes to free up memory. Your files stay intact for when you return.
- If your API server is still running in a terminal window, press Ctrl+C to stop it.
- Close any terminal windows you opened for this project.
- Your bookstore folder and all its files remain on your Desktop.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- If your API server is still running, press Ctrl+C to stop it first.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/bookstore
On Windows, run this in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\bookstore
- To clear the Go module cache entries (this removes cached modules for ALL Go projects, not just this one):
go clean -modcache
Selective cleanup
If you have other Go projects, skip the go clean -modcache step. It removes all cached modules, not just the ones from this project. The cached modules only use a small amount of disk space.
Nice Work!
Nice Work!
You just built a production-grade Go REST API from scratch. This is the same architecture and set of patterns used in high-traffic Go services at companies like Cloudflare and Stripe.
You learned how to:
- Build a full CRUD REST API using Go 1.22+ enhanced routing with method-based patterns and path parameters.
- Create a production middleware stack with structured JSON logging, panic recovery, and token bucket rate limiting.
- Implement layered architecture (handler/service/repository) with SQLite WAL mode, graceful shutdown, and a health check endpoint.
- Secret Mission: Add pagination and filtering to handle large datasets at scale with query parameters, LIMIT/OFFSET, and response metadata.
Ready to quiz yourself?