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.
The Trap: Choosing Speed Over Understanding
Imagine you need to build a notes application. You search for “Go REST API tutorial” and find a framework that handles everything. Authentication, validation, database queries, HTTP routing, serialization. Everything wrapped in decorators and middleware. You follow the tutorial. In two hours, you have a working API.
It works. But you do not understand how it works.
Six months later, you need to change the database from PostgreSQL to SQLite. You discover your business logic is tangled with your database queries. You need to change the database from SQLite to S3. Same problem. You need to write unit tests that do not touch the database. You realize your domain logic imports your HTTP handler. You cannot test it in isolation.
At that moment, you understand: speed at the start costs you for years afterward.
There is a better approach. It takes slightly longer to write. But everything is explicit. Everything is testable. Everything is yours — no magic hiding costs. This guide shows you how to build a real notes API that way.
What We Are Building
A simple but realistic notes application with these features:
- Create, read, update, delete notes
- List notes with filtering
- Search notes by title or content
- Persistence in SQLite
- RESTful API served by Chi
But more importantly, we are building it with clarity. The business logic is separate from the database. The HTTP handler does not know SQL. The domain knows nothing about HTTP. Everything is explicit. No framework magic.
Part 1: The Domain Layer — Where Business Logic Lives
The domain layer contains the core concepts of your application, written in the language of the business. Not the language of databases or HTTP. The language of notes.
Defining the Note
In your notes app, the central concept is a Note. What is a Note in your business language?
A Note is an idea worth keeping. It has an ID (to find it later), a title (to identify it quickly), content (the actual idea), and timestamps (to know when it was created and changed).
Here is what that looks like in code — notice there is no sql tag, no HTTP handler, no serialization logic. Just the domain:
// Domain layer: domain/note.go
package domain
import "time"
// Note represents an idea that a user wants to keep.
// All the business rules about a note live here.
type Note struct {
ID string
Title string
Content string
CreatedAt time.Time
UpdatedAt time.Time
}
// NewNote creates a note with validation.
// The domain enforces its own rules.
func NewNote(title, content string) (*Note, error) {
if len(title) == 0 {
return nil, ErrTitleRequired
}
if len(content) == 0 {
return nil, ErrContentRequired
}
return &Note{
Title: title,
Content: content,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// Update changes the note's content and timestamp.
func (n *Note) Update(title, content string) error {
if len(title) == 0 {
return ErrTitleRequired
}
if len(content) == 0 {
return ErrContentRequired
}
n.Title = title
n.Content = content
n.UpdatedAt = time.Now()
return nil
}
Notice what happened. The Note type has validation logic. It has methods that enforce the business rule — a note always has a title and content, and when it is updated, the timestamp changes. The domain owns its own rules.
This is different from a struct with fields. This is a type that knows what it means to be a Note.
Defining the Repository Interface
Now you need to persist notes. In the domain, you do not care how. SQLite today, PostgreSQL tomorrow, maybe even an in-memory store for tests. You define an interface — a contract — that says “whatever persists notes must be able to do these things”:
// Domain layer: domain/repository.go
package domain
import "context"
// NoteRepository defines the contract for persisting and retrieving notes.
// The domain defines what it needs. The infrastructure implements it.
type NoteRepository interface {
// Save stores a note. If it exists, it updates. If not, it creates.
Save(ctx context.Context, note *Note) error
// FindByID retrieves a note by its ID.
FindByID(ctx context.Context, id string) (*Note, error)
// FindAll retrieves all notes.
FindAll(ctx context.Context) ([]*Note, error)
// Delete removes a note.
Delete(ctx context.Context, id string) error
// Search finds notes whose title or content contains the query.
Search(ctx context.Context, query string) ([]*Note, error)
}
This interface lives in the domain layer. It speaks the language of notes, not databases. The infrastructure layer will implement it using SQLite. The HTTP layer will never touch it directly. The domain — the business logic — talks through this interface.
Part 2: The Application Layer — Orchestrating the Domain
The application layer sits between the HTTP handler and the domain. It orchestrates business operations. It calls domain methods, uses repositories, and returns results to the HTTP layer. It is not business logic. It is choreography.
// Application layer: app/create_note.go
package app
import (
"context"
"github.com/yourname/notes/domain"
)
// CreateNoteCommand represents the request to create a note.
type CreateNoteCommand struct {
Title string
Content string
}
// CreateNoteHandler orchestrates note creation.
type CreateNoteHandler struct {
repo domain.NoteRepository
}
// NewCreateNoteHandler constructs the handler.
func NewCreateNoteHandler(repo domain.NoteRepository) *CreateNoteHandler {
return &CreateNoteHandler{repo: repo}
}
// Handle executes the command.
func (h *CreateNoteHandler) Handle(ctx context.Context, cmd CreateNoteCommand) (*domain.Note, error) {
// The domain creates and validates the note.
note, err := domain.NewNote(cmd.Title, cmd.Content)
if err != nil {
return nil, err // Domain validation failed.
}
// Generate an ID (usually you do this before saving, or let the DB generate it).
note.ID = domain.GenerateID()
// Persist through the repository.
if err := h.repo.Save(ctx, note); err != nil {
return nil, err
}
return note, nil
}
Notice what is missing. The handler does not touch HTTP. It does not know about databases. It receives a command (the user’s intention), orchestrates the domain, and returns the result. Clean separation.
Part 3: The Infrastructure Layer — Making It Real
Now we implement the repository using SQLite. This is where SQL lives. Only here.
// Infrastructure layer: infra/sqlite_note_repository.go
package infra
import (
"context"
"database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3"
"github.com/yourname/notes/domain"
)
type SQLiteNoteRepository struct {
db *sql.DB
}
func NewSQLiteNoteRepository(db *sql.DB) *SQLiteNoteRepository {
return &SQLiteNoteRepository{db: db}
}
func (r *SQLiteNoteRepository) Save(ctx context.Context, note *domain.Note) error {
const query = `
INSERT INTO notes (id, title, content, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET title = ?, content = ?, updated_at = ?
`
_, err := r.db.ExecContext(
ctx, query,
note.ID, note.Title, note.Content, note.CreatedAt, note.UpdatedAt,
note.Title, note.Content, note.UpdatedAt,
)
return err
}
func (r *SQLiteNoteRepository) FindByID(ctx context.Context, id string) (*domain.Note, error) {
const query = `SELECT id, title, content, created_at, updated_at FROM notes WHERE id = ?`
var note domain.Note
err := r.db.QueryRowContext(ctx, query, id).Scan(
¬e.ID, ¬e.Title, ¬e.Content, ¬e.CreatedAt, ¬e.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, domain.ErrNotFound
}
if err != nil {
return nil, err
}
return ¬e, nil
}
func (r *SQLiteNoteRepository) FindAll(ctx context.Context) ([]*domain.Note, error) {
const query = `SELECT id, title, content, created_at, updated_at FROM notes ORDER BY updated_at DESC`
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*domain.Note
for rows.Next() {
var note domain.Note
if err := rows.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.CreatedAt, ¬e.UpdatedAt); err != nil {
return nil, err
}
notes = append(notes, ¬e)
}
return notes, rows.Err()
}
func (r *SQLiteNoteRepository) Delete(ctx context.Context, id string) error {
const query = `DELETE FROM notes WHERE id = ?`
_, err := r.db.ExecContext(ctx, query, id)
return err
}
func (r *SQLiteNoteRepository) Search(ctx context.Context, query string) ([]*domain.Note, error) {
const sql = `
SELECT id, title, content, created_at, updated_at
FROM notes
WHERE title LIKE ? OR content LIKE ?
ORDER BY updated_at DESC
`
pattern := "%" + query + "%"
rows, err := r.db.QueryContext(ctx, sql, pattern, pattern)
if err != nil {
return nil, err
}
defer rows.Close()
var notes []*domain.Note
for rows.Next() {
var note domain.Note
if err := rows.Scan(¬e.ID, ¬e.Title, ¬e.Content, ¬e.CreatedAt, ¬e.UpdatedAt); err != nil {
return nil, err
}
notes = append(notes, ¬e)
}
return notes, rows.Err()
}
This is straightforward SQL with database/sql — the standard library. No ORM. No magic. Every query is visible. You can read it and understand exactly what is happening. The repository implements the domain’s interface, so the domain never knows this code exists. Swapping SQLite for PostgreSQL means rewriting this file. Everything else stays the same.
Part 4: The HTTP Layer — Exposing the API
Chi is a lightweight HTTP router. It does not hide logic. It just routes requests and serves responses.
// HTTP layer: http/handler.go
package http
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/yourname/notes/app"
"github.com/yourname/notes/domain"
)
// NoteResponse is what the API returns.
type NoteResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// Handler groups all note operations.
type Handler struct {
createHandler *app.CreateNoteHandler
getHandler *app.GetNoteHandler
listHandler *app.ListNotesHandler
updateHandler *app.UpdateNoteHandler
deleteHandler *app.DeleteNoteHandler
searchHandler *app.SearchNotesHandler
}
// NewHandler constructs the handler.
func NewHandler(
createHandler *app.CreateNoteHandler,
getHandler *app.GetNoteHandler,
listHandler *app.ListNotesHandler,
updateHandler *app.UpdateNoteHandler,
deleteHandler *app.DeleteNoteHandler,
searchHandler *app.SearchNotesHandler,
) *Handler {
return &Handler{
createHandler: createHandler,
getHandler: getHandler,
listHandler: listHandler,
updateHandler: updateHandler,
deleteHandler: deleteHandler,
searchHandler: searchHandler,
}
}
// RegisterRoutes registers all routes with Chi.
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/notes", h.CreateNote)
r.Get("/notes/{id}", h.GetNote)
r.Get("/notes", h.ListNotes)
r.Put("/notes/{id}", h.UpdateNote)
r.Delete("/notes/{id}", h.DeleteNote)
r.Get("/notes/search", h.SearchNotes)
}
// CreateNote handles POST /notes.
func (h *Handler) CreateNote(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
cmd := app.CreateNoteCommand{
Title: req.Title,
Content: req.Content,
}
note, err := h.createHandler.Handle(r.Context(), cmd)
if err == domain.ErrTitleRequired || err == domain.ErrContentRequired {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(noteToResponse(note))
}
// GetNote handles GET /notes/{id}.
func (h *Handler) GetNote(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
note, err := h.getHandler.Handle(r.Context(), id)
if err == domain.ErrNotFound {
http.Error(w, "note not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(noteToResponse(note))
}
// ListNotes handles GET /notes.
func (h *Handler) ListNotes(w http.ResponseWriter, r *http.Request) {
notes, err := h.listHandler.Handle(r.Context())
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notesToResponses(notes))
}
// SearchNotes handles GET /notes/search?q=query.
func (h *Handler) SearchNotes(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
notes, err := h.searchHandler.Handle(r.Context(), query)
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(notesToResponses(notes))
}
// UpdateNote handles PUT /notes/{id}.
func (h *Handler) UpdateNote(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
cmd := app.UpdateNoteCommand{
ID: id,
Title: req.Title,
Content: req.Content,
}
note, err := h.updateHandler.Handle(r.Context(), cmd)
if err == domain.ErrNotFound {
http.Error(w, "note not found", http.StatusNotFound)
return
}
if err == domain.ErrTitleRequired || err == domain.ErrContentRequired {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(noteToResponse(note))
}
// DeleteNote handles DELETE /notes/{id}.
func (h *Handler) DeleteNote(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.deleteHandler.Handle(r.Context(), id); err == domain.ErrNotFound {
http.Error(w, "note not found", http.StatusNotFound)
return
} else if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func noteToResponse(note *domain.Note) NoteResponse {
return NoteResponse{
ID: note.ID,
Title: note.Title,
Content: note.Content,
CreatedAt: note.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: note.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func notesToResponses(notes []*domain.Note) []NoteResponse {
resp := make([]NoteResponse, len(notes))
for i, note := range notes {
resp[i] = noteToResponse(note)
}
return resp
}
The HTTP handler knows nothing about the database. It does not import the SQLite implementation. It receives application handlers as dependencies — all injected by the composition root. When a request comes in, it parses the input, calls the application handler, translates errors to HTTP status codes, and returns JSON.
Part 5: Composition — Wiring It All Together
The main function wires all these layers together. This is where dependencies flow inward.
// main.go
package main
import (
"database/sql"
"log"
"net/http"
_ "github.com/mattn/go-sqlite3"
"github.com/go-chi/chi/v5"
"github.com/yourname/notes/app"
"github.com/yourname/notes/infra"
httphandlers "github.com/yourname/notes/http"
)
func main() {
// 1. Set up the database.
db, err := sql.Open("sqlite3", ":memory:") // or "notes.db" for persistent storage
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create the table.
if err := createSchema(db); err != nil {
log.Fatal(err)
}
// 2. Create the repository (infrastructure layer).
noteRepo := infra.NewSQLiteNoteRepository(db)
// 3. Create application handlers.
createHandler := app.NewCreateNoteHandler(noteRepo)
getHandler := app.NewGetNoteHandler(noteRepo)
listHandler := app.NewListNotesHandler(noteRepo)
updateHandler := app.NewUpdateNoteHandler(noteRepo)
deleteHandler := app.NewDeleteNoteHandler(noteRepo)
searchHandler := app.NewSearchNotesHandler(noteRepo)
// 4. Create the HTTP handler.
handler := httphandlers.NewHandler(
createHandler,
getHandler,
listHandler,
updateHandler,
deleteHandler,
searchHandler,
)
// 5. Set up the router.
router := chi.NewRouter()
handler.RegisterRoutes(router)
// 6. Start the server.
log.Println("Server running on :8080")
if err := http.ListenAndServe(":8080", router); err != nil {
log.Fatal(err)
}
}
func createSchema(db *sql.DB) error {
const schema = `
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_updated_at ON notes(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_search ON notes(title, content);
`
_, err := db.Exec(schema)
return err
}
Notice what happened. The database is created. The repository is created. The application handlers are created and injected with the repository. The HTTP handler is created with the application handlers. Everything flows inward. The outer layers depend on the inner layers, never the other way around.
Part 6: Testing — Why This Architecture Matters
Now you understand why all of this matters. Testing is simple.
// app/create_note_test.go
package app
import (
"context"
"testing"
"github.com/yourname/notes/domain"
)
// MockRepository is a fake implementation for testing.
type MockRepository struct {
saved map[string]*domain.Note
}
func NewMockRepository() *MockRepository {
return &MockRepository{saved: make(map[string]*domain.Note)}
}
func (m *MockRepository) Save(ctx context.Context, note *domain.Note) error {
m.saved[note.ID] = note
return nil
}
func (m *MockRepository) FindByID(ctx context.Context, id string) (*domain.Note, error) {
if note, ok := m.saved[id]; ok {
return note, nil
}
return nil, domain.ErrNotFound
}
func (m *MockRepository) FindAll(ctx context.Context) ([]*domain.Note, error) {
notes := make([]*domain.Note, 0, len(m.saved))
for _, note := range m.saved {
notes = append(notes, note)
}
return notes, nil
}
func (m *MockRepository) Delete(ctx context.Context, id string) error {
delete(m.saved, id)
return nil
}
func (m *MockRepository) Search(ctx context.Context, query string) ([]*domain.Note, error) {
return nil, nil // Simplified for this example
}
// Test that the handler creates a note with the correct data.
func TestCreateNoteHandler(t *testing.T) {
repo := NewMockRepository()
handler := NewCreateNoteHandler(repo)
cmd := CreateNoteCommand{
Title: "Test Note",
Content: "This is a test",
}
note, err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if note.Title != cmd.Title || note.Content != cmd.Content {
t.Fatal("note data mismatch")
}
// Verify the note was saved.
saved, err := repo.FindByID(context.Background(), note.ID)
if err != nil {
t.Fatalf("expected note to be saved, got error: %v", err)
}
if saved.ID != note.ID {
t.Fatal("saved note ID mismatch")
}
}
You do not need a database for testing. You inject a mock. You test the application logic in isolation. It is fast. It is reliable. It does not depend on external state.
Why This Approach? The Trade-Offs
No magic means everything is explicit. You can read the code and understand what it does. When something breaks, you know where to look.
Testable means you can write tests without mocking everything. The domain layer has no external dependencies. The application layer is tested with a mock repository. The repository is tested separately with a real database or a mock connection.
Flexible means you can change the database without changing the domain or application layers. You can change the HTTP framework without changing the domain. Your business logic is truly separate.
The trade-off is more code. A framework might give you the same API in half the files. But those files would be more complex, harder to test, and harder to change. You have chosen clarity and flexibility over brevity.
Building It: The Practical Steps
- Create the domain: the Note type and the NoteRepository interface.
- Create the application handlers: CreateNote, GetNote, ListNotes, UpdateNote, DeleteNote, SearchNotes.
- Create the SQLite repository implementing NoteRepository.
- Create the HTTP handlers in Chi.
- Wire it all together in main().
- Test each layer independently.
That is a complete REST API. No framework magic. No hidden dependencies. Everything explicit. Changeable. Testable.
When to Use This Approach
Use this architecture when:
- You expect the project to live for years, not weeks.
- You will need to change the database or other infrastructure.
- Your domain logic is complex enough to deserve protection.
- You need to write tests that do not depend on external systems.
- Your team values clarity over brevity.
Do not use this architecture when:
- You are prototyping and speed is the only goal.
- The API is so simple that the domain layer adds no value.
- You are learning Go for the first time and need to stay focused.
For a real notes app that will evolve, that will scale, that will be maintained for years? This is exactly right.
The clearest code is not the shortest code. It is the code that shows you what is actually happening. Chi and SQLite do not hide anything. Hexagonal Architecture shows you where everything belongs. That clarity is worth the extra files.