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.
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
Dockerfilewith no CGO,distrolessfinal 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:
- Domain β the
Notetype, its validation rules, its errors. It imports nothing external. - Application β orchestrates the domain: create, find, update. Defines the outbound port (
NoteRepository) it needs, without knowing who implements it. - Inbound adapter β Gin translates HTTP requests into calls to the application.
- Outbound adapter β SQLite implements
NoteRepositorywith 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 ¬eService{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 avoidsdatabase is lockederrors 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/slogfor structured JSON logs, no external dependency.signal.NotifyContextturns operating system signals (Ctrl+C,SIGTERM) into acontext.Contextthat cancels itself β so the server shuts down cleanly, without leaving connections half-open.ReadHeaderTimeouton thehttp.Serverβ a single line that stops basic slowloris attacks. Skip it andgo vetwill 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βsFTS5. - Add authentication with a Gin middleware that validates a JWT before the request reaches the handler β without touching the domain or the application.
- Replace
sqlitewithpostgresby implementing a second adapter that satisfies the samedomain.NoteRepositoryinterface. 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.
Related articles
By relevance
Building a Notes API in Go: Chi, SQLite, DDD & Hexagonal Architecture Without Magic
Learn to build a production-ready RESTful notes API in Go using Chi router, SQLite, and Hexagonal Architecture. No frameworks hiding complexity, just clear domains and explicit dependencies.
Go Notes API: BDD, TDD, DDD, Hexagonal Architecture with Gin, gRPC, Redis & PostgreSQL
Build a production-ready Notes API in Go 1.24+ using BDD, TDD, DDD, Hexagonal Architecture, Gin, gRPC, Redis, PostgreSQL, JWT auth, Docker, FX, and Zap. Step by step.
Go 1.25 Medical API: DDD, TDD, BDD, PostgreSQL, Redis β Complete Clinical System
Build a production-ready medical records API in Go 1.25 with DDD, TDD, BDD, Gherkin scenarios, PostgreSQL, Redis. Full patient history, clinical boards, and every file explained.
Go 1.25 Production API: DDD, TDD, BDD, PostgreSQL, Redis β Complete Project Setup
Build a production-ready Go 1.25 REST API from scratch. Full DDD architecture, TDD/BDD testing, PostgreSQL, Redis caching, Chi router, and every file explained with clean code patterns.