Go 1.26 Full-Stack TODO: SolidJS + Embed + Hexagonal Architecture

Go 1.26 Full-Stack TODO: SolidJS + Embed + Hexagonal Architecture

Build a full-stack TODO app with Go 1.26, SolidJS, and go:embed. Hexagonal Architecture, DDD, TDD, BDD, SQLite, query params, PATCH, env vars, httpie. Zero to production.

By Omar Flores

Imagine a craftsman who builds furniture in a workshop that also serves as the showroom. The tools, the raw wood, the finished chairs, and the customers — everything exists under one roof. That is a self-contained monolith. The wood-working bench does not know about the customers, and the customers do not care how the saw works. Yet the building holds them together in a way that makes the whole thing ship as a single unit.

This post builds exactly that: a Go 1.26 server that compiles the entire application — backend API, frontend SPA, and database — into one binary. The backend is a clean REST API written with the standard library. The frontend is a SolidJS application compiled to static HTML, CSS, and JS. Go’s embed package bakes those static files into the binary at compile time, so deploying means copying a single executable.

Along the way, you will learn Hexagonal Architecture, Domain-Driven Design, Test-Driven Development, BDD-style test naming, SQLite persistence, full HTTP CRUD with optional query parameters and custom headers, partial updates with PATCH, environment variable configuration, and how to test every endpoint with httpie.

Nothing is assumed. Every folder creation, every file, every line of code is explained.


What we are building

A TODO application with:

  • A Go 1.26 backend exposing a JSON REST API
  • SQLite as the database (file-based, no server required)
  • A SolidJS frontend served directly from the Go binary
  • Hexagonal Architecture separating domain, application, and adapters
  • Full CRUD: GET /todos, POST /todos, GET /todos/{id}, PATCH /todos/{id}, DELETE /todos/{id}
  • Query parameters for filtering: ?completed=true&search=milk
  • Custom request and response headers: X-Request-ID, X-API-Version
  • Environment variable configuration with .env file support
  • Graceful shutdown using Go 1.26’s improved os/signal.NotifyContext
  • Tests written in BDD-style naming conventions

The final binary runs like this: ./server — and serves both the API and the frontend on the same port.


Part 1 — Setting up the environment

Before writing a single line of code, you need to understand the tools and confirm they are installed. This section covers everything from Go itself to the Node.js toolchain needed for SolidJS.

Installing Go 1.26

Go 1.26 was released in February 2026. Download it from the official site:

# Linux
wget https://go.dev/dl/go1.26.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.26.0.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin

Verify the installation:

go version
# go version go1.26.0 linux/amd64

Installing Node.js and npm

SolidJS requires Node.js to compile. Install version 20 LTS or later:

# Using nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 20
nvm use 20
node --version  # v20.x.x
npm --version   # 10.x.x

Installing httpie

httpie is a command-line HTTP client with a human-readable syntax. It is cleaner than curl for API testing:

# On Debian/Ubuntu
sudo apt install httpie

# On macOS
brew install httpie

# Verify
http --version

Part 2 — Creating the project

This section creates every folder and file in the project. Read each step carefully — the folder structure itself communicates the architecture.

Step 1: Initialize the Go module

Open a terminal and run:

mkdir go-fullstack-todo
cd go-fullstack-todo
go mod init github.com/yourname/go-fullstack-todo

The go mod init command creates a go.mod file. This file tracks the module name and its dependencies. With Go 1.26, running go mod init using the 1.26 toolchain will write go 1.25.0 as the minimum — this is intentional: Go 1.26 now defaults to N-1 so your module stays compatible with the previous stable release. You can change it with go get go@1.26 if you want to use 1.26-specific features explicitly.

Step 2: Create the folder structure

The folder structure follows Hexagonal Architecture. The domain and application layers are at the center. Adapters (HTTP, SQLite) live at the edges. The cmd/server package is the entry point that wires everything together.

Create every folder now:

mkdir -p cmd/server
mkdir -p domain
mkdir -p ports/input
mkdir -p ports/output
mkdir -p application
mkdir -p adapters/sqlite
mkdir -p adapters/http
mkdir -p web
mkdir -p config

After this, your project looks like:

go-fullstack-todo/
  cmd/server/         ← main.go lives here
  domain/             ← entities, value objects, sentinel errors
  ports/input/        ← interfaces for use cases (what the HTTP handler calls)
  ports/output/       ← interfaces for persistence (what the application calls)
  application/        ← use case implementations
  adapters/sqlite/    ← SQLite implementation of the output port
  adapters/http/      ← HTTP handler, DTOs, middleware
  web/                ← go:embed wrapper for the SolidJS dist
  config/             ← environment variable loader
  go.mod

This layout communicates something important: the domain/ and application/ folders do not import anything from adapters/. The arrows of dependency point inward, toward the domain. The domain knows nothing about SQLite, HTTP, or SolidJS. This is the core idea of Hexagonal Architecture.

Step 3: Install dependencies

Install all external packages at once:

go get modernc.org/sqlite
go get github.com/joho/godotenv

modernc.org/sqlite is a pure-Go port of SQLite. It requires no C compiler and no CGO, which means the binary compiles on any platform with a single go build command. This is important for deployment.

github.com/joho/godotenv reads a .env file and loads its key-value pairs into environment variables before your program logic runs. This is the standard approach for configuring development environments.

Verify both are in go.mod:

cat go.mod

Part 3 — Configuration and environment variables

A production application never has secrets or configuration baked into its source code. Ports, database paths, API keys — these change between environments (development, staging, production). The .env file pattern solves this for development without touching the code.

Think of environment variables as settings written on sticky notes and placed on the workshop door. The craftsman reads the notes each morning before starting work. The workshop itself does not know what the notes say — it just reads them and adjusts.

Create the .env file

Create a file named .env in the project root:

touch .env

Open it and add:

PORT=8080
DB_PATH=./todos.db
APP_ENV=development

This file must never be committed to version control. Create a .gitignore file:

touch .gitignore

Add these entries:

.env
*.db
dist/

Create the config package

Create the file config/config.go:

touch config/config.go

Open it and write:

package config

import (
	"log/slog"
	"os"

	"github.com/joho/godotenv"
)

// Config holds all runtime configuration read from environment variables.
// It is populated once at startup and passed down to every component.
// No component reads os.Getenv directly — they receive a Config value.
type Config struct {
	Port   string
	DBPath string
	Env    string
}

// Load reads the .env file (if present) and populates Config from environment
// variables. Defaults are applied for any variable that is not set.
// Call this once in main() before constructing any other component.
func Load() Config {
	// godotenv.Load reads .env and sets the values into the process environment.
	// If .env does not exist (as in production where env vars are set by the
	// platform), Load silently continues. This is intentional.
	if err := godotenv.Load(); err != nil {
		slog.Info("no .env file found, reading from environment")
	}

	return Config{
		Port:   getEnv("PORT", "8080"),
		DBPath: getEnv("DB_PATH", "./todos.db"),
		Env:    getEnv("APP_ENV", "production"),
	}
}

// getEnv returns the value of the environment variable named by key.
// If the variable is not set or is empty, it returns the fallback value.
func getEnv(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

The Config struct is the boundary. After Load() returns, nothing in your program reads os.Getenv again. This makes the application fully testable: in tests, you construct a Config directly with test values and pass it in.


Part 4 — The domain layer (DDD)

The domain layer contains the core business rules. It has no dependencies on databases, HTTP, or any external package. If you deleted every other folder in this project, the domain would still compile. That is the measure of its purity.

In Domain-Driven Design, the domain contains:

  • Entities — objects with identity (a Todo with an ID)
  • Value Objects — immutable objects defined by their value (a Title that must not be empty)
  • Sentinel errors — named error values that the application layer inspects

Create the sentinel errors

Sentinel errors are pre-defined error values that callers can compare against using errors.Is. They communicate the category of failure, not the implementation detail.

Create domain/errors.go:

touch domain/errors.go
package domain

import "errors"

// ErrNotFound is returned when a Todo with the requested ID does not exist.
var ErrNotFound = errors.New("todo: not found")

// ErrEmptyTitle is returned when a title value object is created with an empty string.
var ErrEmptyTitle = errors.New("todo: title cannot be empty")

// ErrInvalidID is returned when an ID cannot be parsed from a request.
var ErrInvalidID = errors.New("todo: invalid id")

These three errors cover all failure modes in this application. The HTTP adapter will inspect them and translate them into the correct HTTP status codes (404, 400, etc.).

Create the domain types

Create domain/todo.go:

touch domain/todo.go
package domain

import "time"

// ID is the type for a todo's unique identifier.
// Using a named type instead of int64 makes the domain self-documenting.
// When you see a function that accepts an ID, you know exactly what it expects.
type ID int64

// Title is a value object representing a todo's title.
// It is unexported because we only create it through NewTitle, which validates it.
// This guarantees that any Title value in the system is always valid.
type Title struct {
	value string
}

// NewTitle creates a Title from a string. It returns ErrEmptyTitle if s is blank.
// This is the only way to create a Title. Callers that receive a Title value
// know it has already been validated — they do not need to check it again.
func NewTitle(s string) (Title, error) {
	if s == "" {
		return Title{}, ErrEmptyTitle
	}
	return Title{value: s}, nil
}

// String returns the underlying string value of the Title.
func (t Title) String() string {
	return t.value
}

// Todo is the central entity. It represents a task with an ID, a title,
// a completion status, and timestamps. The fields are unexported to enforce
// that mutation only happens through the provided methods. This prevents
// callers from putting a Todo into an invalid state.
type Todo struct {
	id        ID
	title     Title
	completed bool
	createdAt time.Time
	updatedAt time.Time
}

// NewTodo creates a new Todo with the given title. The ID is zero because
// the database assigns the real ID on insertion. CreatedAt and UpdatedAt
// are set to the current time.
func NewTodo(title Title) Todo {
	now := time.Now().UTC()
	return Todo{
		title:     title,
		completed: false,
		createdAt: now,
		updatedAt: now,
	}
}

// Reconstitute builds a Todo from values read from the database.
// Its purpose is to restore state, not to create new state.
// This separation makes the distinction between "new" and "existing" explicit.
func Reconstitute(id ID, title Title, completed bool, createdAt, updatedAt time.Time) Todo {
	return Todo{
		id:        id,
		title:     title,
		completed: completed,
		createdAt: createdAt,
		updatedAt: updatedAt,
	}
}

// Accessors — read-only access to todo fields.
func (t Todo) ID() ID             { return t.id }
func (t Todo) Title() Title       { return t.title }
func (t Todo) Completed() bool    { return t.completed }
func (t Todo) CreatedAt() time.Time { return t.createdAt }
func (t Todo) UpdatedAt() time.Time { return t.updatedAt }

// Complete returns a new Todo with completed set to true and UpdatedAt refreshed.
// Notice that it returns a new value instead of mutating the receiver.
// This makes the domain immutable by convention, which simplifies reasoning
// about state — you always know what a Todo was before and after an operation.
func (t Todo) Complete() Todo {
	return Todo{
		id:        t.id,
		title:     t.title,
		completed: true,
		createdAt: t.createdAt,
		updatedAt: time.Now().UTC(),
	}
}

// Uncomplete is the reverse of Complete.
func (t Todo) Uncomplete() Todo {
	return Todo{
		id:        t.id,
		title:     t.title,
		completed: false,
		createdAt: t.createdAt,
		updatedAt: time.Now().UTC(),
	}
}

// UpdateTitle returns a new Todo with a different title and a refreshed UpdatedAt.
func (t Todo) UpdateTitle(title Title) Todo {
	return Todo{
		id:        t.id,
		title:     title,
		completed: t.completed,
		createdAt: t.createdAt,
		updatedAt: time.Now().UTC(),
	}
}

Test the domain layer (TDD + BDD)

Tests come before or alongside the code they verify. In this case, we write the domain tests right after writing the domain. The naming convention follows BDD style: Test[Subject]_Given[Condition]_[ExpectedBehavior].

Create domain/todo_test.go:

touch domain/todo_test.go
package domain_test

import (
	"errors"
	"testing"

	"github.com/yourname/go-fullstack-todo/domain"
)

func TestNewTitle_GivenEmptyString_ReturnsErrEmptyTitle(t *testing.T) {
	_, err := domain.NewTitle("")
	if !errors.Is(err, domain.ErrEmptyTitle) {
		t.Fatalf("expected ErrEmptyTitle, got %v", err)
	}
}

func TestNewTitle_GivenNonEmptyString_ReturnsTitle(t *testing.T) {
	title, err := domain.NewTitle("Buy milk")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if title.String() != "Buy milk" {
		t.Fatalf("expected 'Buy milk', got %q", title.String())
	}
}

func TestNewTodo_GivenValidTitle_ReturnsTodoWithCompletedFalse(t *testing.T) {
	title, _ := domain.NewTitle("Write tests")
	todo := domain.NewTodo(title)

	if todo.Completed() {
		t.Fatal("expected completed to be false for a new todo")
	}
	if todo.Title().String() != "Write tests" {
		t.Fatalf("expected title 'Write tests', got %q", todo.Title().String())
	}
}

func TestTodo_Complete_GivenIncompleteTodo_ReturnsCompletedTodo(t *testing.T) {
	title, _ := domain.NewTitle("Water plants")
	todo := domain.NewTodo(title)

	completed := todo.Complete()

	if !completed.Completed() {
		t.Fatal("expected todo to be completed after calling Complete()")
	}
}

func TestTodo_Uncomplete_GivenCompletedTodo_ReturnsIncompleteTodo(t *testing.T) {
	title, _ := domain.NewTitle("Water plants")
	todo := domain.NewTodo(title).Complete()

	uncompleted := todo.Uncomplete()

	if uncompleted.Completed() {
		t.Fatal("expected todo to be incomplete after calling Uncomplete()")
	}
}

func TestTodo_UpdateTitle_GivenNewTitle_ReturnsTodoWithUpdatedTitle(t *testing.T) {
	original, _ := domain.NewTitle("Old title")
	updated, _ := domain.NewTitle("New title")
	todo := domain.NewTodo(original)

	todo = todo.UpdateTitle(updated)

	if todo.Title().String() != "New title" {
		t.Fatalf("expected 'New title', got %q", todo.Title().String())
	}
}

Run the tests:

go test ./domain/...
# ok  github.com/yourname/go-fullstack-todo/domain

All pass. The domain is correct. Now we can build the rest of the application on top of it with confidence.


Part 5 — Ports (interfaces)

Ports are interfaces that separate what the application needs from how it is implemented. There are two kinds:

  • Input ports (driving side): what the outside world can ask the application to do. The HTTP handler calls these.
  • Output ports (driven side): what the application needs from the outside world. The SQLite repository implements these.

This separation means you can swap SQLite for PostgreSQL or the HTTP handler for a gRPC server without changing the application logic.

Input port — the use case interface

Create ports/input/todo_service.go:

touch ports/input/todo_service.go
package input

import "github.com/yourname/go-fullstack-todo/domain"

// ListFilter holds optional filtering criteria for listing todos.
// Both fields are pointers so the application can distinguish
// "not provided" (nil) from "provided as false" (*bool pointing to false).
type ListFilter struct {
	Completed *bool
	Search    *string
}

// TodoService is the input port. It defines all operations that the HTTP
// handler (and any other delivery mechanism) can perform on todos.
// The HTTP handler depends on this interface, not on the concrete implementation.
type TodoService interface {
	List(filter ListFilter) ([]domain.Todo, error)
	Create(title string) (domain.Todo, error)
	GetByID(id domain.ID) (domain.Todo, error)
	Patch(id domain.ID, title *string, completed *bool) (domain.Todo, error)
	Delete(id domain.ID) error
}

Output port — the repository interface

Create ports/output/todo_repository.go:

touch ports/output/todo_repository.go
package output

import "github.com/yourname/go-fullstack-todo/domain"

// ListFilter mirrors the input filter but lives at the output layer.
// The application translates between the two when calling the repository.
type ListFilter struct {
	Completed *bool
	Search    *string
}

// TodoRepository is the output port. It defines persistence operations.
// The application layer calls this interface. The SQLite adapter implements it.
// Because the application depends on the interface and not the concrete type,
// you can substitute a mock in tests without touching the database.
type TodoRepository interface {
	FindAll(filter ListFilter) ([]domain.Todo, error)
	FindByID(id domain.ID) (domain.Todo, error)
	Save(todo domain.Todo) (domain.Todo, error)
	Update(todo domain.Todo) (domain.Todo, error)
	Delete(id domain.ID) error
}

Part 6 — Application layer (use cases)

The application layer implements the input port. It orchestrates domain objects and calls the output port. It contains no HTTP code, no SQL, and no framework-specific knowledge. It is pure Go: structs, interfaces, and standard library types.

Think of it as the foreman in the workshop. The foreman receives orders from the front desk (HTTP), instructs the workers (domain), and uses the storage room (SQLite) through a well-defined request slip (repository interface).

Create application/todo_service.go:

touch application/todo_service.go
package application

import (
	"github.com/yourname/go-fullstack-todo/domain"
	"github.com/yourname/go-fullstack-todo/ports/input"
	"github.com/yourname/go-fullstack-todo/ports/output"
)

// TodoService implements the input.TodoService port.
// It holds a reference to the output port (repository), which is injected
// at construction time. This is dependency injection without a framework.
type TodoService struct {
	repo output.TodoRepository
}

// NewTodoService constructs a TodoService with its required dependency.
// The repository is an interface, so any implementation satisfies it.
func NewTodoService(repo output.TodoRepository) *TodoService {
	return &TodoService{repo: repo}
}

// List returns all todos that match the optional filter.
// When filter is empty (both fields nil), all todos are returned.
func (s *TodoService) List(filter input.ListFilter) ([]domain.Todo, error) {
	return s.repo.FindAll(output.ListFilter{
		Completed: filter.Completed,
		Search:    filter.Search,
	})
}

// Create validates the title, creates a new Todo entity, and persists it.
// Validation happens in the domain (NewTitle), not in the service.
// The service is thin: it delegates to the domain and the repository.
func (s *TodoService) Create(title string) (domain.Todo, error) {
	t, err := domain.NewTitle(title)
	if err != nil {
		return domain.Todo{}, err
	}

	todo := domain.NewTodo(t)

	return s.repo.Save(todo)
}

// GetByID retrieves a single todo by its ID.
// If no todo with that ID exists, the repository returns domain.ErrNotFound.
func (s *TodoService) GetByID(id domain.ID) (domain.Todo, error) {
	return s.repo.FindByID(id)
}

// Patch applies partial updates to an existing todo.
// Only the fields that are non-nil in the arguments are updated.
// This is the PATCH semantic: send only what changed.
func (s *TodoService) Patch(id domain.ID, title *string, completed *bool) (domain.Todo, error) {
	todo, err := s.repo.FindByID(id)
	if err != nil {
		return domain.Todo{}, err
	}

	if title != nil {
		t, err := domain.NewTitle(*title)
		if err != nil {
			return domain.Todo{}, err
		}
		todo = todo.UpdateTitle(t)
	}

	if completed != nil {
		if *completed {
			todo = todo.Complete()
		} else {
			todo = todo.Uncomplete()
		}
	}

	return s.repo.Update(todo)
}

// Delete removes the todo with the given ID.
// It returns domain.ErrNotFound if the ID does not exist.
func (s *TodoService) Delete(id domain.ID) error {
	return s.repo.Delete(id)
}

Test the application layer with a mock

The application layer tests use a mock repository — a hand-written struct that satisfies output.TodoRepository but stores data in memory. This way the tests run instantly and without any database file.

Create application/todo_service_test.go:

touch application/todo_service_test.go
package application_test

import (
	"errors"
	"testing"

	"github.com/yourname/go-fullstack-todo/application"
	"github.com/yourname/go-fullstack-todo/domain"
	"github.com/yourname/go-fullstack-todo/ports/input"
	"github.com/yourname/go-fullstack-todo/ports/output"
)

// mockRepo is an in-memory implementation of output.TodoRepository.
// It is only used in tests. Production code never sees this type.
type mockRepo struct {
	todos  map[domain.ID]domain.Todo
	nextID domain.ID
}

func newMockRepo() *mockRepo {
	return &mockRepo{
		todos:  make(map[domain.ID]domain.Todo),
		nextID: 1,
	}
}

func (m *mockRepo) FindAll(filter output.ListFilter) ([]domain.Todo, error) {
	var result []domain.Todo
	for _, t := range m.todos {
		if filter.Completed != nil && t.Completed() != *filter.Completed {
			continue
		}
		if filter.Search != nil && t.Title().String() != *filter.Search {
			continue
		}
		result = append(result, t)
	}
	return result, nil
}

func (m *mockRepo) FindByID(id domain.ID) (domain.Todo, error) {
	t, ok := m.todos[id]
	if !ok {
		return domain.Todo{}, domain.ErrNotFound
	}
	return t, nil
}

func (m *mockRepo) Save(todo domain.Todo) (domain.Todo, error) {
	saved := domain.Reconstitute(m.nextID, todo.Title(), todo.Completed(), todo.CreatedAt(), todo.UpdatedAt())
	m.todos[m.nextID] = saved
	m.nextID++
	return saved, nil
}

func (m *mockRepo) Update(todo domain.Todo) (domain.Todo, error) {
	if _, ok := m.todos[todo.ID()]; !ok {
		return domain.Todo{}, domain.ErrNotFound
	}
	m.todos[todo.ID()] = todo
	return todo, nil
}

func (m *mockRepo) Delete(id domain.ID) error {
	if _, ok := m.todos[id]; !ok {
		return domain.ErrNotFound
	}
	delete(m.todos, id)
	return nil
}

// — tests —

func TestTodoService_Create_GivenValidTitle_ReturnsTodoWithID(t *testing.T) {
	svc := application.NewTodoService(newMockRepo())

	todo, err := svc.Create("Buy milk")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if todo.ID() == 0 {
		t.Fatal("expected a non-zero ID after creation")
	}
	if todo.Title().String() != "Buy milk" {
		t.Fatalf("expected 'Buy milk', got %q", todo.Title().String())
	}
}

func TestTodoService_Create_GivenEmptyTitle_ReturnsErrEmptyTitle(t *testing.T) {
	svc := application.NewTodoService(newMockRepo())

	_, err := svc.Create("")
	if !errors.Is(err, domain.ErrEmptyTitle) {
		t.Fatalf("expected ErrEmptyTitle, got %v", err)
	}
}

func TestTodoService_GetByID_GivenNonExistentID_ReturnsErrNotFound(t *testing.T) {
	svc := application.NewTodoService(newMockRepo())

	_, err := svc.GetByID(domain.ID(999))
	if !errors.Is(err, domain.ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

func TestTodoService_Patch_GivenCompletedTrue_ReturnsTodoWithCompletedTrue(t *testing.T) {
	svc := application.NewTodoService(newMockRepo())
	todo, _ := svc.Create("Read a book")

	completed := true
	updated, err := svc.Patch(todo.ID(), nil, &completed)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !updated.Completed() {
		t.Fatal("expected todo to be completed after PATCH")
	}
}

func TestTodoService_Delete_GivenExistingID_DeletesTodo(t *testing.T) {
	svc := application.NewTodoService(newMockRepo())
	todo, _ := svc.Create("Temporary task")

	err := svc.Delete(todo.ID())
	if err != nil {
		t.Fatalf("unexpected error on delete: %v", err)
	}

	_, err = svc.GetByID(todo.ID())
	if !errors.Is(err, domain.ErrNotFound) {
		t.Fatal("expected todo to be gone after delete")
	}
}

Run all tests so far:

go test ./...
# ok  github.com/yourname/go-fullstack-todo/domain
# ok  github.com/yourname/go-fullstack-todo/application

Part 7 — SQLite adapter (output port implementation)

The SQLite adapter is where the application meets the real database. It implements output.TodoRepository using database/sql and modernc.org/sqlite. Because it only implements an interface, the application layer does not know or care that SQLite exists.

Create adapters/sqlite/repository.go:

touch adapters/sqlite/repository.go
package sqlite

import (
	"database/sql"
	"errors"
	"time"

	"github.com/yourname/go-fullstack-todo/domain"
	"github.com/yourname/go-fullstack-todo/ports/output"

	_ "modernc.org/sqlite"
)

// TodoRepository is the SQLite implementation of output.TodoRepository.
// The underscore import of modernc.org/sqlite registers the "sqlite" driver
// with database/sql without naming a concrete type anywhere else.
type TodoRepository struct {
	db *sql.DB
}

// New opens (or creates) the SQLite database file at the given path,
// runs the migrations to ensure the schema exists, and returns the repository.
// Call this once at startup.
func New(path string) (*TodoRepository, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, err
	}

	if err := migrate(db); err != nil {
		return nil, err
	}

	return &TodoRepository{db: db}, nil
}

// migrate creates the todos table if it does not already exist.
// Using IF NOT EXISTS makes this idempotent — safe to call on every startup.
func migrate(db *sql.DB) error {
	const createTable = `
	CREATE TABLE IF NOT EXISTS todos (
		id          INTEGER PRIMARY KEY AUTOINCREMENT,
		title       TEXT    NOT NULL,
		completed   INTEGER NOT NULL DEFAULT 0,
		created_at  TEXT    NOT NULL,
		updated_at  TEXT    NOT NULL
	);`

	_, err := db.Exec(createTable)
	return err
}

// FindAll returns todos that match the optional filter.
// Both filter fields are optional: nil means "no filter on this field".
func (r *TodoRepository) FindAll(filter output.ListFilter) ([]domain.Todo, error) {
	query := "SELECT id, title, completed, created_at, updated_at FROM todos WHERE 1=1"
	var args []any

	if filter.Completed != nil {
		query += " AND completed = ?"
		if *filter.Completed {
			args = append(args, 1)
		} else {
			args = append(args, 0)
		}
	}

	if filter.Search != nil && *filter.Search != "" {
		query += " AND title LIKE ?"
		args = append(args, "%"+*filter.Search+"%")
	}

	query += " ORDER BY created_at DESC"

	rows, err := r.db.Query(query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var todos []domain.Todo
	for rows.Next() {
		t, err := scanTodo(rows)
		if err != nil {
			return nil, err
		}
		todos = append(todos, t)
	}

	return todos, rows.Err()
}

// FindByID returns the todo with the given ID, or domain.ErrNotFound if absent.
func (r *TodoRepository) FindByID(id domain.ID) (domain.Todo, error) {
	const query = `
	SELECT id, title, completed, created_at, updated_at
	FROM todos WHERE id = ?`

	row := r.db.QueryRow(query, int64(id))
	todo, err := scanTodoRow(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Todo{}, domain.ErrNotFound
	}
	return todo, err
}

// Save inserts a new todo and returns the saved version with its assigned ID.
func (r *TodoRepository) Save(todo domain.Todo) (domain.Todo, error) {
	const query = `
	INSERT INTO todos (title, completed, created_at, updated_at)
	VALUES (?, ?, ?, ?)
	RETURNING id, title, completed, created_at, updated_at`

	completed := 0
	if todo.Completed() {
		completed = 1
	}

	row := r.db.QueryRow(
		query,
		todo.Title().String(),
		completed,
		todo.CreatedAt().Format(time.RFC3339Nano),
		todo.UpdatedAt().Format(time.RFC3339Nano),
	)

	return scanTodoRow(row)
}

// Update writes the current state of a todo back to the database.
func (r *TodoRepository) Update(todo domain.Todo) (domain.Todo, error) {
	const query = `
	UPDATE todos
	SET title = ?, completed = ?, updated_at = ?
	WHERE id = ?
	RETURNING id, title, completed, created_at, updated_at`

	completed := 0
	if todo.Completed() {
		completed = 1
	}

	row := r.db.QueryRow(
		query,
		todo.Title().String(),
		completed,
		todo.UpdatedAt().Format(time.RFC3339Nano),
		int64(todo.ID()),
	)

	result, err := scanTodoRow(row)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Todo{}, domain.ErrNotFound
	}
	return result, err
}

// Delete removes the todo with the given ID from the database.
func (r *TodoRepository) Delete(id domain.ID) error {
	const query = "DELETE FROM todos WHERE id = ?"

	res, err := r.db.Exec(query, int64(id))
	if err != nil {
		return err
	}

	n, err := res.RowsAffected()
	if err != nil {
		return err
	}
	if n == 0 {
		return domain.ErrNotFound
	}

	return nil
}

// scanTodo reads a domain.Todo from a *sql.Rows cursor.
func scanTodo(rows *sql.Rows) (domain.Todo, error) {
	var (
		id          int64
		titleStr    string
		completedInt int
		createdStr  string
		updatedStr  string
	)

	if err := rows.Scan(&id, &titleStr, &completedInt, &createdStr, &updatedStr); err != nil {
		return domain.Todo{}, err
	}

	return buildTodo(id, titleStr, completedInt, createdStr, updatedStr)
}

// scanTodoRow reads a domain.Todo from a *sql.Row (single row query).
func scanTodoRow(row *sql.Row) (domain.Todo, error) {
	var (
		id          int64
		titleStr    string
		completedInt int
		createdStr  string
		updatedStr  string
	)

	if err := row.Scan(&id, &titleStr, &completedInt, &createdStr, &updatedStr); err != nil {
		return domain.Todo{}, err
	}

	return buildTodo(id, titleStr, completedInt, createdStr, updatedStr)
}

// buildTodo constructs a domain.Todo from raw scanned values.
// It uses domain.Reconstitute because this is an existing entity from storage,
// not a new entity being created.
func buildTodo(id int64, titleStr string, completedInt int, createdStr, updatedStr string) (domain.Todo, error) {
	title, err := domain.NewTitle(titleStr)
	if err != nil {
		return domain.Todo{}, err
	}

	createdAt, err := time.Parse(time.RFC3339Nano, createdStr)
	if err != nil {
		return domain.Todo{}, err
	}

	updatedAt, err := time.Parse(time.RFC3339Nano, updatedStr)
	if err != nil {
		return domain.Todo{}, err
	}

	return domain.Reconstitute(
		domain.ID(id),
		title,
		completedInt == 1,
		createdAt,
		updatedAt,
	), nil
}

The RETURNING clause in the Save and Update queries is supported by SQLite 3.35+ and avoids a second query. modernc.org/sqlite bundles SQLite 3.46+, so this works without any additional configuration.


Part 8 — HTTP adapter (driving the application)

The HTTP adapter translates between HTTP requests and application use cases. It reads request data (path values, query parameters, request bodies, headers), calls the service, and encodes the response. It never contains business logic.

Think of the HTTP handler as a receptionist at the workshop. The receptionist takes customer orders in whatever language the customer speaks (HTTP), translates them into work orders that the foreman understands (use cases), and hands back the result in the customer’s language.

DTOs — data transfer objects

DTOs define the shape of JSON on the wire. They are separate from domain objects. The domain Todo has unexported fields and immutable semantics; the HTTP response just needs a flat struct that encoding/json can marshal.

Create adapters/http/dto.go:

touch adapters/http/dto.go
package httpadapter

import (
	"time"

	"github.com/yourname/go-fullstack-todo/domain"
)

// todoResponse is the JSON shape returned by all endpoints that return a todo.
type todoResponse struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	Completed bool      `json:"completed"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

// createRequest is the JSON body expected by POST /todos.
type createRequest struct {
	Title string `json:"title"`
}

// patchRequest is the JSON body expected by PATCH /todos/{id}.
// All fields are pointers — a nil pointer means "this field was not provided".
// This is what enables partial updates: you only send what changed.
type patchRequest struct {
	Title     *string `json:"title"`
	Completed *bool   `json:"completed"`
}

// toResponse converts a domain.Todo to a todoResponse for JSON serialization.
// This conversion happens in the HTTP adapter, never in the domain or application.
func toResponse(t domain.Todo) todoResponse {
	return todoResponse{
		ID:        int64(t.ID()),
		Title:     t.Title().String(),
		Completed: t.Completed(),
		CreatedAt: t.CreatedAt(),
		UpdatedAt: t.UpdatedAt(),
	}
}

Middleware

Middleware wraps every HTTP handler to add cross-cutting behavior: logging each request, adding a request ID to the response headers, and setting a content-type header.

Create adapters/http/middleware.go:

touch adapters/http/middleware.go
package httpadapter

import (
	"crypto/rand"
	"encoding/hex"
	"log/slog"
	"net/http"
	"time"
)

// Logging wraps a handler and logs the method, path, status code, and duration
// of every request. It uses log/slog for structured output compatible with
// log aggregators and cloud logging services.
func Logging(logger *slog.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		wrapped := &responseWriter{ResponseWriter: w, status: http.StatusOK}

		next.ServeHTTP(wrapped, r)

		logger.Info("request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", wrapped.status,
			"duration_ms", time.Since(start).Milliseconds(),
			"request_id", r.Header.Get("X-Request-ID"),
		)
	})
}

// RequestID reads the X-Request-ID header from the incoming request.
// If none is present, it generates a new random 16-byte hex string.
// Either way, it echoes the value back in the response as X-Request-ID.
// This lets clients correlate a request with its log line.
func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			b := make([]byte, 16)
			_, _ = rand.Read(b)
			id = hex.EncodeToString(b)
		}

		w.Header().Set("X-Request-ID", id)
		r.Header.Set("X-Request-ID", id)

		next.ServeHTTP(w, r)
	})
}

// APIVersion adds the X-API-Version header to every response.
// Clients can read this to know which version of the API they are talking to.
func APIVersion(version string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-API-Version", version)
		next.ServeHTTP(w, r)
	})
}

// responseWriter wraps http.ResponseWriter to capture the status code.
// The standard ResponseWriter does not expose the status after WriteHeader is called.
// We need the status code for logging.
type responseWriter struct {
	http.ResponseWriter
	status int
}

func (rw *responseWriter) WriteHeader(status int) {
	rw.status = status
	rw.ResponseWriter.WriteHeader(status)
}

The HTTP handler

The handler is where all HTTP routing and request parsing happen. Create adapters/http/handler.go:

touch adapters/http/handler.go
package httpadapter

import (
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strconv"

	"github.com/yourname/go-fullstack-todo/domain"
	"github.com/yourname/go-fullstack-todo/ports/input"
)

// Handler holds the service and logger. It registers all routes on the provided mux.
type Handler struct {
	svc    input.TodoService
	logger *slog.Logger
}

// New constructs a Handler and registers all routes on the given ServeMux.
// It uses Go 1.22+ method-qualified patterns: "GET /todos" only matches GET requests.
// This eliminates the need to check r.Method inside handlers.
func New(mux *http.ServeMux, svc input.TodoService, logger *slog.Logger) *Handler {
	h := &Handler{svc: svc, logger: logger}

	mux.HandleFunc("GET /todos", h.list)
	mux.HandleFunc("POST /todos", h.create)
	mux.HandleFunc("GET /todos/{id}", h.getByID)
	mux.HandleFunc("PATCH /todos/{id}", h.patch)
	mux.HandleFunc("DELETE /todos/{id}", h.delete)

	return h
}

// list handles GET /todos
//
// Optional query parameters:
//   - completed=true or completed=false — filter by completion status
//   - search=text — filter todos whose title contains text
//
// Example:
//
//	http GET :8080/todos completed==true search==milk
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
	filter := input.ListFilter{}

	if v := r.URL.Query().Get("completed"); v != "" {
		b, err := strconv.ParseBool(v)
		if err != nil {
			writeError(w, http.StatusBadRequest, "completed must be true or false")
			return
		}
		filter.Completed = &b
	}

	if v := r.URL.Query().Get("search"); v != "" {
		filter.Search = &v
	}

	todos, err := h.svc.List(filter)
	if err != nil {
		h.logger.Error("list todos", "error", err)
		writeError(w, http.StatusInternalServerError, "failed to list todos")
		return
	}

	responses := make([]todoResponse, len(todos))
	for i, t := range todos {
		responses[i] = toResponse(t)
	}

	writeJSON(w, http.StatusOK, responses)
}

// create handles POST /todos
//
// Request body: {"title": "Buy milk"}
//
// Example:
//
//	http POST :8080/todos title="Buy milk"
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
	var req createRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}

	todo, err := h.svc.Create(req.Title)
	if errors.Is(err, domain.ErrEmptyTitle) {
		writeError(w, http.StatusUnprocessableEntity, "title cannot be empty")
		return
	}
	if err != nil {
		h.logger.Error("create todo", "error", err)
		writeError(w, http.StatusInternalServerError, "failed to create todo")
		return
	}

	writeJSON(w, http.StatusCreated, toResponse(todo))
}

// getByID handles GET /todos/{id}
//
// Example:
//
//	http GET :8080/todos/1
func (h *Handler) getByID(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeError(w, http.StatusBadRequest, "id must be a positive integer")
		return
	}

	todo, err := h.svc.GetByID(id)
	if errors.Is(err, domain.ErrNotFound) {
		writeError(w, http.StatusNotFound, "todo not found")
		return
	}
	if err != nil {
		h.logger.Error("get todo by id", "id", id, "error", err)
		writeError(w, http.StatusInternalServerError, "failed to get todo")
		return
	}

	writeJSON(w, http.StatusOK, toResponse(todo))
}

// patch handles PATCH /todos/{id}
//
// Request body: any combination of {"title": "new title"} and {"completed": true}.
// Only fields present in the body are updated. Omitted fields are unchanged.
//
// Example — mark as done:
//
//	http PATCH :8080/todos/1 completed:=true
//
// Example — rename only:
//
//	http PATCH :8080/todos/1 title="Updated title"
//
// Example — both at once:
//
//	http PATCH :8080/todos/1 title="Updated title" completed:=false
func (h *Handler) patch(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeError(w, http.StatusBadRequest, "id must be a positive integer")
		return
	}

	var req patchRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid JSON body")
		return
	}

	todo, err := h.svc.Patch(id, req.Title, req.Completed)
	if errors.Is(err, domain.ErrNotFound) {
		writeError(w, http.StatusNotFound, "todo not found")
		return
	}
	if errors.Is(err, domain.ErrEmptyTitle) {
		writeError(w, http.StatusUnprocessableEntity, "title cannot be empty")
		return
	}
	if err != nil {
		h.logger.Error("patch todo", "id", id, "error", err)
		writeError(w, http.StatusInternalServerError, "failed to patch todo")
		return
	}

	writeJSON(w, http.StatusOK, toResponse(todo))
}

// delete handles DELETE /todos/{id}
//
// Example:
//
//	http DELETE :8080/todos/1
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeError(w, http.StatusBadRequest, "id must be a positive integer")
		return
	}

	err = h.svc.Delete(id)
	if errors.Is(err, domain.ErrNotFound) {
		writeError(w, http.StatusNotFound, "todo not found")
		return
	}
	if err != nil {
		h.logger.Error("delete todo", "id", id, "error", err)
		writeError(w, http.StatusInternalServerError, "failed to delete todo")
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// — helpers —

// parseID extracts the {id} path value and converts it to domain.ID.
// r.PathValue is available since Go 1.22. It reads the named segment
// from the URL pattern matched by the ServeMux.
func parseID(r *http.Request) (domain.ID, error) {
	raw := r.PathValue("id")
	n, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || n <= 0 {
		return 0, domain.ErrInvalidID
	}
	return domain.ID(n), nil
}

// writeJSON encodes v as JSON and writes it with the given status code.
// It always sets Content-Type to application/json.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// writeError writes a JSON error object: {"error": "message"}.
func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, map[string]string{"error": message})
}

Part 9 — The SolidJS frontend

SolidJS is a declarative UI library like React, but with a key difference: it compiles to fine-grained DOM updates without a virtual DOM. The result is a very small, very fast bundle — ideal for a self-contained binary.

This section builds the frontend, explains the components, and ends with npm run build, which produces the dist/ folder that Go will embed.

Step 1: Create the SolidJS app

From the project root, run:

npm create solid@latest frontend

When prompted:

  • Template: bare (minimal, no router)
  • TypeScript: yes

After it finishes:

cd frontend
npm install

Step 2: Create the API client

The frontend communicates with the backend through the browser’s built-in fetch API. Create a small API module to keep the fetch calls organized.

Create frontend/src/api.ts:

touch frontend/src/api.ts
const BASE = "/todos";

export type Todo = {
  id: number;
  title: string;
  completed: boolean;
  created_at: string;
  updated_at: string;
};

export async function fetchTodos(completed?: boolean, search?: string): Promise<Todo[]> {
  const params = new URLSearchParams();
  if (completed !== undefined) params.set("completed", String(completed));
  if (search) params.set("search", search);

  const url = params.size > 0 ? `${BASE}?${params}` : BASE;
  const res = await fetch(url);
  if (!res.ok) throw new Error("Failed to fetch todos");
  return res.json();
}

export async function createTodo(title: string): Promise<Todo> {
  const res = await fetch(BASE, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title }),
  });
  if (!res.ok) throw new Error("Failed to create todo");
  return res.json();
}

export async function patchTodo(id: number, data: Partial<Pick<Todo, "title" | "completed">>): Promise<Todo> {
  const res = await fetch(`${BASE}/${id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
  });
  if (!res.ok) throw new Error("Failed to patch todo");
  return res.json();
}

export async function deleteTodo(id: number): Promise<void> {
  const res = await fetch(`${BASE}/${id}`, { method: "DELETE" });
  if (!res.ok) throw new Error("Failed to delete todo");
}

Step 3: Build the TodoList component

Replace the contents of frontend/src/App.tsx with a full implementation:

import { createSignal, createResource, For, Show } from "solid-js";
import { fetchTodos, createTodo, patchTodo, deleteTodo, type Todo } from "./api";

export default function App() {
  const [input, setInput] = createSignal("");
  const [filter, setFilter] = createSignal<boolean | undefined>(undefined);
  const [search, setSearch] = createSignal("");

  const [todos, { refetch }] = createResource(
    () => ({ completed: filter(), search: search() }),
    ({ completed, search }) => fetchTodos(completed, search)
  );

  async function handleCreate(e: Event) {
    e.preventDefault();
    const title = input().trim();
    if (!title) return;
    await createTodo(title);
    setInput("");
    refetch();
  }

  async function handleToggle(todo: Todo) {
    await patchTodo(todo.id, { completed: !todo.completed });
    refetch();
  }

  async function handleDelete(id: number) {
    await deleteTodo(id);
    refetch();
  }

  return (
    <main>
      <h1>TODO</h1>

      <form onSubmit={handleCreate}>
        <input
          type="text"
          placeholder="What needs to be done?"
          value={input()}
          onInput={(e) => setInput(e.currentTarget.value)}
        />
        <button type="submit">Add</button>
      </form>

      <div class="filters">
        <button onClick={() => setFilter(undefined)}>All</button>
        <button onClick={() => setFilter(false)}>Active</button>
        <button onClick={() => setFilter(true)}>Done</button>
        <input
          type="text"
          placeholder="Search..."
          onInput={(e) => setSearch(e.currentTarget.value)}
        />
      </div>

      <Show when={!todos.loading} fallback={<p>Loading...</p>}>
        <ul>
          <For each={todos()}>
            {(todo) => (
              <li class={todo.completed ? "done" : ""}>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  onChange={() => handleToggle(todo)}
                />
                <span>{todo.title}</span>
                <button onClick={() => handleDelete(todo.id)}>Delete</button>
              </li>
            )}
          </For>
        </ul>
      </Show>
    </main>
  );
}

Step 4: Add CSS

Replace the content of frontend/src/index.css with:

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  font-family: system-ui, sans-serif;
  background: #f4f4f5;
  color: #18181b;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  padding: 2rem;
}

main {
  width: 100%;
  max-width: 480px;
}

h1 { font-size: 2rem; margin-bottom: 1.5rem; }

form {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

input[type="text"] {
  flex: 1;
  padding: 0.5rem 0.75rem;
  border: 1px solid #d4d4d8;
  border-radius: 6px;
  font-size: 1rem;
  background: #fff;
}

button {
  padding: 0.5rem 1rem;
  background: #18181b;
  color: #fff;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 0.9rem;
}

button:hover { background: #3f3f46; }

.filters {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
  flex-wrap: wrap;
}

ul { list-style: none; }

li {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.75rem;
  background: #fff;
  border: 1px solid #e4e4e7;
  border-radius: 8px;
  margin-bottom: 0.5rem;
}

li span { flex: 1; }
li.done span { text-decoration: line-through; color: #a1a1aa; }
li button { background: #ef4444; font-size: 0.8rem; padding: 0.25rem 0.5rem; }
li button:hover { background: #dc2626; }

Step 5: Build the frontend

npm run build

This creates a frontend/dist/ folder containing index.html, a JS bundle, and the CSS. These are the files that Go will embed.


Part 10 — Embedding the frontend with go:embed

The embed package lets you include files from the filesystem into the Go binary at compile time. The //go:embed directive tells the compiler to read specific files or directories and store their contents in a variable.

Think of it as photocopying all the showroom furniture blueprints into the craftsman’s personal notebook. The notebook now contains everything. The craftsman can open the workshop anywhere, without needing the original files.

Create web/embed.go:

touch web/embed.go
package web

import (
	"embed"
	"io/fs"
	"net/http"
)

// dist holds the compiled SolidJS frontend.
// The //go:embed directive must appear immediately before the variable declaration.
// "frontend/dist" is the path relative to this file (web/embed.go lives in web/).
// Adjust this path if your project structure differs.
//
//go:embed frontend/dist
var dist embed.FS

// FileServer returns an http.Handler that serves the embedded SolidJS frontend.
// It strips the "frontend/dist" prefix from the embedded path so that requests
// to "/" match "index.html" inside the embedded directory.
func FileServer() http.Handler {
	sub, err := fs.Sub(dist, "frontend/dist")
	if err != nil {
		// This can only happen if the embed path is wrong.
		// If the binary compiled, the files are guaranteed to be present.
		panic("embed: could not sub frontend/dist: " + err.Error())
	}
	return http.FileServer(http.FS(sub))
}

The fs.Sub call creates a sub-filesystem rooted at frontend/dist. This means a request to /index.html serves the file at frontend/dist/index.html from the embedded FS — without exposing the frontend/dist prefix in URLs.

Important: After writing this file, create a symlink so the embed path resolves correctly. The embed directive path is relative to the Go source file, so web/embed.go needs frontend/dist to be reachable from inside the web/ directory:

cd web
ln -s ../frontend/dist frontend/dist
cd ..

Alternatively, move the frontend/dist folder into web/dist and adjust the embed path to dist. Either approach works. The symlink approach avoids moving files during development.


Part 11 — Wiring everything together in main.go

The main.go file is the composition root. It constructs every dependency, connects them, and starts the server. Nothing is wired anywhere else — all dependency injection happens here. This makes the application’s dependency graph visible in one place.

Create cmd/server/main.go:

touch cmd/server/main.go
package main

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

	"github.com/yourname/go-fullstack-todo/adapters/sqlite"
	httpadapter "github.com/yourname/go-fullstack-todo/adapters/http"
	"github.com/yourname/go-fullstack-todo/application"
	"github.com/yourname/go-fullstack-todo/config"
	"github.com/yourname/go-fullstack-todo/web"
)

func main() {
	// 1. Load configuration from .env and environment variables.
	cfg := config.Load()

	// 2. Set up structured logging.
	// In development, use a human-readable text format.
	// In production, use JSON so log aggregators can parse fields.
	var handler slog.Handler
	if cfg.Env == "development" {
		handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
	} else {
		handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
	}
	logger := slog.New(handler)

	// 3. Open the SQLite database and run migrations.
	repo, err := sqlite.New(cfg.DBPath)
	if err != nil {
		logger.Error("failed to open database", "path", cfg.DBPath, "error", err)
		os.Exit(1)
	}
	logger.Info("database ready", "path", cfg.DBPath)

	// 4. Construct the application service (use case layer).
	svc := application.NewTodoService(repo)

	// 5. Set up the HTTP mux and register all routes.
	mux := http.NewServeMux()

	// Register the API handler — all /todos/* routes.
	httpadapter.New(mux, svc, logger)

	// 6. Serve the SolidJS frontend for all other routes.
	// The frontend is the fallback: any route not matched by the API
	// falls through to the SPA. The SPA's own router handles client-side navigation.
	mux.Handle("/", web.FileServer())

	// 7. Wrap the mux with middleware.
	// Middleware layers are applied outside-in: RequestID runs first,
	// then APIVersion, then Logging, then the actual handler.
	var appHandler http.Handler = mux
	appHandler = httpadapter.Logging(logger, appHandler)
	appHandler = httpadapter.APIVersion("1.0", appHandler)
	appHandler = httpadapter.RequestID(appHandler)

	// 8. Construct the HTTP server.
	addr := fmt.Sprintf(":%s", cfg.Port)
	srv := &http.Server{
		Addr:    addr,
		Handler: appHandler,
	}

	// 9. Set up graceful shutdown using os/signal.NotifyContext.
	//
	// Go 1.26 improvement: NotifyContext now cancels the returned context with
	// a cause that records which signal triggered the shutdown. You can read it
	// with context.Cause(ctx) to log the exact signal that caused the exit.
	// This is useful in production to distinguish SIGTERM (orchestrator shutdown)
	// from SIGINT (developer pressing Ctrl+C).
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	// 10. Start the server in a goroutine so the main goroutine can wait
	// for the shutdown signal without blocking.
	logger.Info("server starting", "addr", addr, "env", cfg.Env)

	serverErr := make(chan error, 1)
	go func() {
		if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			serverErr <- err
		}
	}()

	// 11. Wait for either a signal or a server error.
	select {
	case err := <-serverErr:
		logger.Error("server failed to start", "error", err)
		os.Exit(1)
	case <-ctx.Done():
		// Read the cause — this is the new Go 1.26 behavior:
		// NotifyContext now stores the signal as the cancel cause.
		cause := context.Cause(ctx)
		logger.Info("shutdown signal received", "cause", cause)
	}

	// 12. Gracefully shut down: wait for in-flight requests to finish,
	// but enforce a deadline so the process does not hang forever.
	shutdownCtx := context.Background()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		logger.Error("shutdown error", "error", err)
		os.Exit(1)
	}

	logger.Info("server stopped cleanly")
}

Part 12 — Running the application

You now have every piece. Verify it builds:

go build ./...

If there are no errors, run the server:

go run ./cmd/server

You should see:

time=2026-04-09T12:00:00.000Z level=INFO msg="database ready" path=./todos.db
time=2026-04-09T12:00:00.001Z level=INFO msg="server starting" addr=:8080 env=development

Open a browser at http://localhost:8080 to see the SolidJS frontend.


Part 13 — Testing every endpoint with httpie

httpie’s syntax is http METHOD URL [DATA...]. Data preceded by == is a query parameter. Data followed by := is a JSON value (non-string). Plain key=value is a string JSON field.

Create a todo

http POST :8080/todos title="Buy milk"
HTTP/1.1 201 Created
Content-Type: application/json
X-API-Version: 1.0
X-Request-ID: 4a7f2e...

{
  "id": 1,
  "title": "Buy milk",
  "completed": false,
  "created_at": "2026-04-09T12:00:00Z",
  "updated_at": "2026-04-09T12:00:00Z"
}

List all todos

http GET :8080/todos

Filter by completion status

http GET :8080/todos completed==false
http GET :8080/todos completed==true

The completed==false syntax sends ?completed=false as a query parameter. The handler calls strconv.ParseBool on the string and uses the pointer filter.

Search by title

http GET :8080/todos search==milk

You can combine filters:

http GET :8080/todos completed==false search==milk

Get a single todo by ID

http GET :8080/todos/1

If the todo does not exist:

http GET :8080/todos/999
# HTTP/1.1 404 Not Found
# {"error": "todo not found"}

Send a custom request ID header

http GET :8080/todos/1 X-Request-ID:my-trace-id-123

The server echoes it back in the response headers:

X-Request-ID: my-trace-id-123

You can use this to correlate frontend requests with server log lines. Search the server logs for request_id=my-trace-id-123 to find the exact log entry for this request.

Partially update a todo with PATCH

Mark as completed without changing the title:

http PATCH :8080/todos/1 completed:=true

Change the title without changing the status:

http PATCH :8080/todos/1 title="Buy oat milk"

Change both at once:

http PATCH :8080/todos/1 title="Buy oat milk" completed:=false

The := syntax in httpie sends a JSON boolean (true, not "true"). Without it, the JSON would contain a string, and the Go handler would fail to decode it into a *bool.

Delete a todo

http DELETE :8080/todos/1
# HTTP/1.1 204 No Content

A successful delete returns 204 with no body. If you try to delete the same todo again, you get 404.


Part 14 — Building the production binary

When you are ready to deploy, build a single binary that contains everything:

# Build the SolidJS frontend first
cd frontend && npm run build && cd ..

# Build the Go binary
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server ./cmd/server

The resulting server file is a single executable that includes the Go runtime, all application code, the SQLite library, and the compiled SolidJS frontend. Copy it to any Linux amd64 machine and run it:

./server

That is the entire deployment.


Part 15 — Understanding what you built

Take a moment to trace a complete request through the layers.

A user checks the “Done” checkbox on a todo in the browser. The SolidJS component calls patchTodo(1, { completed: true }). The browser sends:

PATCH /todos/1 HTTP/1.1
Content-Type: application/json

{"completed": true}

Go’s net/http ServeMux matches the pattern "PATCH /todos/{id}" and calls handler.patch. The handler calls parseID to extract 1, then json.Decode to read {"completed": true} into a patchRequest where Completed is a *bool pointing to true.

The handler calls svc.Patch(1, nil, &true). The application service calls repo.FindByID(1) — this hits SQLite and returns a domain.Todo. The service calls todo.Complete(), which returns a new domain.Todo with completed: true and an updated updated_at. Then repo.Update(todo) runs an UPDATE SQL statement and returns the updated row via RETURNING.

The service returns the updated domain.Todo to the handler. The handler calls toResponse(todo) to convert it to a todoResponse (a flat struct for JSON). writeJSON sets Content-Type: application/json, writes the status 200, and encodes the struct.

The middleware layers capture the request ID, set X-API-Version: 1.0, and log the request with duration in milliseconds.

The browser receives the JSON response. SolidJS updates the DOM — only the changed element, without a full re-render. The checkbox shows as checked.

Every layer has one job. No layer knows anything about the layers above or below it except through the interface it was given.


Closing insight

A self-contained binary is not just a deployment convenience. It is a design statement: this application is complete and coherent. It does not depend on a package manager, a file system layout, a reverse proxy configuration, or a frontend CDN to do its job. It carries its own dependencies into every environment.

The architecture that makes this possible is the same architecture that makes it testable, maintainable, and replaceable one layer at a time. When you need to scale, you add a process boundary. When you need a different frontend, you change one file. When you need a different database, you write a new adapter. The domain does not change.

Build systems you understand completely. Then you can reason about them when they fail — and they always fail eventually.