Go 1.25 Native REST API: Hexagonal Architecture, DDD, TDD & BDD from Zero

Go 1.25 Native REST API: Hexagonal Architecture, DDD, TDD & BDD from Zero

Build a fully functional TODO REST API in Go 1.25 using only the standard library and SQLite. Step-by-step: Hexagonal Architecture, DDD, TDD, BDD, dependency injection, clean code.

By Omar Flores

A building has distinct zones that never bleed into each other. The foundation holds the structure. The walls carry the utilities. The facade is what tenants and visitors see. When you renovate the lobby, you do not touch the pipes. When you rewire the elevators, the tenants on floor twelve do not notice.

Software built without that discipline looks different. A database call lives inside a request handler. Business logic hides inside a SQL query. A change in the database schema requires hunting through HTTP code. The renovation crew cuts into a load-bearing wall because no one labeled it.

Hexagonal Architecture, and the Domain-Driven Design thinking behind it, exist to label the walls. To put the load-bearing code in the center — protected, testable, independent — and push everything else to the edges where it belongs.

This guide builds a TODO REST API in Go 1.25 from scratch. Every dependency is either the standard library or a pure-Go SQLite driver. You will write real domain code, real tests, and real HTTP handlers. By the end you will have a working API and a mental model for structuring any Go service.

No Gin. No GORM. No code generation. Just Go.


What You Will Build

A REST API for managing TODOs with five endpoints:

MethodPathDescription
GET/todosList all todos
POST/todosCreate a todo
GET/todos/{id}Get a specific todo
PUT/todos/{id}Update a todo
DELETE/todos/{id}Delete a todo

The data is persisted in SQLite. The architecture is Hexagonal (Ports and Adapters). The domain follows DDD principles. The tests follow TDD and BDD conventions, all with Go’s standard testing package.


Architecture Overview

Hexagonal Architecture divides the system into three zones:

┌─────────────────────────────────────────────────────────┐
│                     ADAPTERS (outer)                    │
│                                                         │
│   ┌──────────────┐              ┌──────────────────┐    │
│   │  HTTP layer  │              │  SQLite adapter  │    │
│   │  (driving)   │              │  (driven)        │    │
│   └──────┬───────┘              └────────┬─────────┘    │
│          │                               │              │
│   ┌──────▼───────────────────────────────▼─────────┐    │
│   │                  PORTS (boundary)               │    │
│   │  input.TodoService     output.TodoRepository    │    │
│   └──────────────────────┬──────────────────────────┘    │
│                          │                              │
│          ┌───────────────▼──────────────────┐           │
│          │     APPLICATION (use cases)      │           │
│          │       application.TodoService    │           │
│          └───────────────┬──────────────────┘           │
│                          │                              │
│               ┌──────────▼──────────┐                  │
│               │   DOMAIN (center)   │                  │
│               │   Todo entity       │                  │
│               │   Title value obj.  │                  │
│               │   Domain errors     │                  │
│               └─────────────────────┘                  │
└─────────────────────────────────────────────────────────┘

The domain is the heart. It has no imports from any outer layer. Every rule about what a Todo is lives here.

The ports are interfaces. They define contracts — how the outside world talks to the application (input) and how the application talks to the outside world (output). Nothing concrete lives here.

The application layer contains use cases. CreateTodo, UpdateTodo, DeleteTodo. It orchestrates domain objects and delegates persistence to the output port.

The adapters are concrete implementations of those ports. The HTTP adapter translates HTTP requests into use case calls. The SQLite adapter translates repository calls into SQL.

The dependency direction always points inward: adapters depend on ports, ports depend on the domain. The domain depends on nothing.


The Project Blueprint

Here is the folder structure before you write a single line of code:

todo-api/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── domain/
│   │   ├── todo.go
│   │   └── errors.go
│   ├── ports/
│   │   ├── input/
│   │   │   └── todo_service.go
│   │   └── output/
│   │       └── todo_repository.go
│   ├── application/
│   │   └── todo_service.go
│   └── adapters/
│       ├── http/
│       │   ├── dto.go
│       │   └── handler.go
│       └── sqlite/
│           └── repository.go
├── go.mod
└── go.sum

The internal/ directory prevents other Go modules from importing your packages directly — a Go convention for application code. cmd/ holds the entry point: the main function that wires everything together.


Prerequisites

You need Go 1.22 or later. Go 1.25 is used here. Check your version:

go version

You also need an internet connection to download the SQLite driver the first time. After that the code compiles with no external services.


Step 1 — Initialize the Project

Create the project directory and initialize the Go module.

mkdir todo-api
cd todo-api
go mod init github.com/sazardev/todo-api

The module path identifies your project. You can use any path, but the convention is github.com/<username>/<project>.

Now create the folder structure:

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

Your project is empty but organized. Every file you create from now will have a clear place in this structure.


Step 2 — Model the Domain

The domain is where you translate business concepts into Go types. A Todo has a title, an optional description, a completion status, and timestamps. These are facts about the business, not about your database schema or your HTTP API shape.

Start with the domain errors. Errors belong to the domain because the domain owns the invariants that can be violated.

Create internal/domain/errors.go

touch internal/domain/errors.go
// Package domain contains the core business logic of the TODO application.
// It has no dependencies on any external package — only the Go standard library.
package domain

import "errors"

// These are sentinel errors: named, comparable values you use with errors.Is().
// Defining them in the domain means callers at every layer can check for them
// without importing any framework or library.
var (
	// ErrEmptyTitle is returned when a title is blank or whitespace-only.
	ErrEmptyTitle = errors.New("title cannot be empty")

	// ErrTitleTooLong is returned when a title exceeds 255 characters.
	ErrTitleTooLong = errors.New("title cannot exceed 255 characters")

	// ErrAlreadyCompleted is returned when Complete() is called on a todo
	// that is already done. It protects the invariant that a todo's state
	// transitions are one-way: incomplete → completed.
	ErrAlreadyCompleted = errors.New("todo is already completed")

	// ErrNotFound is returned when a todo does not exist in the repository.
	// Defining it here keeps it decoupled from any database-specific error.
	ErrNotFound = errors.New("todo not found")
)

Why sentinel errors instead of custom error types here? Because the callers — the application layer and the HTTP adapter — need to check errors.Is(err, domain.ErrNotFound) without knowing the underlying persistence mechanism. A sql.ErrNoRows wrapped with %w inside the SQLite adapter becomes a domain.ErrNotFound by the time it reaches the handler. That translation belongs in the adapter, not in the handler.

Create internal/domain/todo.go

touch internal/domain/todo.go

The domain entity and its value objects live here. Notice that Todo has unexported fields — you cannot set todo.title = "something" from outside this package. Every mutation goes through a method that enforces the invariant.

package domain

import (
	"strings"
	"time"
)

// Title is a value object. It wraps a string and enforces the business rules
// that govern what a valid title looks like. Two Titles with the same string
// value are equal — there is no identity beyond the value itself.
// This is the defining characteristic of a value object vs an entity.
type Title struct {
	value string
}

// NewTitle is the constructor for Title. It is the only way to create a Title
// from outside this package, which means the invariants are always enforced.
func NewTitle(s string) (Title, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return Title{}, ErrEmptyTitle
	}
	if len(s) > 255 {
		return Title{}, ErrTitleTooLong
	}
	return Title{value: s}, nil
}

// String returns the underlying string value. This satisfies the fmt.Stringer
// interface and makes the value trivial to use in string contexts.
func (t Title) String() string {
	return t.value
}

// ID is a domain-level type for a todo's identifier. Using a named type
// instead of a raw int64 prevents accidental assignment errors like:
//
//	repo.FindByID(ctx, userID) // compiler error if userID is domain.UserID, not domain.ID
//
// The type carries meaning at compile time.
type ID int64

// Todo is the central aggregate root of this domain. It owns all invariants
// related to the lifecycle of a single todo item. No external code can put
// a Todo into an invalid state because all state mutations go through methods.
type Todo struct {
	id          ID
	title       Title
	description string
	completed   bool
	createdAt   time.Time
	updatedAt   time.Time
}

// NewTodo creates a brand-new Todo that has never been persisted.
// The caller provides a validated Title (which enforces its own rules)
// and an optional description. The ID is zero-value here — the repository
// is responsible for assigning a real ID on save.
func NewTodo(title Title, description string) Todo {
	now := time.Now()
	return Todo{
		title:       title,
		description: description,
		completed:   false,
		createdAt:   now,
		updatedAt:   now,
	}
}

// ReconstituteTodo restores a Todo from a persisted record. The key word is
// "reconstitute" — this is not a creation, it is a resurrection. The ID
// already exists in the database; we are rebuilding the in-memory object.
// This function bypasses the creation-time invariants (like generating
// timestamps) because the stored data was already valid when it was written.
func ReconstituteTodo(
	id ID,
	title Title,
	description string,
	completed bool,
	createdAt time.Time,
	updatedAt time.Time,
) Todo {
	return Todo{
		id:          id,
		title:       title,
		description: description,
		completed:   completed,
		createdAt:   createdAt,
		updatedAt:   updatedAt,
	}
}

// Accessors expose the entity's state as read-only values.
// The domain controls what the outside world can see without
// exposing the ability to mutate via direct field access.

func (t Todo) ID() ID              { return t.id }
func (t Todo) Title() Title        { return t.title }
func (t Todo) Description() string { return t.description }
func (t Todo) IsCompleted() bool   { return t.completed }
func (t Todo) CreatedAt() time.Time { return t.createdAt }
func (t Todo) UpdatedAt() time.Time { return t.updatedAt }

// Complete transitions the todo from incomplete to completed.
// It is intentionally a one-way transition — once done, a todo does not
// go back to pending. If a future requirement needs that, you add Reopen()
// with its own invariant check. You never silently permit invalid transitions.
func (t *Todo) Complete() error {
	if t.completed {
		return ErrAlreadyCompleted
	}
	t.completed = true
	t.updatedAt = time.Now()
	return nil
}

// UpdateTitle replaces the todo's title. The caller must already have
// validated the new title into a Title value object, so no validation
// is repeated here. The domain trusts its own types.
func (t *Todo) UpdateTitle(title Title) {
	t.title = title
	t.updatedAt = time.Now()
}

// UpdateDescription replaces the description. Descriptions have no validation
// rules — an empty description is valid. That is a business decision, not a
// technical shortcut.
func (t *Todo) UpdateDescription(description string) {
	t.description = description
	t.updatedAt = time.Now()
}

Step 3 — Define the Ports

Ports are the contracts between layers. They are Go interfaces. Nothing more. They contain no implementation, no struct, no SQL, no HTTP. Just method signatures.

This is where the architecture’s flexibility lives. You can swap the SQLite adapter for PostgreSQL without changing a single line of application or domain code. The application layer never knows which database is behind the output port.

Create internal/ports/output/todo_repository.go

The output port defines how the application accesses persistence. “Output” because it represents the application sending data out to an external system.

touch internal/ports/output/todo_repository.go
// Package output defines the secondary (driven) ports of the hexagonal architecture.
// These ports are implemented by adapters in the adapters/ layer.
// The application layer depends on these interfaces, not on concrete implementations.
package output

import (
	"context"

	"github.com/sazardev/todo-api/internal/domain"
)

// TodoRepository is the persistence contract.
// Any storage adapter — SQLite, PostgreSQL, in-memory, a JSON file — must
// satisfy this interface to be usable by the application layer.
//
// All methods accept a context.Context as the first argument. This is the
// Go convention for cancellation and deadline propagation. A request that
// times out in the HTTP layer will cancel the inflight database query.
type TodoRepository interface {
	// Save persists a new Todo and returns the saved version with its
	// database-assigned ID populated.
	Save(ctx context.Context, todo domain.Todo) (domain.Todo, error)

	// FindByID retrieves a single Todo. Returns domain.ErrNotFound if no
	// record exists for the given ID.
	FindByID(ctx context.Context, id domain.ID) (domain.Todo, error)

	// FindAll returns all todos ordered by creation time ascending.
	// Returns an empty slice (not nil) when no todos exist.
	FindAll(ctx context.Context) ([]domain.Todo, error)

	// Update persists changes to an existing Todo. The caller is responsible
	// for passing a Todo that already exists in the repository.
	Update(ctx context.Context, todo domain.Todo) (domain.Todo, error)

	// Delete removes the todo with the given ID from persistence.
	Delete(ctx context.Context, id domain.ID) error
}

Create internal/ports/input/todo_service.go

The input port defines what the application can do. “Input” because it represents requests coming into the application from the outside world.

touch internal/ports/input/todo_service.go
// Package input defines the primary (driving) ports of the hexagonal architecture.
// These are the use cases the application exposes to the outside world —
// in this project, through the HTTP adapter.
package input

import (
	"context"

	"github.com/sazardev/todo-api/internal/domain"
)

// CreateTodoRequest carries the raw data needed to create a new todo.
// Using a request struct instead of plain parameters allows the signature
// to evolve without breaking callers: add a new optional field to the struct
// and callers that do not use it are unaffected.
type CreateTodoRequest struct {
	Title       string
	Description string
}

// UpdateTodoRequest carries the data for a full or partial update.
// The application service decides which fields to apply based on what
// is populated.
type UpdateTodoRequest struct {
	ID          domain.ID
	Title       string // If empty, the title is not changed.
	Description string
	Completed   bool // If true and the todo is not yet completed, it is completed.
}

// TodoService defines the application's use cases.
// The HTTP adapter depends on this interface, so you can test the HTTP
// adapter in isolation by providing a mock that also satisfies this interface.
type TodoService interface {
	// Create validates and persists a new todo.
	Create(ctx context.Context, req CreateTodoRequest) (domain.Todo, error)

	// GetByID retrieves a single todo by its domain ID.
	GetByID(ctx context.Context, id domain.ID) (domain.Todo, error)

	// GetAll retrieves every todo in the system.
	GetAll(ctx context.Context) ([]domain.Todo, error)

	// Update applies changes to an existing todo.
	Update(ctx context.Context, req UpdateTodoRequest) (domain.Todo, error)

	// Delete removes a todo permanently.
	Delete(ctx context.Context, id domain.ID) error
}

Step 4 — Write Domain Tests First (TDD)

Test-Driven Development means writing the test before the code that makes it pass. The domain tests are the purest form of this: no database, no HTTP, no dependencies. Just the business rules in isolation.

BDD-style naming comes from Behavior-Driven Development. The convention is TestSubject_GivenCondition_ExpectedBehavior. Reading the test name alone should tell you what scenario is being validated and what the correct outcome is.

Create the test file inside the domain package:

touch internal/domain/todo_test.go
package domain_test

// The _test suffix on the package name means this is an external test package.
// It can only use exported identifiers from the domain package, which is the
// same restriction that any other package has. This prevents tests from
// accidentally depending on internal implementation details.

import (
	"errors"
	"testing"

	"github.com/sazardev/todo-api/internal/domain"
)

// ─── Title Tests ──────────────────────────────────────────────────────────────

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_GivenWhitespaceOnly_ReturnsErrEmptyTitle(t *testing.T) {
	// Business rule: a title of "   " is not a meaningful title.
	// The whitespace trimming lives in NewTitle, not in the caller.
	_, err := domain.NewTitle("   ")
	if !errors.Is(err, domain.ErrEmptyTitle) {
		t.Fatalf("expected ErrEmptyTitle for whitespace-only input, got %v", err)
	}
}

func TestNewTitle_GivenStringLongerThan255Chars_ReturnsErrTitleTooLong(t *testing.T) {
	long := string(make([]byte, 256))
	_, err := domain.NewTitle(long)
	if !errors.Is(err, domain.ErrTitleTooLong) {
		t.Fatalf("expected ErrTitleTooLong, got %v", err)
	}
}

func TestNewTitle_GivenValidString_ReturnsTitleWithTrimmedValue(t *testing.T) {
	// The trimming is part of the business rule: "  Buy milk  " becomes "Buy milk".
	// We test for the trimmed value so the behavior is explicit and locked in.
	title, err := domain.NewTitle("  Buy milk  ")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if got := title.String(); got != "Buy milk" {
		t.Errorf("got %q, want %q", got, "Buy milk")
	}
}

// ─── Todo Entity Tests ────────────────────────────────────────────────────────

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

	// A new todo must start as incomplete. This is a domain invariant.
	if todo.IsCompleted() {
		t.Error("a new todo should not be completed")
	}
	if todo.Title().String() != "Write tests" {
		t.Errorf("unexpected title: %q", todo.Title().String())
	}
	if todo.Description() != "All the tests" {
		t.Errorf("unexpected description: %q", todo.Description())
	}
}

func TestTodo_Complete_GivenIncompleteTodo_MarksAsCompleted(t *testing.T) {
	title, _ := domain.NewTitle("Deploy to production")
	todo := domain.NewTodo(title, "")

	if err := todo.Complete(); err != nil {
		t.Fatalf("unexpected error completing todo: %v", err)
	}
	if !todo.IsCompleted() {
		t.Error("todo should be completed after Complete() call")
	}
}

func TestTodo_Complete_GivenAlreadyCompletedTodo_ReturnsErrAlreadyCompleted(t *testing.T) {
	// This tests the one-way invariant: you cannot "re-complete" a todo.
	title, _ := domain.NewTitle("Review PR")
	todo := domain.NewTodo(title, "")
	_ = todo.Complete()

	err := todo.Complete()
	if !errors.Is(err, domain.ErrAlreadyCompleted) {
		t.Errorf("expected ErrAlreadyCompleted, got %v", err)
	}
}

func TestTodo_UpdateTitle_GivenValidTitle_ChangesTitle(t *testing.T) {
	title, _ := domain.NewTitle("Original title")
	todo := domain.NewTodo(title, "")

	newTitle, _ := domain.NewTitle("Updated title")
	todo.UpdateTitle(newTitle)

	if got := todo.Title().String(); got != "Updated title" {
		t.Errorf("got %q, want %q", got, "Updated title")
	}
}

func TestTodo_UpdateTitle_GivenUpdate_ChangesUpdatedAt(t *testing.T) {
	// updatedAt must change when the todo is mutated.
	// This drives the audit-trail requirement: the API consumer can tell
	// when a todo was last modified.
	title, _ := domain.NewTitle("Task A")
	todo := domain.NewTodo(title, "")
	before := todo.UpdatedAt()

	// Ensure measurable time passes. time.Sleep would introduce test latency;
	// instead we check that updatedAt is not before the original timestamp.
	newTitle, _ := domain.NewTitle("Task A revised")
	todo.UpdateTitle(newTitle)

	if todo.UpdatedAt().Before(before) {
		t.Error("updatedAt must not precede the previous updatedAt after a mutation")
	}
}

// ─── Table-Driven Tests ───────────────────────────────────────────────────────
//
// The table-driven style is idiomatic Go for cases where the same behavior
// is tested with multiple inputs. Each row in the table is a scenario.

func TestNewTitle_TableDriven(t *testing.T) {
	tests := []struct {
		name      string
		input     string
		wantErr   error
		wantValue string
	}{
		{
			name:      "given a normal title, returns it trimmed",
			input:     " Fix login bug ",
			wantErr:   nil,
			wantValue: "Fix login bug",
		},
		{
			name:    "given an empty string, returns ErrEmptyTitle",
			input:   "",
			wantErr: domain.ErrEmptyTitle,
		},
		{
			name:    "given whitespace only, returns ErrEmptyTitle",
			input:   "\t\n  ",
			wantErr: domain.ErrEmptyTitle,
		},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			title, err := domain.NewTitle(tc.input)

			if tc.wantErr != nil {
				if !errors.Is(err, tc.wantErr) {
					t.Errorf("got error %v, want %v", err, tc.wantErr)
				}
				return
			}

			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if title.String() != tc.wantValue {
				t.Errorf("got %q, want %q", title.String(), tc.wantValue)
			}
		})
	}
}

Run the tests now. They should all pass against the domain code you already wrote:

go test ./internal/domain/...

You should see ok github.com/sazardev/todo-api/internal/domain. If anything fails, go back to todo.go and align the implementation with what the tests assert. That is how TDD works: the test is the specification.


Step 5 — Implement the Application Layer

The application layer contains the use cases. It is the orchestrator: it takes a request, validates it using domain rules, delegates persistence to the repository, and returns the result. It has no knowledge of HTTP. It has no knowledge of SQL. It only knows the domain and the port interfaces.

touch internal/application/todo_service.go
// Package application contains the use cases — the heart of what the application
// actually does. It depends on the domain package and on the port interfaces.
// It does not depend on any adapter: no HTTP, no SQL, no specific database.
package application

import (
	"context"
	"fmt"

	"github.com/sazardev/todo-api/internal/domain"
	"github.com/sazardev/todo-api/internal/ports/input"
	"github.com/sazardev/todo-api/internal/ports/output"
)

// TodoService implements input.TodoService using any output.TodoRepository.
// The choice of repository — SQLite, PostgreSQL, in-memory — is made by the
// caller at wiring time, not here. This is dependency injection without a container.
type TodoService struct {
	repo output.TodoRepository
}

// NewTodoService constructs a TodoService with the given repository.
// The function signature makes the dependency explicit and required:
// you cannot create a TodoService without a repository.
func NewTodoService(repo output.TodoRepository) *TodoService {
	return &TodoService{repo: repo}
}

// Create handles the "create a todo" use case.
//
// The steps mirror how you would describe this to a product manager:
// 1. Validate the input title against the business rules.
// 2. Build a new Todo domain object.
// 3. Ask the repository to save it.
// 4. Return the saved Todo (with its database-assigned ID).
func (s *TodoService) Create(ctx context.Context, req input.CreateTodoRequest) (domain.Todo, error) {
	// Delegate title validation to the domain. The application layer never
	// duplicates business rules — it calls domain constructors.
	title, err := domain.NewTitle(req.Title)
	if err != nil {
		// Wrap the error with context so a stack of callers can understand
		// what operation failed. fmt.Errorf with %w preserves errors.Is behavior.
		return domain.Todo{}, fmt.Errorf("application: create todo: %w", err)
	}

	todo := domain.NewTodo(title, req.Description)

	saved, err := s.repo.Save(ctx, todo)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("application: save todo: %w", err)
	}

	return saved, nil
}

// GetByID retrieves a single todo. Returns domain.ErrNotFound if no todo
// exists for the given ID — the repository is responsible for that translation.
func (s *TodoService) GetByID(ctx context.Context, id domain.ID) (domain.Todo, error) {
	todo, err := s.repo.FindByID(ctx, id)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("application: get todo by id: %w", err)
	}
	return todo, nil
}

// GetAll retrieves every todo in the system.
func (s *TodoService) GetAll(ctx context.Context) ([]domain.Todo, error) {
	todos, err := s.repo.FindAll(ctx)
	if err != nil {
		return nil, fmt.Errorf("application: get all todos: %w", err)
	}
	return todos, nil
}

// Update applies changes to an existing todo. The strategy is:
// 1. Load the existing todo (fails fast with ErrNotFound if missing).
// 2. Apply only the fields that the caller wants to change.
// 3. Delegate state transitions to the domain entity (Complete(), UpdateTitle()).
// 4. Persist the updated todo.
func (s *TodoService) Update(ctx context.Context, req input.UpdateTodoRequest) (domain.Todo, error) {
	// Load first. If the record does not exist, there is nothing to update.
	todo, err := s.repo.FindByID(ctx, req.ID)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("application: load todo for update: %w", err)
	}

	// Apply the title change only when a non-empty title was provided.
	// An empty req.Title means "do not change the title", not "clear the title".
	if req.Title != "" {
		newTitle, err := domain.NewTitle(req.Title)
		if err != nil {
			return domain.Todo{}, fmt.Errorf("application: update title: %w", err)
		}
		todo.UpdateTitle(newTitle)
	}

	// Description changes are always applied — an empty string is a valid description.
	todo.UpdateDescription(req.Description)

	// Complete() enforces the one-way transition invariant.
	// We only call it when the caller explicitly requests completion.
	if req.Completed && !todo.IsCompleted() {
		if err := todo.Complete(); err != nil {
			return domain.Todo{}, fmt.Errorf("application: complete todo: %w", err)
		}
	}

	updated, err := s.repo.Update(ctx, todo)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("application: persist todo update: %w", err)
	}

	return updated, nil
}

// Delete removes a todo by ID. It loads the todo first to confirm it exists,
// which produces a cleaner ErrNotFound error than relying on "rows affected == 0"
// from a database DELETE statement.
func (s *TodoService) Delete(ctx context.Context, id domain.ID) error {
	if _, err := s.repo.FindByID(ctx, id); err != nil {
		return fmt.Errorf("application: find todo for delete: %w", err)
	}

	if err := s.repo.Delete(ctx, id); err != nil {
		return fmt.Errorf("application: delete todo: %w", err)
	}

	return nil
}

Step 6 — Test the Application Layer with a Mock

The application layer has no database and no HTTP. Testing it means providing a fake repository that satisfies the output.TodoRepository interface. In Go this requires no framework — just a struct with the right methods.

touch internal/application/todo_service_test.go
package application_test

import (
	"context"
	"errors"
	"testing"

	"github.com/sazardev/todo-api/internal/application"
	"github.com/sazardev/todo-api/internal/domain"
	"github.com/sazardev/todo-api/internal/ports/input"
)

// ─── In-Memory Mock Repository ────────────────────────────────────────────────
//
// mockRepo is a test double: a fake implementation of output.TodoRepository
// that stores data in a map. It is fast, deterministic, and has no I/O.
// Every test creates a fresh instance so tests are isolated from each other.

type mockRepo struct {
	todos  map[int64]domain.Todo
	nextID int64
}

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

func (m *mockRepo) Save(_ context.Context, todo domain.Todo) (domain.Todo, error) {
	id := domain.ID(m.nextID)
	m.nextID++
	// Reconstitute assigns the generated ID to the returned todo.
	saved := domain.ReconstituteTodo(
		id, todo.Title(), todo.Description(), todo.IsCompleted(),
		todo.CreatedAt(), todo.UpdatedAt(),
	)
	m.todos[int64(id)] = saved
	return saved, nil
}

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

func (m *mockRepo) FindAll(_ context.Context) ([]domain.Todo, error) {
	result := make([]domain.Todo, 0, len(m.todos))
	for _, t := range m.todos {
		result = append(result, t)
	}
	return result, nil
}

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

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

// ─── Application Service Tests ────────────────────────────────────────────────
//
// Each test describes a scenario as: "given state, when action, then outcome."
// The BDD naming makes the test suite readable as documentation.

func TestTodoService_Create(t *testing.T) {
	t.Run("given a valid request, it returns the saved todo with an assigned ID", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())

		todo, err := svc.Create(context.Background(), input.CreateTodoRequest{
			Title:       "Write unit tests",
			Description: "Cover the domain and application layers",
		})

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if todo.ID() == 0 {
			t.Error("expected a non-zero ID after save")
		}
		if todo.Title().String() != "Write unit tests" {
			t.Errorf("unexpected title: %q", todo.Title().String())
		}
		if todo.IsCompleted() {
			t.Error("a new todo must not be completed")
		}
	})

	t.Run("given an empty title, it returns a domain error", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())

		_, err := svc.Create(context.Background(), input.CreateTodoRequest{Title: ""})

		if !errors.Is(err, domain.ErrEmptyTitle) {
			t.Errorf("expected ErrEmptyTitle, got %v", err)
		}
	})
}

func TestTodoService_GetByID(t *testing.T) {
	t.Run("given an existing ID, it returns the correct todo", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())
		created, _ := svc.Create(context.Background(), input.CreateTodoRequest{Title: "Target todo"})

		found, err := svc.GetByID(context.Background(), created.ID())

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if found.ID() != created.ID() {
			t.Errorf("got ID %v, want %v", found.ID(), created.ID())
		}
	})

	t.Run("given a non-existent ID, it returns ErrNotFound", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())

		_, err := svc.GetByID(context.Background(), domain.ID(9999))

		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound, got %v", err)
		}
	})
}

func TestTodoService_Update(t *testing.T) {
	t.Run("given a valid update request, it changes the title and description", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())
		created, _ := svc.Create(context.Background(), input.CreateTodoRequest{Title: "Old title"})

		updated, err := svc.Update(context.Background(), input.UpdateTodoRequest{
			ID:          created.ID(),
			Title:       "New title",
			Description: "New description",
		})

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if updated.Title().String() != "New title" {
			t.Errorf("got %q, want %q", updated.Title().String(), "New title")
		}
		if updated.Description() != "New description" {
			t.Errorf("got %q, want %q", updated.Description(), "New description")
		}
	})

	t.Run("given Completed=true, it marks the todo as completed", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())
		created, _ := svc.Create(context.Background(), input.CreateTodoRequest{Title: "Finish feature"})

		updated, err := svc.Update(context.Background(), input.UpdateTodoRequest{
			ID:        created.ID(),
			Completed: true,
		})

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if !updated.IsCompleted() {
			t.Error("todo should be completed after update with Completed=true")
		}
	})

	t.Run("given a non-existent ID, it returns ErrNotFound", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())

		_, err := svc.Update(context.Background(), input.UpdateTodoRequest{
			ID:    domain.ID(9999),
			Title: "Ghost",
		})

		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound, got %v", err)
		}
	})
}

func TestTodoService_Delete(t *testing.T) {
	t.Run("given an existing ID, it deletes the todo successfully", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())
		created, _ := svc.Create(context.Background(), input.CreateTodoRequest{Title: "To be deleted"})

		if err := svc.Delete(context.Background(), created.ID()); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		// Confirm it no longer exists.
		_, err := svc.GetByID(context.Background(), created.ID())
		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound after deletion, got %v", err)
		}
	})

	t.Run("given a non-existent ID, it returns ErrNotFound", func(t *testing.T) {
		svc := application.NewTodoService(newMockRepo())

		err := svc.Delete(context.Background(), domain.ID(9999))

		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound, got %v", err)
		}
	})
}

Run the application tests:

go test ./internal/application/...

All tests should pass. The application layer is fully tested without a real database and without a running HTTP server. This is the value of ports: you can test each layer independently.


Step 7 — Add the SQLite Driver

You need the SQLite driver to run Step 8. This is the only external dependency in the project. It is pure Go — it compiles without cgo, which means it works in Alpine Linux containers and cross-compilation scenarios without extra setup.

go get modernc.org/sqlite

This adds the dependency to go.mod and go.sum. The driver registers itself under the name "sqlite" with Go’s database/sql package when you import it with the blank identifier.


Step 8 — Implement the SQLite Adapter

The SQLite adapter is the output adapter. It implements output.TodoRepository by translating method calls into SQL statements. The domain never sees SQL. The application layer never sees SQL. All of that exists here, in one file, isolated.

touch internal/adapters/sqlite/repository.go
// Package sqlite is an output (driven) adapter that implements
// output.TodoRepository using SQLite via the database/sql interface.
// All SQL lives in this package. No other package contains SQL.
package sqlite

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

	"github.com/sazardev/todo-api/internal/domain"

	// The blank import registers the "sqlite" driver with database/sql.
	// We never use the package name directly, but the init() function inside
	// runs at startup and makes the driver available to sql.Open.
	_ "modernc.org/sqlite"
)

// schema is the DDL executed on every startup. CREATE TABLE IF NOT EXISTS is
// idempotent — safe to run against an existing database without data loss.
// Boolean yes/no values are stored as integers (0 or 1), which is SQLite-native.
const schema = `
CREATE TABLE IF NOT EXISTS todos (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    title       TEXT    NOT NULL,
    description TEXT    NOT NULL DEFAULT '',
    completed   INTEGER NOT NULL DEFAULT 0,
    created_at  TEXT    NOT NULL,
    updated_at  TEXT    NOT NULL
);`

// The time layout must be consistent between writes and reads.
// RFC3339 is universal and unambiguous. Storing time as text is
// the recommended approach for SQLite since it has no native datetime type.
const timeLayout = time.RFC3339Nano

// TodoRepository is the SQLite implementation of output.TodoRepository.
type TodoRepository struct {
	db *sql.DB
}

// New applies the schema to the database and returns a ready-to-use repository.
// It returns an error if the schema migration fails, which would happen if
// the database file is corrupt or the permissions are wrong.
func New(db *sql.DB) (*TodoRepository, error) {
	if _, err := db.Exec(schema); err != nil {
		return nil, fmt.Errorf("sqlite: apply schema: %w", err)
	}
	return &TodoRepository{db: db}, nil
}

// Open opens the SQLite database at dsn (a file path or ":memory:").
// Use ":memory:" in tests for a fast, isolated, zero-cleanup database.
// db.SetMaxOpenConns(1) is important: SQLite serializes all writes, and
// multiple concurrent writers cause "database is locked" errors.
func Open(dsn string) (*sql.DB, error) {
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, fmt.Errorf("sqlite: open: %w", err)
	}
	db.SetMaxOpenConns(1)
	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("sqlite: ping: %w", err)
	}
	return db, nil
}

// Save inserts a new todo and returns it with the database-assigned ID.
// ExecContext respects the context deadline, so a timed-out HTTP request
// will cancel the INSERT before it completes.
func (r *TodoRepository) Save(ctx context.Context, todo domain.Todo) (domain.Todo, error) {
	const q = `
		INSERT INTO todos (title, description, completed, created_at, updated_at)
		VALUES (?, ?, ?, ?, ?)`

	result, err := r.db.ExecContext(ctx, q,
		todo.Title().String(),
		todo.Description(),
		boolToInt(todo.IsCompleted()),
		todo.CreatedAt().Format(timeLayout),
		todo.UpdatedAt().Format(timeLayout),
	)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("sqlite: save: %w", err)
	}

	rawID, err := result.LastInsertId()
	if err != nil {
		return domain.Todo{}, fmt.Errorf("sqlite: last insert id: %w", err)
	}

	// Return a reconstituted Todo with the real ID assigned by the database.
	return domain.ReconstituteTodo(
		domain.ID(rawID),
		todo.Title(),
		todo.Description(),
		todo.IsCompleted(),
		todo.CreatedAt(),
		todo.UpdatedAt(),
	), nil
}

// FindByID retrieves a single todo. It translates sql.ErrNoRows into
// domain.ErrNotFound, keeping the adapter's errors decoupled from the caller.
func (r *TodoRepository) FindByID(ctx context.Context, id domain.ID) (domain.Todo, error) {
	const q = `
		SELECT id, title, description, completed, created_at, updated_at
		FROM todos WHERE id = ?`

	row := r.db.QueryRowContext(ctx, q, int64(id))
	todo, err := scanRow(row)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return domain.Todo{}, domain.ErrNotFound
		}
		return domain.Todo{}, fmt.Errorf("sqlite: find by id: %w", err)
	}
	return todo, nil
}

// FindAll returns all todos ordered by creation time.
// rows.Close() is deferred as soon as the query succeeds. Failing to close
// results in a connection leak — this defer guarantees cleanup even if the
// scanning loop returns early with an error.
func (r *TodoRepository) FindAll(ctx context.Context) ([]domain.Todo, error) {
	const q = `
		SELECT id, title, description, completed, created_at, updated_at
		FROM todos ORDER BY created_at ASC`

	rows, err := r.db.QueryContext(ctx, q)
	if err != nil {
		return nil, fmt.Errorf("sqlite: find all: %w", err)
	}
	defer rows.Close()

	var todos []domain.Todo
	for rows.Next() {
		todo, err := scanRows(rows)
		if err != nil {
			return nil, fmt.Errorf("sqlite: scan row: %w", err)
		}
		todos = append(todos, todo)
	}

	// rows.Err() returns any error that was encountered during iteration.
	// It must be checked after the loop — a truncated result set is a real error.
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("sqlite: rows error: %w", err)
	}

	// Return an empty slice rather than nil. A nil slice and an empty slice
	// behave identically in most Go code, but encoding/json marshals nil as
	// null and an empty slice as []. The API should return [] when no todos exist.
	if todos == nil {
		todos = []domain.Todo{}
	}

	return todos, nil
}

// Update commits changes to an existing todo. The todo's ID identifies the row.
func (r *TodoRepository) Update(ctx context.Context, todo domain.Todo) (domain.Todo, error) {
	const q = `
		UPDATE todos
		SET title = ?, description = ?, completed = ?, updated_at = ?
		WHERE id = ?`

	if _, err := r.db.ExecContext(ctx, q,
		todo.Title().String(),
		todo.Description(),
		boolToInt(todo.IsCompleted()),
		todo.UpdatedAt().Format(timeLayout),
		int64(todo.ID()),
	); err != nil {
		return domain.Todo{}, fmt.Errorf("sqlite: update: %w", err)
	}

	return todo, nil
}

// Delete removes a todo by ID.
func (r *TodoRepository) Delete(ctx context.Context, id domain.ID) error {
	const q = `DELETE FROM todos WHERE id = ?`

	if _, err := r.db.ExecContext(ctx, q, int64(id)); err != nil {
		return fmt.Errorf("sqlite: delete: %w", err)
	}

	return nil
}

// ─── Internal Helpers ─────────────────────────────────────────────────────────

// scanRow scans a *sql.Row into a domain.Todo.
// It handles both sql.ErrNoRows (which the caller maps to domain.ErrNotFound)
// and unexpected scan errors (which propagate as-is).
func scanRow(row *sql.Row) (domain.Todo, error) {
	var (
		id          int64
		title       string
		description string
		completed   int
		createdStr  string
		updatedStr  string
	)
	if err := row.Scan(&id, &title, &description, &completed, &createdStr, &updatedStr); err != nil {
		return domain.Todo{}, err
	}
	return reconstitute(id, title, description, completed, createdStr, updatedStr)
}

// scanRows scans a *sql.Rows row into a domain.Todo.
func scanRows(rows *sql.Rows) (domain.Todo, error) {
	var (
		id          int64
		title       string
		description string
		completed   int
		createdStr  string
		updatedStr  string
	)
	if err := rows.Scan(&id, &title, &description, &completed, &createdStr, &updatedStr); err != nil {
		return domain.Todo{}, err
	}
	return reconstitute(id, title, description, completed, createdStr, updatedStr)
}

// reconstitute rebuilds a domain.Todo from raw database column values.
// This is the inverse of the Save/Update operations: it goes from storage
// representation back to domain representation.
func reconstitute(
	id int64,
	titleStr, description string,
	completedInt int,
	createdStr, updatedStr string,
) (domain.Todo, error) {
	// Title validation during reconstitution catches data corruption
	// (e.g., someone manually wrote an empty title to the DB).
	title, err := domain.NewTitle(titleStr)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("sqlite: reconstitute title: %w", err)
	}

	createdAt, err := time.Parse(timeLayout, createdStr)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("sqlite: parse created_at %q: %w", createdStr, err)
	}

	updatedAt, err := time.Parse(timeLayout, updatedStr)
	if err != nil {
		return domain.Todo{}, fmt.Errorf("sqlite: parse updated_at %q: %w", updatedStr, err)
	}

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

// boolToInt converts a bool to a SQLite-compatible integer (0 or 1).
// SQLite has no native boolean type.
func boolToInt(b bool) int {
	if b {
		return 1
	}
	return 0
}

Test the SQLite Adapter

The SQLite adapter test is an integration test: it uses a real database, but in-memory (:memory:), so it leaves no files on disk and runs almost as fast as a unit test.

touch internal/adapters/sqlite/repository_test.go
package sqlite_test

import (
	"context"
	"errors"
	"testing"

	"github.com/sazardev/todo-api/internal/adapters/sqlite"
	"github.com/sazardev/todo-api/internal/domain"
)

// newTestRepo creates a fresh in-memory repository for each test.
// Because it is in-memory, isolation between tests is automatic:
// when the *sql.DB is garbage-collected, the database disappears.
func newTestRepo(t *testing.T) *sqlite.TodoRepository {
	t.Helper()
	db, err := sqlite.Open(":memory:")
	if err != nil {
		t.Fatalf("open in-memory db: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	repo, err := sqlite.New(db)
	if err != nil {
		t.Fatalf("create repo: %v", err)
	}
	return repo
}

func TestTodoRepository_Save(t *testing.T) {
	t.Run("given a new todo, it returns the todo with a non-zero ID", func(t *testing.T) {
		repo := newTestRepo(t)
		title, _ := domain.NewTitle("Learn Go internals")
		todo := domain.NewTodo(title, "Read the runtime source")

		saved, err := repo.Save(context.Background(), todo)

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if saved.ID() == 0 {
			t.Error("expected assigned ID, got 0")
		}
		if saved.Title().String() != "Learn Go internals" {
			t.Errorf("got title %q", saved.Title().String())
		}
	})
}

func TestTodoRepository_FindByID(t *testing.T) {
	t.Run("given an existing todo, it returns it by ID", func(t *testing.T) {
		repo := newTestRepo(t)
		title, _ := domain.NewTitle("Target")
		saved, _ := repo.Save(context.Background(), domain.NewTodo(title, ""))

		found, err := repo.FindByID(context.Background(), saved.ID())

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if found.ID() != saved.ID() {
			t.Errorf("ID mismatch: got %v, want %v", found.ID(), saved.ID())
		}
	})

	t.Run("given a missing ID, it returns ErrNotFound", func(t *testing.T) {
		repo := newTestRepo(t)

		_, err := repo.FindByID(context.Background(), domain.ID(9999))

		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound, got %v", err)
		}
	})
}

func TestTodoRepository_FindAll(t *testing.T) {
	t.Run("given no todos, it returns an empty non-nil slice", func(t *testing.T) {
		repo := newTestRepo(t)

		todos, err := repo.FindAll(context.Background())

		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		if todos == nil {
			t.Error("expected empty slice, got nil — this would serialize as JSON null")
		}
		if len(todos) != 0 {
			t.Errorf("expected 0 todos, got %d", len(todos))
		}
	})

	t.Run("given two saved todos, it returns both", func(t *testing.T) {
		repo := newTestRepo(t)
		t1, _ := domain.NewTitle("First")
		t2, _ := domain.NewTitle("Second")
		repo.Save(context.Background(), domain.NewTodo(t1, ""))
		repo.Save(context.Background(), domain.NewTodo(t2, ""))

		todos, _ := repo.FindAll(context.Background())

		if len(todos) != 2 {
			t.Errorf("expected 2 todos, got %d", len(todos))
		}
	})
}

func TestTodoRepository_Delete(t *testing.T) {
	t.Run("given an existing todo, it removes it permanently", func(t *testing.T) {
		repo := newTestRepo(t)
		title, _ := domain.NewTitle("To be removed")
		saved, _ := repo.Save(context.Background(), domain.NewTodo(title, ""))

		if err := repo.Delete(context.Background(), saved.ID()); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}

		_, err := repo.FindByID(context.Background(), saved.ID())
		if !errors.Is(err, domain.ErrNotFound) {
			t.Errorf("expected ErrNotFound after delete, got %v", err)
		}
	})
}

Run all tests to confirm everything is working against a real database:

go test ./internal/adapters/sqlite/...

Step 9 — Implement the HTTP Adapter

The HTTP adapter is the input adapter. It translates HTTP requests into use case calls and use case results into HTTP responses. It knows about JSON, status codes, and request parsing. It knows nothing about SQL.

Go 1.22 introduced method-qualified patterns for http.ServeMux. You can now write "GET /todos" and "POST /todos" and they route to the right handler based on both the method and the path. This eliminates the need for a third-party router for straightforward CRUD APIs.

Go 1.22 also introduced r.PathValue("id") to extract named path variables like {id} from patterns.

Create internal/adapters/http/dto.go

DTOs (Data Transfer Objects) are the shapes JSON takes on the wire. They belong to the adapter layer because they represent the HTTP surface of the API, not the domain model.

touch internal/adapters/http/dto.go
// Package httpadapter is the HTTP input adapter.
// It translates between HTTP (JSON over wire) and the application ports.
package httpadapter

import "time"

// TodoResponse is the JSON shape returned by the API for a single todo.
// The json struct tags define the exact field names the client receives.
// This is intentionally different from the domain.Todo type: the API shape
// is a contract with consumers and must evolve independently of the domain.
type TodoResponse struct {
	ID          int64     `json:"id"`
	Title       string    `json:"title"`
	Description string    `json:"description"`
	Completed   bool      `json:"completed"`
	CreatedAt   time.Time `json:"created_at"`
	UpdatedAt   time.Time `json:"updated_at"`
}

// CreateRequest is the JSON body the client sends when creating a todo.
type CreateRequest struct {
	Title       string `json:"title"`
	Description string `json:"description"`
}

// UpdateRequest is the JSON body for updating an existing todo.
// All fields are optional: omitting title means "do not change the title."
type UpdateRequest struct {
	Title       string `json:"title"`
	Description string `json:"description"`
	Completed   bool   `json:"completed"`
}

// errorResponse is the consistent JSON error shape.
// All error responses from the API use this structure.
type errorResponse struct {
	Error string `json:"error"`
}

Create internal/adapters/http/handler.go

touch internal/adapters/http/handler.go
package httpadapter

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

	"github.com/sazardev/todo-api/internal/domain"
	"github.com/sazardev/todo-api/internal/ports/input"
)

// TodoHandler handles all HTTP requests for the /todos resource.
// It depends on input.TodoService — the port interface, not a concrete type.
// That means the handler is testable with any struct that satisfies the interface.
type TodoHandler struct {
	svc input.TodoService
}

// NewTodoHandler constructs a handler wired to the given service.
func NewTodoHandler(svc input.TodoService) *TodoHandler {
	return &TodoHandler{svc: svc}
}

// RegisterRoutes registers all todo endpoints into the provided ServeMux.
//
// Go 1.22+ routing syntax:
//   - "GET /todos"      — matches only GET requests to /todos exactly
//   - "POST /todos"     — matches only POST requests
//   - "GET /todos/{id}" — matches GET requests where {id} is any non-empty segment
//
// Before Go 1.22, you had to switch on r.Method inside a single handler.
// Now the mux handles method dispatch for you, using only the standard library.
func (h *TodoHandler) RegisterRoutes(mux *http.ServeMux) {
	mux.HandleFunc("GET /todos", h.handleList)
	mux.HandleFunc("POST /todos", h.handleCreate)
	mux.HandleFunc("GET /todos/{id}", h.handleGetByID)
	mux.HandleFunc("PUT /todos/{id}", h.handleUpdate)
	mux.HandleFunc("DELETE /todos/{id}", h.handleDelete)
}

// handleList handles GET /todos.
// On success: 200 OK with a JSON array of todos (never null, always an array).
func (h *TodoHandler) handleList(w http.ResponseWriter, r *http.Request) {
	todos, err := h.svc.GetAll(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "failed to list todos")
		return
	}

	// Build the response DTOs. We must never send domain objects directly
	// over the wire — the DTO is a deliberate contract that we control.
	responses := make([]TodoResponse, len(todos))
	for i, t := range todos {
		responses[i] = toResponse(t)
	}

	writeJSON(w, http.StatusOK, responses)
}

// handleCreate handles POST /todos.
// On success: 201 Created with the new todo in the body.
func (h *TodoHandler) handleCreate(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(r.Context(), input.CreateTodoRequest{
		Title:       req.Title,
		Description: req.Description,
	})
	if err != nil {
		// Map domain errors to HTTP status codes.
		// The HTTP adapter is responsible for this translation.
		if errors.Is(err, domain.ErrEmptyTitle) || errors.Is(err, domain.ErrTitleTooLong) {
			writeError(w, http.StatusUnprocessableEntity, err.Error())
			return
		}
		writeError(w, http.StatusInternalServerError, "failed to create todo")
		return
	}

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

// handleGetByID handles GET /todos/{id}.
// On success: 200 OK with the requested todo.
// On missing: 404 Not Found.
func (h *TodoHandler) handleGetByID(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}

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

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

// handleUpdate handles PUT /todos/{id}.
// On success: 200 OK with the updated todo.
func (h *TodoHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}

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

	todo, err := h.svc.Update(r.Context(), input.UpdateTodoRequest{
		ID:          domain.ID(id),
		Title:       req.Title,
		Description: req.Description,
		Completed:   req.Completed,
	})
	if err != nil {
		if errors.Is(err, domain.ErrNotFound) {
			writeError(w, http.StatusNotFound, "todo not found")
			return
		}
		if errors.Is(err, domain.ErrEmptyTitle) || errors.Is(err, domain.ErrTitleTooLong) {
			writeError(w, http.StatusUnprocessableEntity, err.Error())
			return
		}
		writeError(w, http.StatusInternalServerError, "failed to update todo")
		return
	}

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

// handleDelete handles DELETE /todos/{id}.
// On success: 204 No Content (no body — nothing to return after deletion).
// On missing: 404 Not Found.
func (h *TodoHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
	id, ok := parseID(w, r)
	if !ok {
		return
	}

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

	w.WriteHeader(http.StatusNoContent)
}

// ─── Internal Helpers ─────────────────────────────────────────────────────────

// toResponse converts a domain.Todo to the API-facing DTO.
// This lives in the adapter — the domain is not shaped by the HTTP contract.
func toResponse(t domain.Todo) TodoResponse {
	return TodoResponse{
		ID:          int64(t.ID()),
		Title:       t.Title().String(),
		Description: t.Description(),
		Completed:   t.IsCompleted(),
		CreatedAt:   t.CreatedAt(),
		UpdatedAt:   t.UpdatedAt(),
	}
}

// parseID extracts and validates the {id} path variable.
// r.PathValue is available from Go 1.22+. It reads variables from {name}
// patterns registered with HandleFunc.
func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) {
	raw := r.PathValue("id")
	id, err := strconv.ParseInt(raw, 10, 64)
	if err != nil || id <= 0 {
		writeError(w, http.StatusBadRequest, "id must be a positive integer")
		return 0, false
	}
	return id, true
}

// writeJSON writes a JSON response with the given status code.
// Content-Type is set before WriteHeader — changing headers after writing
// the status code has no effect.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	// Encode writes directly to the ResponseWriter without allocating
	// a full intermediate buffer. For large payloads this matters.
	if err := json.NewEncoder(w).Encode(v); err != nil {
		// At this point the status code is already written.
		// The best we can do is log. In production you would pass a logger here.
		_ = err
	}
}

// writeError writes a consistent JSON error response.
func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, errorResponse{Error: message})
}

Step 10 — Wire Everything in main.go

The entry point is where the dependency graph is assembled. Every layer depends on an interface; here is where you decide which concrete implementation fills each interface. This is manual dependency injection — no framework, no magic, just function calls.

touch cmd/server/main.go
// Package main is the composition root: the single place where all concrete
// dependencies are instantiated and wired together.
//
// If you ever add PostgreSQL support, you create a postgres adapter package
// and change two lines here. Nothing else changes.
package main

import (
	"log/slog"
	"net/http"
	"os"

	httpadapter "github.com/sazardev/todo-api/internal/adapters/http"
	"github.com/sazardev/todo-api/internal/adapters/sqlite"
	"github.com/sazardev/todo-api/internal/application"
)

func main() {
	// log/slog is the structured logging package introduced in Go 1.21.
	// It writes key=value pairs to stdout by default, which is friendly
	// with log aggregation systems like Loki or CloudWatch.
	logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

	// 1. Open the database.
	db, err := sqlite.Open("todos.db")
	if err != nil {
		logger.Error("failed to open database", "error", err)
		os.Exit(1)
	}
	defer db.Close()

	// 2. Build the repository (output adapter).
	repo, err := sqlite.New(db)
	if err != nil {
		logger.Error("failed to initialize repository", "error", err)
		os.Exit(1)
	}

	// 3. Build the application service (use cases).
	//    The service receives the repository through its constructor.
	//    It holds a reference to the output.TodoRepository interface,
	//    never to the concrete *sqlite.TodoRepository type.
	svc := application.NewTodoService(repo)

	// 4. Build the HTTP handler (input adapter).
	//    The handler receives the service through its constructor.
	//    It holds a reference to the input.TodoService interface.
	handler := httpadapter.NewTodoHandler(svc)

	// 5. Register routes.
	mux := http.NewServeMux()
	handler.RegisterRoutes(mux)

	// 6. Start the server.
	addr := ":8080"
	logger.Info("server starting", "addr", addr)
	if err := http.ListenAndServe(addr, mux); err != nil {
		logger.Error("server stopped", "error", err)
		os.Exit(1)
	}
}

The wiring reads top to bottom: database → repository → service → handler → mux → server. Each arrow is a constructor call. If you extract this pattern into a wire() function or a container struct as the application grows, the shape stays exactly the same — you are just organizing the same dependency graph.


Step 11 — Build and Run

Download dependencies, build, and start the server:

go mod tidy
go build ./...
go run ./cmd/server

You should see:

time=2026-04-01T10:00:00Z level=INFO msg="server starting" addr=:8080

The server is live. The database file todos.db is created automatically.


Step 12 — Test the Endpoints with curl

Open a second terminal and try every endpoint:

Create a todo:

curl -s -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Build the API","description":"Follow the guide step by step"}' \
  | jq

Expected response (201 Created):

{
  "id": 1,
  "title": "Build the API",
  "description": "Follow the guide step by step",
  "completed": false,
  "created_at": "2026-04-01T10:00:01Z",
  "updated_at": "2026-04-01T10:00:01Z"
}

List all todos:

curl -s http://localhost:8080/todos | jq

Get one todo:

curl -s http://localhost:8080/todos/1 | jq

Update a todo (mark as completed):

curl -s -X PUT http://localhost:8080/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"title":"Build the API","description":"Done","completed":true}' \
  | jq

Delete a todo:

curl -s -X DELETE http://localhost:8080/todos/1 -o /dev/null -w "%{http_code}\n"

Expected: 204

Try an invalid request to see domain validation in action:

curl -s -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title":""}' \
  | jq

Expected response (422 Unprocessable Entity):

{
  "error": "title cannot be empty"
}

The domain error traveled from domain.ErrEmptyTitle through the application service, through the HTTP adapter, and became a meaningful 422 response — without any layer knowing the details of the other layers.


Run the Full Test Suite

go test ./... -v

You should see tests passing at all three levels:

--- PASS: TestNewTitle_GivenEmptyString_ReturnsErrEmptyTitle
--- PASS: TestTodo_Complete_GivenAlreadyCompletedTodo_ReturnsErrAlreadyCompleted
--- PASS: TestTodoService_Create/given_a_valid_request,...
--- PASS: TestTodoRepository_FindByID/given_a_missing_ID,...

Each layer has its own test strategy:

LayerTest typeSpeedDependencies
domainPure unit testsInstantNone
applicationUnit with mock repoFastmock only
sqlite adapterIntegration testsFastIn-memory SQLite

What the Architecture Bought You

Stand back and look at the final structure:

internal/
  domain/        — zero imports beyond stdlib. Testable as pure logic.
  ports/         — interfaces only. No implementation anywhere.
  application/   — depends on domain + port interfaces. No SQL, no HTTP.
  adapters/http/ — depends on input port. No SQL.
  adapters/sqlite/ — depends on output port + domain. No HTTP.

You can replace SQLite with PostgreSQL tomorrow. Write a new adapters/postgres/repository.go that implements output.TodoRepository. Change two lines in main.go. Done. The domain, application layer, and HTTP adapter are untouched, untested, unchanged.

You can add a gRPC interface next month. Write a new input adapter that satisfies input.TodoService. Wire it in main.go. The business rules do not move.

You can test 100% of the business logic without a database or an HTTP server. The domain tests run in microseconds. The application tests run in milliseconds.


What to Add Next

This API is deliberately minimal. Real applications need a few more pieces:

Middleware — You can wrap http.ServeMux with a logging middleware, a recovery middleware (catches panics from handlers), and a request ID middleware using only the standard library. Each middleware is a function that receives an http.Handler and returns an http.Handler.

Configuration — Read the port, database path, and log level from environment variables using os.Getenv. For more structured config, encoding/json or flag are both stdlib options.

Graceful shutdown — Use context.WithCancel and http.Server.Shutdown to drain in-flight requests before the process exits. This prevents data loss when you deploy a new version.

Pagination — The FindAll method intentionally omits pagination. Add limit and offset query parameters to handleList and pass them through a new ListParams struct in the input port.

Validation in the handler — The current handler decodes JSON and delegates all validation to the domain. For richer input validation (field types, required fields before they reach the domain), add a validation step in the DTO layer.


Closing

Software is the sum of its decisions. Hexagonal Architecture does not make the code faster or the feature richer. It makes the decisions explicit, documented in the shape of the code itself. The domain is protected. The ports are labeled. The adapters are swappable.

Go 1.25 gives you everything you need to build this architecture without a single framework: a capable HTTP mux with method routing, a database interface built for adapters, a structured logging package, and a type system precise enough to express domain invariants at compile time.

The TODO API you built here is simple by design. The architecture is not. And when the requirements change — when a new storage backend arrives, when a second transport protocol is needed, when a junior developer joins the team and needs to find where the business logic lives — the architecture will do most of the explaining for you.

The best architecture is not the most clever one. It is the one that communicates its own rules to the next person who has to change it.