Go 1.27, Gin & SQLite: Notes API with Hexagonal Architecture

Go 1.27, Gin & SQLite: Notes API with Hexagonal Architecture

A step-by-step guide to building a REST notes API in Go 1.27 with Gin, SQLite and Hexagonal Architecture + DDD. Clean code, tests and Docker included.

By Omar Flores
Table of Contents

Why This Project

You don’t need a massive project to learn hexagonal architecture. You need a small, real, complete one β€” where every layer exists for a reason you can touch with your own hands.

This guide builds a notes API. Yes, notes again. But this time the point isn’t the app β€” it’s the path. You’re going to write every file yourself, in order, understanding why it exists before you create it. By the end you’ll have a complete REST API, running on SQLite without a single line of CGO, served with Gin, organized into domain, application and adapters, with real tests and a Dockerfile that works on the first try.

No magic frameworks. No ORMs hiding the SQL. No dependency you can’t explain. Go 1.27, the modern standard library (log/slog, cmp, context, errors.Is), and only two external packages: gin for HTTP and modernc.org/sqlite for persistence.


What We’re Building

A CRUD notes API with:

  • Create, read, list, update and delete notes
  • Mark notes as done (done)
  • Filter by text and by status, with simple pagination
  • SQLite persistence, embedded, no external server
  • Hexagonal Architecture: the domain has no idea Gin or SQLite exist
  • Domain tests and HTTP integration tests
  • A multi-stage Dockerfile with no CGO, distroless final image

Before you start:

go version
# go version go1.27.1 linux/amd64 (or newer)

Hexagonal Architecture in Five Minutes

The core idea: the domain (business rules) lives at the center, isolated. It doesn’t import anything from the outside. Everything else β€” HTTP, SQLite β€” is an adapter that connects to the domain through ports (interfaces).

graph LR
    subgraph "Inbound Adapter"
        A["Gin HTTP Handler"]
    end

    subgraph "Core"
        B["Inbound Port<br/>NoteService"]
        C["Application<br/>Use Cases"]
        D["Domain<br/>Note + Rules"]
        E["Outbound Port<br/>NoteRepository"]
    end

    subgraph "Outbound Adapter"
        F["SQLite Repository"]
    end

    A -->|"calls"| B
    B --> C
    C --> D
    C -->|"uses"| E
    E -.->|"implemented by"| F

Four layers, each with one job:

  1. Domain β€” the Note type, its validation rules, its errors. It imports nothing external.
  2. Application β€” orchestrates the domain: create, find, update. Defines the outbound port (NoteRepository) it needs, without knowing who implements it.
  3. Inbound adapter β€” Gin translates HTTP requests into calls to the application.
  4. Outbound adapter β€” SQLite implements NoteRepository with real SQL.

If tomorrow you swap SQLite for PostgreSQL, you rewrite one file. The domain and the application never notice. That’s the entire point of the exercise.


Setting Up the Project

Create the folder and the module:

mkdir notesapi && cd notesapi
go mod init notesapi

Install the two β€” and only two β€” external dependencies:

go get github.com/gin-gonic/gin@latest
go get modernc.org/sqlite@latest
go mod tidy

modernc.org/sqlite is a reimplementation of SQLite compiled to pure Go (transpiled from C, no CGO). This matters: it means you build the API without gcc, without musl-dev, without Docker headaches. It’s the β€œmodern” choice over the classic mattn/go-sqlite3, which does require CGO.

Create the folder structure:

mkdir -p cmd/api
mkdir -p internal/domain
mkdir -p internal/application
mkdir -p internal/adapters/httpapi
mkdir -p internal/adapters/sqlite
mkdir -p internal/config

By the end of the guide, the tree will look like this:

notesapi/
β”œβ”€β”€ cmd/
β”‚   └── api/
β”‚       └── main.go
β”œβ”€β”€ internal/
β”‚   β”œβ”€β”€ domain/
β”‚   β”‚   β”œβ”€β”€ note.go
β”‚   β”‚   β”œβ”€β”€ errors.go
β”‚   β”‚   β”œβ”€β”€ repository.go
β”‚   β”‚   └── note_test.go
β”‚   β”œβ”€β”€ application/
β”‚   β”‚   β”œβ”€β”€ ports.go
β”‚   β”‚   └── note_service.go
β”‚   β”œβ”€β”€ adapters/
β”‚   β”‚   β”œβ”€β”€ httpapi/
β”‚   β”‚   β”‚   β”œβ”€β”€ dto.go
β”‚   β”‚   β”‚   β”œβ”€β”€ handler.go
β”‚   β”‚   β”‚   β”œβ”€β”€ handler_test.go
β”‚   β”‚   β”‚   └── router.go
β”‚   β”‚   └── sqlite/
β”‚   β”‚       β”œβ”€β”€ db.go
β”‚   β”‚       └── note_repository.go
β”‚   └── config/
β”‚       └── config.go
β”œβ”€β”€ go.mod
β”œβ”€β”€ go.sum
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ docker-compose.yml
└── Makefile

Step 1: The Domain β€” Note and Its Rules

Always start at the center. Before thinking about HTTP or SQL, define what a note is in the language of the business.

internal/domain/errors.go

package domain

import "errors"

var (
	ErrEmptyTitle   = errors.New("title cannot be empty")
	ErrTitleTooLong = errors.New("title exceeds 120 characters")
	ErrNotFound     = errors.New("note not found")
)

internal/domain/note.go

package domain

import (
	"crypto/rand"
	"encoding/hex"
	"strings"
	"time"
)

type Note struct {
	ID        string
	Title     string
	Content   string
	Done      bool
	CreatedAt time.Time
	UpdatedAt time.Time
}

func NewNote(title, content string) (*Note, error) {
	title = strings.TrimSpace(title)
	if err := validateTitle(title); err != nil {
		return nil, err
	}

	now := time.Now().UTC()
	return &Note{
		ID:        newID(),
		Title:     title,
		Content:   strings.TrimSpace(content),
		CreatedAt: now,
		UpdatedAt: now,
	}, nil
}

func (n *Note) Rename(title, content string) error {
	title = strings.TrimSpace(title)
	if err := validateTitle(title); err != nil {
		return err
	}

	n.Title = title
	n.Content = strings.TrimSpace(content)
	n.UpdatedAt = time.Now().UTC()
	return nil
}

func (n *Note) SetDone(done bool) {
	n.Done = done
	n.UpdatedAt = time.Now().UTC()
}

func validateTitle(title string) error {
	if title == "" {
		return ErrEmptyTitle
	}
	if len(title) > 120 {
		return ErrTitleTooLong
	}
	return nil
}

func newID() string {
	buf := make([]byte, 12)
	_, _ = rand.Read(buf)
	return hex.EncodeToString(buf)
}

Notice what’s not here: no json:"...", no sql:"...", no import of Gin or database/sql. Note has no idea it’s going to travel over HTTP or land in a table. It only knows its own rules: a title can’t be empty, can’t exceed 120 characters, and every change bumps UpdatedAt.

The ID generator uses crypto/rand from the standard library β€” you don’t need an external UUID package for this.

internal/domain/repository.go β€” the outbound port

package domain

import "context"

type ListFilter struct {
	Query  string
	Done   *bool
	Limit  int
	Offset int
}

type NoteRepository interface {
	Save(ctx context.Context, note *Note) error
	FindByID(ctx context.Context, id string) (*Note, error)
	FindAll(ctx context.Context, filter ListFilter) ([]*Note, error)
	Delete(ctx context.Context, id string) error
}

This interface lives in the domain, but infrastructure implements it. It’s the contract: β€œwhoever wants to persist notes must be able to do this.” The domain defines the need; SQLite, later on, satisfies it.


Step 2: The Application β€” Use Cases

The application layer orchestrates. It doesn’t validate business rules (that’s already Note’s job), doesn’t know SQL, doesn’t know HTTP. It just coordinates: receives an intent, calls the domain, uses the repository.

internal/application/ports.go β€” the inbound port

package application

import (
	"context"

	"notesapi/internal/domain"
)

type NoteService interface {
	Create(ctx context.Context, title, content string) (*domain.Note, error)
	Get(ctx context.Context, id string) (*domain.Note, error)
	List(ctx context.Context, filter domain.ListFilter) ([]*domain.Note, error)
	Update(ctx context.Context, id, title, content string) (*domain.Note, error)
	SetDone(ctx context.Context, id string, done bool) (*domain.Note, error)
	Delete(ctx context.Context, id string) error
}

This is the port the HTTP adapter is going to call. Notice it returns domain types, not DTOs β€” the JSON translation happens later, in the adapter.

internal/application/note_service.go

package application

import (
	"context"
	"fmt"

	"notesapi/internal/domain"
)

type noteService struct {
	repo domain.NoteRepository
}

func NewNoteService(repo domain.NoteRepository) NoteService {
	return &noteService{repo: repo}
}

func (s *noteService) Create(ctx context.Context, title, content string) (*domain.Note, error) {
	note, err := domain.NewNote(title, content)
	if err != nil {
		return nil, err
	}

	if err := s.repo.Save(ctx, note); err != nil {
		return nil, fmt.Errorf("save note: %w", err)
	}
	return note, nil
}

func (s *noteService) Get(ctx context.Context, id string) (*domain.Note, error) {
	return s.repo.FindByID(ctx, id)
}

func (s *noteService) List(ctx context.Context, filter domain.ListFilter) ([]*domain.Note, error) {
	return s.repo.FindAll(ctx, filter)
}

func (s *noteService) Update(ctx context.Context, id, title, content string) (*domain.Note, error) {
	note, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}

	if err := note.Rename(title, content); err != nil {
		return nil, err
	}

	if err := s.repo.Save(ctx, note); err != nil {
		return nil, fmt.Errorf("save note: %w", err)
	}
	return note, nil
}

func (s *noteService) SetDone(ctx context.Context, id string, done bool) (*domain.Note, error) {
	note, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}

	note.SetDone(done)

	if err := s.repo.Save(ctx, note); err != nil {
		return nil, fmt.Errorf("save note: %w", err)
	}
	return note, nil
}

func (s *noteService) Delete(ctx context.Context, id string) error {
	return s.repo.Delete(ctx, id)
}

noteService receives domain.NoteRepository β€” the interface, not a concrete implementation. SQLite doesn’t exist in this layer yet, and it shouldn’t. That’s what makes this code testable without touching a real database: in a test, you can hand it anything that satisfies the interface.


Step 3: The SQLite Adapter β€” Real Persistence

Here’s where SQL finally shows up.

internal/adapters/sqlite/db.go

package sqlite

import (
	"database/sql"
	"fmt"

	_ "modernc.org/sqlite"
)

const schema = `
CREATE TABLE IF NOT EXISTS notes (
	id TEXT PRIMARY KEY,
	title TEXT NOT NULL,
	content TEXT NOT NULL DEFAULT '',
	done INTEGER NOT NULL DEFAULT 0,
	created_at DATETIME NOT NULL,
	updated_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes (updated_at DESC);
`

func Open(path string) (*sql.DB, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, fmt.Errorf("open sqlite: %w", err)
	}

	db.SetMaxOpenConns(1)

	if _, err := db.Exec("PRAGMA journal_mode = WAL;"); err != nil {
		db.Close()
		return nil, fmt.Errorf("enable wal: %w", err)
	}
	if _, err := db.Exec("PRAGMA busy_timeout = 5000;"); err != nil {
		db.Close()
		return nil, fmt.Errorf("set busy timeout: %w", err)
	}
	if _, err := db.Exec(schema); err != nil {
		db.Close()
		return nil, fmt.Errorf("migrate schema: %w", err)
	}

	return db, nil
}

Three decisions worth explaining:

  • db.SetMaxOpenConns(1) β€” SQLite only allows one writer at a time. Forcing a single connection avoids database is locked errors under concurrent load, and also makes :memory: behave predictably in tests (every new connection to :memory: is a separate, empty database; with a single connection, everything shares the same one).
  • PRAGMA journal_mode = WAL β€” Write-Ahead Logging mode, better read concurrency while writing.
  • PRAGMA busy_timeout = 5000 β€” if SQLite is busy, wait 5 seconds before failing, instead of failing immediately.

There’s no external migration tool. For a project this size, CREATE TABLE IF NOT EXISTS on startup is enough, and it doesn’t add one more dependency.

internal/adapters/sqlite/note_repository.go

package sqlite

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"

	"notesapi/internal/domain"
)

type NoteRepository struct {
	db *sql.DB
}

func NewNoteRepository(db *sql.DB) *NoteRepository {
	return &NoteRepository{db: db}
}

func (r *NoteRepository) Save(ctx context.Context, note *domain.Note) error {
	const query = `
		INSERT INTO notes (id, title, content, done, created_at, updated_at)
		VALUES (?, ?, ?, ?, ?, ?)
		ON CONFLICT (id) DO UPDATE SET
			title = excluded.title,
			content = excluded.content,
			done = excluded.done,
			updated_at = excluded.updated_at
	`

	_, err := r.db.ExecContext(ctx, query,
		note.ID, note.Title, note.Content, note.Done, note.CreatedAt, note.UpdatedAt,
	)
	if err != nil {
		return fmt.Errorf("save note: %w", err)
	}
	return nil
}

func (r *NoteRepository) FindByID(ctx context.Context, id string) (*domain.Note, error) {
	const query = `SELECT id, title, content, done, created_at, updated_at FROM notes WHERE id = ?`

	var n domain.Note
	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&n.ID, &n.Title, &n.Content, &n.Done, &n.CreatedAt, &n.UpdatedAt,
	)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, domain.ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find note: %w", err)
	}
	return &n, nil
}

func (r *NoteRepository) FindAll(ctx context.Context, filter domain.ListFilter) ([]*domain.Note, error) {
	var query strings.Builder
	query.WriteString(`SELECT id, title, content, done, created_at, updated_at FROM notes WHERE 1 = 1`)

	var args []any

	if filter.Query != "" {
		query.WriteString(` AND (title LIKE ? OR content LIKE ?)`)
		pattern := "%" + filter.Query + "%"
		args = append(args, pattern, pattern)
	}
	if filter.Done != nil {
		query.WriteString(` AND done = ?`)
		args = append(args, *filter.Done)
	}

	limit := filter.Limit
	if limit <= 0 {
		limit = 50
	}
	query.WriteString(` ORDER BY updated_at DESC LIMIT ? OFFSET ?`)
	args = append(args, limit, filter.Offset)

	rows, err := r.db.QueryContext(ctx, query.String(), args...)
	if err != nil {
		return nil, fmt.Errorf("list notes: %w", err)
	}
	defer rows.Close()

	notes := make([]*domain.Note, 0, limit)
	for rows.Next() {
		var n domain.Note
		if err := rows.Scan(&n.ID, &n.Title, &n.Content, &n.Done, &n.CreatedAt, &n.UpdatedAt); err != nil {
			return nil, fmt.Errorf("scan note: %w", err)
		}
		notes = append(notes, &n)
	}
	return notes, rows.Err()
}

func (r *NoteRepository) Delete(ctx context.Context, id string) error {
	const query = `DELETE FROM notes WHERE id = ?`

	res, err := r.db.ExecContext(ctx, query, id)
	if err != nil {
		return fmt.Errorf("delete note: %w", err)
	}

	affected, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("rows affected: %w", err)
	}
	if affected == 0 {
		return domain.ErrNotFound
	}
	return nil
}

Save does an UPSERT: if the id already exists, it updates; if not, it inserts. That lets you use the same method for both create and update, without duplicating SQL logic. SQLite has no native BOOLEAN type β€” it stores done as INTEGER (0 or 1), and database/sql converts it to bool transparently on Scan.

*NoteRepository implicitly implements domain.NoteRepository β€” there’s no explicit implements keyword in Go. The compiler checks it the moment you wire it into the service, in main.go.


Step 4: The HTTP Adapter β€” Gin Up Front

This adapter translates HTTP into application calls, and application results into JSON. No business logic here.

internal/adapters/httpapi/dto.go

package httpapi

import (
	"time"

	"notesapi/internal/domain"
)

type createNoteRequest struct {
	Title   string `json:"title" binding:"required"`
	Content string `json:"content"`
}

type updateNoteRequest struct {
	Title   string `json:"title" binding:"required"`
	Content string `json:"content"`
}

type setDoneRequest struct {
	Done bool `json:"done"`
}

type noteResponse struct {
	ID        string `json:"id"`
	Title     string `json:"title"`
	Content   string `json:"content"`
	Done      bool   `json:"done"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
}

func toNoteResponse(n *domain.Note) noteResponse {
	return noteResponse{
		ID:        n.ID,
		Title:     n.Title,
		Content:   n.Content,
		Done:      n.Done,
		CreatedAt: n.CreatedAt.Format(time.RFC3339),
		UpdatedAt: n.UpdatedAt.Format(time.RFC3339),
	}
}

DTOs live separately from the domain on purpose. If tomorrow you change the API’s date format, or add a computed field to the response, you touch this file β€” not domain.Note.

internal/adapters/httpapi/handler.go

package httpapi

import (
	"errors"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"

	"notesapi/internal/application"
	"notesapi/internal/domain"
)

type NoteHandler struct {
	service application.NoteService
}

func NewNoteHandler(service application.NoteService) *NoteHandler {
	return &NoteHandler{service: service}
}

func (h *NoteHandler) Create(c *gin.Context) {
	var req createNoteRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	note, err := h.service.Create(c.Request.Context(), req.Title, req.Content)
	if err != nil {
		respondError(c, err)
		return
	}

	c.JSON(http.StatusCreated, toNoteResponse(note))
}

func (h *NoteHandler) Get(c *gin.Context) {
	note, err := h.service.Get(c.Request.Context(), c.Param("id"))
	if err != nil {
		respondError(c, err)
		return
	}

	c.JSON(http.StatusOK, toNoteResponse(note))
}

func (h *NoteHandler) List(c *gin.Context) {
	filter := domain.ListFilter{
		Query:  c.Query("q"),
		Limit:  parseIntDefault(c.Query("limit"), 50),
		Offset: parseIntDefault(c.Query("offset"), 0),
	}

	if raw := c.Query("done"); raw != "" {
		done, err := strconv.ParseBool(raw)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "done must be true or false"})
			return
		}
		filter.Done = &done
	}

	notes, err := h.service.List(c.Request.Context(), filter)
	if err != nil {
		respondError(c, err)
		return
	}

	responses := make([]noteResponse, 0, len(notes))
	for _, n := range notes {
		responses = append(responses, toNoteResponse(n))
	}
	c.JSON(http.StatusOK, responses)
}

func (h *NoteHandler) Update(c *gin.Context) {
	var req updateNoteRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	note, err := h.service.Update(c.Request.Context(), c.Param("id"), req.Title, req.Content)
	if err != nil {
		respondError(c, err)
		return
	}

	c.JSON(http.StatusOK, toNoteResponse(note))
}

func (h *NoteHandler) SetDone(c *gin.Context) {
	var req setDoneRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	note, err := h.service.SetDone(c.Request.Context(), c.Param("id"), req.Done)
	if err != nil {
		respondError(c, err)
		return
	}

	c.JSON(http.StatusOK, toNoteResponse(note))
}

func (h *NoteHandler) Delete(c *gin.Context) {
	if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
		respondError(c, err)
		return
	}

	c.Status(http.StatusNoContent)
}

func respondError(c *gin.Context, err error) {
	switch {
	case errors.Is(err, domain.ErrNotFound):
		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
	case errors.Is(err, domain.ErrEmptyTitle), errors.Is(err, domain.ErrTitleTooLong):
		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
	default:
		c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
	}
}

func parseIntDefault(raw string, fallback int) int {
	if raw == "" {
		return fallback
	}
	value, err := strconv.Atoi(raw)
	if err != nil || value < 0 {
		return fallback
	}
	return value
}

respondError is the only place that translates domain errors into HTTP status codes. errors.Is works even if the error comes wrapped with fmt.Errorf("...: %w", err) from a lower layer β€” that’s why every layer below uses %w, never %v.

internal/adapters/httpapi/router.go

package httpapi

import (
	"net/http"

	"github.com/gin-gonic/gin"
)

func NewRouter(handler *NoteHandler) *gin.Engine {
	router := gin.New()
	router.Use(gin.Logger(), gin.Recovery())

	router.GET("/healthz", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	notes := router.Group("/api/v1/notes")
	{
		notes.POST("", handler.Create)
		notes.GET("", handler.List)
		notes.GET("/:id", handler.Get)
		notes.PUT("/:id", handler.Update)
		notes.PATCH("/:id/done", handler.SetDone)
		notes.DELETE("/:id", handler.Delete)
	}

	return router
}

Notice the package is called httpapi, not http β€” that avoids visually colliding with the standard net/http package, which is imported in the same file.


Step 5: Configuration Without the Drama

No Viper, no .yaml files for three environment variables.

internal/config/config.go

package config

import (
	"cmp"
	"os"
)

type Config struct {
	Port    string
	DBPath  string
	GinMode string
}

func Load() Config {
	return Config{
		Port:    cmp.Or(os.Getenv("PORT"), "8080"),
		DBPath:  cmp.Or(os.Getenv("DB_PATH"), "notes.db"),
		GinMode: cmp.Or(os.Getenv("GIN_MODE"), "release"),
	}
}

cmp.Or is genuinely modern Go: it returns the first non-zero value in a list. It replaces the classic if x == "" { x = default } repeated three times over.


Step 6: main.go β€” Wiring It All Together

This is where the domain, the application and the adapters meet for the first time. It’s the only layer that knows about all of them.

cmd/api/main.go

package main

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/gin-gonic/gin"

	"notesapi/internal/adapters/httpapi"
	"notesapi/internal/adapters/sqlite"
	"notesapi/internal/application"
	"notesapi/internal/config"
)

func main() {
	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))

	cfg := config.Load()

	db, err := sqlite.Open(cfg.DBPath)
	if err != nil {
		slog.Error("open database", "error", err)
		os.Exit(1)
	}
	defer db.Close()

	repo := sqlite.NewNoteRepository(db)
	service := application.NewNoteService(repo)
	handler := httpapi.NewNoteHandler(service)

	gin.SetMode(cfg.GinMode)
	router := httpapi.NewRouter(handler)

	server := &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           router,
		ReadHeaderTimeout: 5 * time.Second,
	}

	go func() {
		slog.Info("server listening", "port", cfg.Port)
		if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("server error", "error", err)
			os.Exit(1)
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()

	slog.Info("shutting down")

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	if err := server.Shutdown(shutdownCtx); err != nil {
		slog.Error("shutdown error", "error", err)
	}
}

Three modern touches worth calling out:

  • log/slog for structured JSON logs, no external dependency.
  • signal.NotifyContext turns operating system signals (Ctrl+C, SIGTERM) into a context.Context that cancels itself β€” so the server shuts down cleanly, without leaving connections half-open.
  • ReadHeaderTimeout on the http.Server β€” a single line that stops basic slowloris attacks. Skip it and go vet will even flag it for you.

Run the API:

go run ./cmd/api

Testing the API with curl

With the server up on :8080:

curl -X POST localhost:8080/api/v1/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Go 1.27","content":"Review slog and cmp.Or"}'
curl localhost:8080/api/v1/notes
curl localhost:8080/api/v1/notes/<id>
curl -X PUT localhost:8080/api/v1/notes/<id> \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Go 1.27 in depth","content":"Finished the generics chapter"}'
curl -X PATCH localhost:8080/api/v1/notes/<id>/done \
  -H "Content-Type: application/json" \
  -d '{"done":true}'
curl "localhost:8080/api/v1/notes?q=go&done=true&limit=10"
curl -X DELETE localhost:8080/api/v1/notes/<id> -i

Step 7: Tests β€” Trust But Verify

The full payoff of separating domain from adapters shows up here. Two kinds of test, two different speeds.

internal/domain/note_test.go β€” pure, no database, runs in microseconds

package domain

import (
	"errors"
	"strings"
	"testing"
)

func TestNewNote(t *testing.T) {
	tests := []struct {
		name    string
		title   string
		content string
		wantErr error
	}{
		{name: "valid note", title: "Buy coffee", content: "Before 9am"},
		{name: "empty title", title: "   ", content: "something", wantErr: ErrEmptyTitle},
		{name: "title too long", title: strings.Repeat("a", 121), wantErr: ErrTitleTooLong},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			note, err := NewNote(tt.title, tt.content)
			if !errors.Is(err, tt.wantErr) {
				t.Fatalf("NewNote() error = %v, want %v", err, tt.wantErr)
			}
			if tt.wantErr == nil && note.ID == "" {
				t.Fatal("expected generated ID")
			}
		})
	}
}

internal/adapters/httpapi/handler_test.go β€” real integration, in-memory SQLite, no mocks

package httpapi_test

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gin-gonic/gin"

	"notesapi/internal/adapters/httpapi"
	"notesapi/internal/adapters/sqlite"
	"notesapi/internal/application"
)

func newTestRouter(t *testing.T) *gin.Engine {
	t.Helper()

	db, err := sqlite.Open(":memory:")
	if err != nil {
		t.Fatalf("open test database: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	repo := sqlite.NewNoteRepository(db)
	service := application.NewNoteService(repo)
	handler := httpapi.NewNoteHandler(service)

	gin.SetMode(gin.TestMode)
	return httpapi.NewRouter(handler)
}

func TestCreateAndGetNote(t *testing.T) {
	router := newTestRouter(t)

	body, _ := json.Marshal(map[string]string{
		"title":   "Learn Go 1.27",
		"content": "Review generics and slog",
	})

	req := httptest.NewRequest(http.MethodPost, "/api/v1/notes", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	if rec.Code != http.StatusCreated {
		t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
	}

	var created map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
		t.Fatalf("decode response: %v", err)
	}

	getReq := httptest.NewRequest(http.MethodGet, "/api/v1/notes/"+created["id"].(string), nil)
	getRec := httptest.NewRecorder()
	router.ServeHTTP(getRec, getReq)

	if getRec.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", getRec.Code, getRec.Body.String())
	}
}

This second test uses sqlite.Open(":memory:") β€” the very same production function, pointing at an in-memory database. There are no repository mocks: the full router, with the real handler and the real repository, responds to a real HTTP request. It’s fast because in-memory SQLite is fast, and it’s honest because it tests the system exactly as it behaves in production.

Run everything:

go test ./...

Step 8: Docker β€” Bring It All Up With One Command

Since modernc.org/sqlite doesn’t use CGO, the Dockerfile doesn’t need gcc or musl-dev at any stage. That simplifies everything.

Dockerfile

FROM golang:1.27-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/notesapi ./cmd/api

FROM gcr.io/distroless/static-debian12
COPY --from=build /out/notesapi /notesapi
EXPOSE 8080
VOLUME ["/data"]
ENV DB_PATH=/data/notes.db
ENV GIN_MODE=release
ENTRYPOINT ["/notesapi"]

The final image is distroless/static: no shell, no package manager, nothing an attacker could use if the process gets compromised. Just the static binary.

docker-compose.yml

services:
  notesapi:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - notes-data:/data
    environment:
      PORT: "8080"
      DB_PATH: "/data/notes.db"
      GIN_MODE: "release"
    restart: unless-stopped

volumes:
  notes-data:

Makefile β€” shortcuts, not required but convenient

run:
	go run ./cmd/api

test:
	go test ./...

build:
	go build -o bin/notesapi ./cmd/api

up:
	docker compose up --build

down:
	docker compose down

Bring it all up:

docker compose up --build

The database lives in a named volume (notes-data), so it survives docker compose down and only disappears if you delete the volume explicitly.


Final Project Structure

notesapi/
β”œβ”€β”€ cmd/api/main.go
β”œβ”€β”€ internal/
β”‚   β”œβ”€β”€ domain/{note,errors,repository,note_test}.go
β”‚   β”œβ”€β”€ application/{ports,note_service}.go
β”‚   β”œβ”€β”€ adapters/
β”‚   β”‚   β”œβ”€β”€ httpapi/{dto,handler,handler_test,router}.go
β”‚   β”‚   └── sqlite/{db,note_repository}.go
β”‚   └── config/config.go
β”œβ”€β”€ go.mod
β”œβ”€β”€ go.sum
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ docker-compose.yml
└── Makefile

Every folder answers a different question: domain is β€œwhat is a note?”, application is β€œwhat can you do with notes?”, adapters/httpapi is β€œhow does the outside world get in?”, adapters/sqlite is β€œhow does it get saved?”


What You Got (And What’s Next)

You built a complete API where no layer knows more than it needs to. The domain has no idea Gin exists. The application has no idea SQLite exists. The domain test runs without a database; the HTTP test runs without mocks. And the whole project uses exactly two external dependencies.

From here, the natural next steps are yours to practice:

  • Swap the text filter (LIKE) for full-text search with SQLite’s FTS5.
  • Add authentication with a Gin middleware that validates a JWT before the request reaches the handler β€” without touching the domain or the application.
  • Replace sqlite with postgres by implementing a second adapter that satisfies the same domain.NoteRepository interface. If the previous exercise is done right, it’s the only new file you need to write.

That last test β€” swapping databases by touching a single file β€” is the real measure of whether you understood hexagonal architecture, or just copied folders with nice names.