Go 1.26 Full-Stack Notes App: SolidJS, Auth, Hexagonal Architecture & DDD from Zero

Go 1.26 Full-Stack Notes App: SolidJS, Auth, Hexagonal Architecture & DDD from Zero

Build a full-stack notes app in Go 1.26 with SolidJS, JWT auth, and Hexagonal Architecture. Step-by-step from zero to production with DDD, TDD, and BDD.

By Omar Flores

Consider a notebook. It belongs to you. You can write in it, cross things out, share a page with a colleague, and lock it away when you are done. No one else reads it unless you decide to let them. The rules are simple, the data is personal, and the security is physical.

Now put that notebook on a server. Suddenly you need to prove who you are before opening it. You need a way to share specific pages without sharing the whole book. You need to make sure no one tampers with entries that are not theirs. And you need all of this to work without breaking when ten thousand people arrive at once.

This guide builds exactly that system. A notes application, written entirely in Go 1.26, backed by SQLite, with a SolidJS frontend embedded directly into the Go binary. You will implement user registration, login, JWT-based session management, note creation and retrieval, and note sharing between users. Every layer — from the database to the browser — lives in a single deployable binary.

The architecture is Hexagonal. The design follows Domain-Driven Design. The tests use Test-Driven Development with BDD-style naming. Nothing is assumed. Every folder, every file, every line of code is explained, in order, before you write it.


What you will build

A full-stack notes application with:

  • User registration and login with hashed passwords (bcrypt)
  • JWT authentication — tokens issued at login, verified on every protected request
  • Personal notes: create, read, update, delete
  • Note sharing: grant another user read access to one of your notes
  • A SolidJS frontend served from the same Go binary via go:embed
  • SQLite as the database — no server required, one file on disk
  • Hexagonal Architecture with a clean domain layer independent of all infrastructure
  • Full test coverage using testing, net/http/httptest, and BDD test names
  • A .env configuration system for ports, secrets, and database path
  • Manual API testing with httpie at every step

The finished binary deploys with a single command: ./server. The frontend and backend share the same port.


Part 1 — Preparing the environment

Before a single line of code is written, you need the right tools and a clear understanding of what each one does. Skipping this step costs time later. Take five minutes here and save an hour later.

Install Go 1.26

Go 1.26 was released in February 2026. It ships with improved routing in net/http, a stable log/slog structured logging package, and a cleaner os/signal API for graceful shutdown. Download it from the official site:

# Linux (amd64)
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
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc

Verify the installation:

go version
# go version go1.26.0 linux/amd64

Install Node.js

SolidJS is a JavaScript framework. You need Node.js to compile the frontend into static assets that Go will embed. Use Node 20 LTS or later:

# Using nvm — the cleanest approach
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

Install httpie

httpie is a command-line HTTP client designed for humans. Its syntax is readable, its output is colorized, and it handles JSON automatically. Use it throughout this guide to test every endpoint instead of writing curl commands by hand:

# Debian/Ubuntu
sudo apt install httpie

# macOS
brew install httpie

# Verify
http --version

Install the SolidJS Vite template helper

The SolidJS project is initialized with npm create. It uses Vite under the hood — a fast JavaScript bundler. You do not need to install anything globally here; npm create downloads the template when you run it.


Part 2 — Creating the project

The project has two parts that live in two separate folders: backend/ for Go and frontend/ for SolidJS. After the frontend is compiled, Go embeds the output folder directly into the binary. There is no separate deployment step.

Step 1: Create the root folder

Open a terminal and run:

mkdir go-notes-app
cd go-notes-app

All commands in this guide run from inside go-notes-app unless specified otherwise.

Step 2: Initialize the Go module

The Go module is the unit of code distribution in Go. Every project has one, and it lives in go.mod. Initialize it now:

mkdir backend
cd backend
go mod init github.com/yourname/go-notes-app
cd ..

Replace yourname with your actual GitHub username or any valid module path. This string does not need to point to a real URL during development, but it must be a valid Go module path.

Step 3: Create the backend folder structure

The folder structure is not arbitrary. Each folder has a specific role in the architecture. Hexagonal Architecture divides the code into three zones:

  1. The domain — pure business logic, no external dependencies
  2. The application — use cases that orchestrate the domain
  3. The adapters — everything that connects the application to the outside world (HTTP, SQLite, JWT)

Create every folder now:

cd backend
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 adapters/jwt
mkdir -p config
cd ..

After this, the backend looks like:

backend/
  cmd/server/       ← entry point: main.go
  domain/           ← User, Note entities; sentinel errors; value objects
  ports/input/      ← interfaces the HTTP adapter calls (use cases)
  ports/output/     ← interfaces the application calls (repositories)
  application/      ← use case implementations
  adapters/sqlite/  ← SQLite implementations of the output ports
  adapters/http/    ← HTTP handlers, middleware, DTOs
  adapters/jwt/     ← JWT token generation and validation
  config/           ← configuration loaded from environment variables
  go.mod

Step 4: Create the SolidJS frontend

Go back to the root folder and initialize the frontend project:

cd go-notes-app
npm create solid@latest frontend

When prompted:

  • Template: choose bare (the minimal template)
  • TypeScript: choose Yes

After the command finishes:

cd frontend
npm install
cd ..

This creates a Vite + SolidJS project in frontend/. The folder you care about is frontend/dist — that is where the compiled output lands after you run npm run build.

Step 5: Install Go dependencies

Go back into the backend folder and install all required packages at once:

cd backend
go get modernc.org/sqlite
go get github.com/joho/godotenv
go get github.com/golang-jwt/jwt/v5
go get golang.org/x/crypto/bcrypt
cd ..

What each package does:

PackagePurpose
modernc.org/sqlitePure-Go SQLite driver — no CGO, compiles on any platform
github.com/joho/godotenvLoads .env files into environment variables at startup
github.com/golang-jwt/jwt/v5JWT creation and validation following RFC 7519
golang.org/x/crypto/bcryptPassword hashing using the bcrypt algorithm

None of these packages introduce a framework. They are single-purpose tools. The architecture, routing, and request handling remain entirely in the Go standard library.


Part 3 — Configuration

A well-structured application never reads secrets or port numbers directly from source code. It reads them from the environment. This means the same binary runs in development with a local .env file and in production with environment variables set by the platform — no code changes required.

Think of the configuration layer as a receptionist at the office door. Everyone inside the building talks to the receptionist to get the information they need. No one walks outside to read the notepad on the doorstep themselves.

Create the .env file

In the backend/ folder, create .env:

cd backend
touch .env

Open it and write:

PORT=8080
DB_PATH=./notes.db
JWT_SECRET=change-this-before-production
APP_ENV=development

Every value here will be read by the config package at startup. The JWT_SECRET is the key used to sign and verify JWT tokens. In production, this must be a long, random string generated with a tool like openssl rand -hex 32.

Now create .gitignore in the backend/ folder so sensitive files are never committed:

touch .gitignore

Add:

.env
*.db

Create config/config.go

Create the file:

touch config/config.go

Open it and write:

package config

import (
	"log/slog"
	"os"

	"github.com/joho/godotenv"
)

// Config holds all runtime configuration for the application.
// It is populated once at startup and injected into every component
// that needs it. No component reads os.Getenv directly — they receive
// a Config value. This makes the application fully testable.
type Config struct {
	Port      string
	DBPath    string
	JWTSecret string
	Env       string
}

// Load reads the .env file (if present) and builds a Config from environment
// variables. Missing variables fall back to safe defaults.
// Call this exactly once, at the top of main().
func Load() Config {
	// godotenv.Load is silent if the file does not exist.
	// In production, variables are already in the environment
	// before the process starts, so no .env file is needed.
	if err := godotenv.Load(); err != nil {
		slog.Info("no .env file found, using environment variables")
	}

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

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

The empty default for JWTSecret is intentional. If you try to start the server without setting JWT_SECRET, the server should refuse to start — not silently generate tokens with an empty key. You will add that validation in main() later.


Part 4 — The domain layer

The domain layer is the heart of the application. It contains the entities — the real-world concepts your application works with — and the rules that govern them. No database driver is imported here. No HTTP package either. The domain does not know that it runs inside a web server.

This is not a stylistic choice. It is a structural guarantee. When the domain imports nothing from the outside world, you can test every business rule without spinning up a database or an HTTP server. A failing domain test tells you exactly where the logic broke — not whether the database connection timed out.

Create domain/errors.go

Business operations fail in predictable ways. A user tries to log in with the wrong password. A request tries to access a note that does not belong to them. These failures are not exceptional — they are part of the normal flow. Name them explicitly.

touch domain/errors.go
package domain

import "errors"

// Sentinel errors represent the named failure states the domain can return.
// Application and HTTP layers inspect these with errors.Is to decide
// how to respond — which HTTP status code, which message.

var (
	// ErrNotFound is returned when an entity does not exist in the repository.
	ErrNotFound = errors.New("not found")

	// ErrUnauthorized is returned when an operation is attempted without
	// sufficient permissions — wrong password, missing token, wrong owner.
	ErrUnauthorized = errors.New("unauthorized")

	// ErrConflict is returned when a uniqueness constraint is violated —
	// registering an email that already exists, for example.
	ErrConflict = errors.New("conflict")

	// ErrValidation is returned when input fails business rule validation —
	// a title that is too short, an empty body, an invalid email format.
	ErrValidation = errors.New("validation error")
)

Create domain/user.go

The User entity represents a person who has an account in the system. It carries an identity (ID), a unique identifier used to find them (Email), and a secret that proves they are who they say they are (PasswordHash).

touch domain/user.go
package domain

import (
	"strings"
	"time"
)

// User is an entity. It has identity — two users with the same email
// but different IDs are not the same user. The ID is assigned by the
// database at creation time.
type User struct {
	ID           int64
	Email        string
	PasswordHash string
	CreatedAt    time.Time
}

// NewUser creates a User value with validated fields.
// passwordHash must already be a bcrypt hash — this function does not
// hash the password. The caller (a use case in the application layer)
// is responsible for hashing before calling NewUser.
func NewUser(email, passwordHash string) (User, error) {
	email = strings.TrimSpace(strings.ToLower(email))

	if email == "" {
		return User{}, ErrValidation
	}
	if !strings.Contains(email, "@") {
		return User{}, ErrValidation
	}
	if passwordHash == "" {
		return User{}, ErrValidation
	}

	return User{
		Email:        email,
		PasswordHash: passwordHash,
		CreatedAt:    time.Now().UTC(),
	}, nil
}

The email is normalized — trimmed and lowercased — inside the entity constructor. This means the rest of the application never has to remember to normalize it. The rule lives in one place.

Create domain/note.go

The Note entity represents a piece of text owned by a user. Ownership is expressed through OwnerID, which matches a User.ID. A note can also carry a list of SharedWith user IDs — the set of people the owner has granted read access.

touch domain/note.go
package domain

import (
	"strings"
	"time"
)

// Note is an entity owned by one user and optionally shared with others.
// The domain does not know how sharing is stored — that is the repository's concern.
type Note struct {
	ID        int64
	OwnerID   int64
	Title     string
	Body      string
	CreatedAt time.Time
	UpdatedAt time.Time
}

// NewNote creates a Note value with validated fields.
// ownerID must be the ID of an already-persisted user.
func NewNote(ownerID int64, title, body string) (Note, error) {
	title = strings.TrimSpace(title)
	body = strings.TrimSpace(body)

	if title == "" {
		return Note{}, ErrValidation
	}
	// A title longer than 200 characters is a data quality problem,
	// not a storage problem. Enforce it here, not in SQL.
	if len(title) > 200 {
		return Note{}, ErrValidation
	}

	now := time.Now().UTC()
	return Note{
		OwnerID:   ownerID,
		Title:     title,
		Body:      body,
		CreatedAt: now,
		UpdatedAt: now,
	}, nil
}

// BelongsTo returns true if the note is owned by the given user ID.
// Use this before any mutation to enforce ownership.
func (n Note) BelongsTo(userID int64) bool {
	return n.OwnerID == userID
}

The BelongsTo method is a domain rule. The question “does this note belong to that user?” is a business decision — it should not live inside a handler or a repository. It lives on the entity.


Part 5 — The ports layer

In Hexagonal Architecture, ports are the boundaries between the application core and the outside world. A port is just an interface — a contract that says “anyone who wants to talk to my application must speak this language.”

There are two kinds of ports:

  • Input ports (ports/input/) — what the HTTP layer calls. These are the use cases. They express what the application can do.
  • Output ports (ports/output/) — what the application calls. These are the repositories and external services. They express what the application needs.

The application layer implements the input ports. The SQLite adapter implements the output ports. The HTTP adapter calls the input ports. Nothing crosses this boundary except through these interfaces.

Create ports/output/user_repository.go

The user repository is what the application layer calls when it needs to persist or retrieve a user. The application does not know whether the data lives in SQLite, PostgreSQL, or an in-memory map. It only knows this interface.

touch ports/output/user_repository.go
package output

import "github.com/yourname/go-notes-app/domain"

// UserRepository is the output port for user persistence.
// The application layer depends on this interface, not on any
// concrete database implementation.
type UserRepository interface {
	// Save persists a new user and returns the user with its assigned ID.
	// Returns domain.ErrConflict if the email is already registered.
	Save(user domain.User) (domain.User, error)

	// FindByEmail returns the user with the given email.
	// Returns domain.ErrNotFound if no user exists.
	FindByEmail(email string) (domain.User, error)

	// FindByID returns the user with the given ID.
	// Returns domain.ErrNotFound if no user exists.
	FindByID(id int64) (domain.User, error)
}

Create ports/output/note_repository.go

The note repository follows the same pattern. Notice how method names use domain language — Save, FindByOwner, Share — not SQL language. The interface does not mention tables or joins.

touch ports/output/note_repository.go
package output

import "github.com/yourname/go-notes-app/domain"

// NoteRepository is the output port for note persistence.
type NoteRepository interface {
	// Save persists a new note and returns the note with its assigned ID.
	Save(note domain.Note) (domain.Note, error)

	// FindByID returns the note with the given ID.
	// Returns domain.ErrNotFound if the note does not exist.
	FindByID(id int64) (domain.Note, error)

	// FindByOwner returns all notes owned by the given user ID.
	FindByOwner(ownerID int64) ([]domain.Note, error)

	// FindSharedWith returns all notes that have been shared with the given user ID.
	FindSharedWith(userID int64) ([]domain.Note, error)

	// Update replaces the title and body of an existing note.
	// Returns domain.ErrNotFound if the note does not exist.
	Update(note domain.Note) (domain.Note, error)

	// Delete removes a note by ID.
	// Returns domain.ErrNotFound if the note does not exist.
	Delete(id int64) error

	// Share grants read access to targetUserID for the note with the given ID.
	// Returns domain.ErrNotFound if the note or the target user does not exist.
	// Returns domain.ErrConflict if access was already granted.
	Share(noteID, targetUserID int64) error
}

Create ports/input/auth_usecase.go

Input ports describe what the application can do. The auth use case handles everything related to identity: registering, logging in, and validating tokens.

touch ports/input/auth_usecase.go
package input

import "github.com/yourname/go-notes-app/domain"

// RegisterInput carries the data needed to register a new user.
type RegisterInput struct {
	Email    string
	Password string
}

// LoginInput carries the data needed to authenticate a user.
type LoginInput struct {
	Email    string
	Password string
}

// AuthResult is returned after a successful login or registration.
// Token is a signed JWT the client stores and sends on future requests.
type AuthResult struct {
	User  domain.User
	Token string
}

// AuthUseCase is the input port for authentication operations.
// The HTTP handler depends on this interface, not on the concrete implementation.
type AuthUseCase interface {
	// Register creates a new user account.
	// Returns domain.ErrConflict if the email is already in use.
	// Returns domain.ErrValidation if the input fails validation.
	Register(input RegisterInput) (AuthResult, error)

	// Login verifies credentials and returns a signed JWT on success.
	// Returns domain.ErrUnauthorized if credentials do not match.
	Login(input LoginInput) (AuthResult, error)
}

Create ports/input/note_usecase.go

touch ports/input/note_usecase.go
package input

import "github.com/yourname/go-notes-app/domain"

// CreateNoteInput carries the data needed to create a note.
type CreateNoteInput struct {
	OwnerID int64
	Title   string
	Body    string
}

// UpdateNoteInput carries the data needed to update a note.
type UpdateNoteInput struct {
	NoteID    int64
	RequestBy int64 // the user making the request — must match the owner
	Title     string
	Body      string
}

// DeleteNoteInput carries the data needed to delete a note.
type DeleteNoteInput struct {
	NoteID    int64
	RequestBy int64
}

// ShareNoteInput carries the data needed to share a note.
type ShareNoteInput struct {
	NoteID       int64
	OwnerID      int64
	TargetUserID int64
}

// NoteUseCase is the input port for note operations.
type NoteUseCase interface {
	Create(input CreateNoteInput) (domain.Note, error)
	GetByID(noteID, requestBy int64) (domain.Note, error)
	ListOwned(ownerID int64) ([]domain.Note, error)
	ListShared(userID int64) ([]domain.Note, error)
	Update(input UpdateNoteInput) (domain.Note, error)
	Delete(input DeleteNoteInput) error
	Share(input ShareNoteInput) error
}

Every use case input and output uses either a domain type or a simple primitive. No HTTP concepts leak into the ports layer. If you read these interfaces without knowing whether this is a REST API or a gRPC server or a CLI tool, you should not be able to tell.


Part 6 — The application layer (use cases)

The application layer contains the concrete implementations of the input ports. Each use case receives injected dependencies — the output ports — through its constructor. It calls the domain to validate business rules, calls the output ports to persist or retrieve data, and returns either a result or a named domain error.

This layer is where the orchestration happens. It does not know about HTTP. It does not know about SQL. It talks to its dependencies through interfaces.

Create application/auth_service.go

The auth service implements AuthUseCase. It depends on UserRepository to persist users and on a token issuer (the JWT adapter) to sign tokens.

First, create an interface for the token issuer in the ports layer:

touch ports/output/token_issuer.go
package output

// TokenIssuer is the output port for JWT token operations.
type TokenIssuer interface {
	// Issue generates a signed token for the given user ID.
	Issue(userID int64) (string, error)

	// Verify validates a token and returns the user ID encoded in it.
	// Returns an error if the token is invalid or expired.
	Verify(token string) (int64, error)
}

Now create the auth service:

touch application/auth_service.go
package application

import (
	"fmt"

	"github.com/yourname/go-notes-app/domain"
	"github.com/yourname/go-notes-app/ports/input"
	"github.com/yourname/go-notes-app/ports/output"
	"golang.org/x/crypto/bcrypt"
)

// AuthService implements the input.AuthUseCase interface.
// It coordinates domain validation, password hashing, repository access,
// and JWT issuance. It knows nothing about HTTP or SQL.
type AuthService struct {
	users  output.UserRepository
	tokens output.TokenIssuer
}

// NewAuthService constructs an AuthService with all required dependencies.
// This is the dependency injection point for the auth use case.
func NewAuthService(users output.UserRepository, tokens output.TokenIssuer) *AuthService {
	return &AuthService{users: users, tokens: tokens}
}

// Register creates a new user account.
// It hashes the password before storing it — the raw password never touches
// the repository.
func (s *AuthService) Register(in input.RegisterInput) (input.AuthResult, error) {
	// Hash the password. bcrypt cost 12 is the community-accepted default
	// that balances security with acceptable latency (~300ms on modern hardware).
	hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), 12)
	if err != nil {
		return input.AuthResult{}, fmt.Errorf("hashing password: %w", err)
	}

	// Build the domain entity. NewUser validates email format.
	user, err := domain.NewUser(in.Email, string(hash))
	if err != nil {
		return input.AuthResult{}, err
	}

	// Persist the user. The repository returns domain.ErrConflict
	// if the email is already registered.
	saved, err := s.users.Save(user)
	if err != nil {
		return input.AuthResult{}, err
	}

	// Issue a JWT for the newly created user so they are logged in immediately.
	token, err := s.tokens.Issue(saved.ID)
	if err != nil {
		return input.AuthResult{}, fmt.Errorf("issuing token: %w", err)
	}

	return input.AuthResult{User: saved, Token: token}, nil
}

// Login verifies credentials and returns a signed token on success.
func (s *AuthService) Login(in input.LoginInput) (input.AuthResult, error) {
	// Look up the user by email. Returns domain.ErrNotFound if absent.
	user, err := s.users.FindByEmail(in.Email)
	if err != nil {
		// Do not distinguish between "not found" and "wrong password" in the
		// response — that would allow email enumeration attacks.
		return input.AuthResult{}, domain.ErrUnauthorized
	}

	// Compare the provided password against the stored hash.
	// bcrypt.CompareHashAndPassword returns an error if they do not match.
	if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(in.Password)); err != nil {
		return input.AuthResult{}, domain.ErrUnauthorized
	}

	token, err := s.tokens.Issue(user.ID)
	if err != nil {
		return input.AuthResult{}, fmt.Errorf("issuing token: %w", err)
	}

	return input.AuthResult{User: user, Token: token}, nil
}

Notice the comment about email enumeration. When the user does not exist, the service returns ErrUnauthorized rather than ErrNotFound. If it returned ErrNotFound, an attacker could probe for valid email addresses. This is a small but real security detail.

Create application/note_service.go

The note service implements NoteUseCase. It uses ownership rules from the domain entities rather than duplicating them here.

touch application/note_service.go
package application

import (
	"fmt"

	"github.com/yourname/go-notes-app/domain"
	"github.com/yourname/go-notes-app/ports/input"
	"github.com/yourname/go-notes-app/ports/output"
)

// NoteService implements the input.NoteUseCase interface.
type NoteService struct {
	notes output.NoteRepository
	users output.UserRepository
}

// NewNoteService constructs a NoteService with its required dependencies.
func NewNoteService(notes output.NoteRepository, users output.UserRepository) *NoteService {
	return &NoteService{notes: notes, users: users}
}

// Create validates the input, builds the domain entity, and persists it.
func (s *NoteService) Create(in input.CreateNoteInput) (domain.Note, error) {
	note, err := domain.NewNote(in.OwnerID, in.Title, in.Body)
	if err != nil {
		return domain.Note{}, err
	}
	return s.notes.Save(note)
}

// GetByID returns a note if the requesting user owns it or has been granted access.
func (s *NoteService) GetByID(noteID, requestBy int64) (domain.Note, error) {
	note, err := s.notes.FindByID(noteID)
	if err != nil {
		return domain.Note{}, err
	}

	// Owner can always read their own notes.
	if note.BelongsTo(requestBy) {
		return note, nil
	}

	// Check if the note was shared with this user.
	shared, err := s.notes.FindSharedWith(requestBy)
	if err != nil {
		return domain.Note{}, fmt.Errorf("checking shared access: %w", err)
	}
	for _, n := range shared {
		if n.ID == noteID {
			return note, nil
		}
	}

	return domain.Note{}, domain.ErrUnauthorized
}

// ListOwned returns all notes created by the given user.
func (s *NoteService) ListOwned(ownerID int64) ([]domain.Note, error) {
	return s.notes.FindByOwner(ownerID)
}

// ListShared returns all notes that have been shared with the given user.
func (s *NoteService) ListShared(userID int64) ([]domain.Note, error) {
	return s.notes.FindSharedWith(userID)
}

// Update applies title and body changes to a note, enforcing ownership.
func (s *NoteService) Update(in input.UpdateNoteInput) (domain.Note, error) {
	note, err := s.notes.FindByID(in.NoteID)
	if err != nil {
		return domain.Note{}, err
	}
	if !note.BelongsTo(in.RequestBy) {
		return domain.Note{}, domain.ErrUnauthorized
	}

	// Apply changes and re-validate through the domain constructor logic.
	note.Title = in.Title
	note.Body = in.Body

	if err := validateNoteFields(note.Title); err != nil {
		return domain.Note{}, err
	}

	return s.notes.Update(note)
}

// Delete removes a note, enforcing ownership.
func (s *NoteService) Delete(in input.DeleteNoteInput) error {
	note, err := s.notes.FindByID(in.NoteID)
	if err != nil {
		return err
	}
	if !note.BelongsTo(in.RequestBy) {
		return domain.ErrUnauthorized
	}
	return s.notes.Delete(in.NoteID)
}

// Share grants another user read access to a note, enforcing ownership.
func (s *NoteService) Share(in input.ShareNoteInput) error {
	note, err := s.notes.FindByID(in.NoteID)
	if err != nil {
		return err
	}
	if !note.BelongsTo(in.OwnerID) {
		return domain.ErrUnauthorized
	}

	// Verify the target user exists before granting access.
	if _, err := s.users.FindByID(in.TargetUserID); err != nil {
		return domain.ErrNotFound
	}

	return s.notes.Share(in.NoteID, in.TargetUserID)
}

// validateNoteFields enforces title constraints inside the service.
// This is an internal helper — not exported, not tested through the public API.
func validateNoteFields(title string) error {
	if title == "" || len(title) > 200 {
		return domain.ErrValidation
	}
	return nil
}

Part 7 — The adapters: SQLite and JWT

Adapters connect the application core to the outside world. They implement the output ports defined in Part 5. The application layer calls the port interface. Go’s runtime dispatches to the adapter at execution time. The application never knows it is talking to SQLite — it only knows it is talking to a NoteRepository.

Create adapters/sqlite/db.go

This file opens the SQLite connection and runs the schema migrations. It is called once at startup.

touch adapters/sqlite/db.go
package sqlite

import (
	"database/sql"
	"fmt"

	_ "modernc.org/sqlite" // registers the "sqlite" driver with database/sql
)

// Open opens (or creates) the SQLite database at the given path
// and runs all schema migrations. Returns a ready-to-use *sql.DB.
func Open(path string) (*sql.DB, error) {
	db, err := sql.Open("sqlite", path)
	if err != nil {
		return nil, fmt.Errorf("opening sqlite: %w", err)
	}

	// Verify the connection is usable.
	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("pinging sqlite: %w", err)
	}

	// WAL mode improves write performance under concurrent readers.
	if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
		return nil, fmt.Errorf("enabling WAL: %w", err)
	}

	if err := migrate(db); err != nil {
		return nil, fmt.Errorf("running migrations: %w", err)
	}

	return db, nil
}

// migrate creates all tables if they do not already exist.
// Each statement is idempotent — safe to run every time the server starts.
func migrate(db *sql.DB) error {
	statements := []string{
		`CREATE TABLE IF NOT EXISTS users (
			id            INTEGER PRIMARY KEY AUTOINCREMENT,
			email         TEXT    NOT NULL UNIQUE,
			password_hash TEXT    NOT NULL,
			created_at    TEXT    NOT NULL
		)`,
		`CREATE TABLE IF NOT EXISTS notes (
			id         INTEGER PRIMARY KEY AUTOINCREMENT,
			owner_id   INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
			title      TEXT    NOT NULL,
			body       TEXT    NOT NULL DEFAULT '',
			created_at TEXT    NOT NULL,
			updated_at TEXT    NOT NULL
		)`,
		`CREATE TABLE IF NOT EXISTS note_shares (
			note_id     INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
			user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
			PRIMARY KEY (note_id, user_id)
		)`,
	}

	for _, stmt := range statements {
		if _, err := db.Exec(stmt); err != nil {
			return err
		}
	}
	return nil
}

Create adapters/sqlite/user_repository.go

touch adapters/sqlite/user_repository.go
package sqlite

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

	"github.com/yourname/go-notes-app/domain"
	"github.com/yourname/go-notes-app/ports/output"
)

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

// NewUserRepo constructs a UserRepo with a shared database connection.
func NewUserRepo(db *sql.DB) output.UserRepository {
	return &UserRepo{db: db}
}

func (r *UserRepo) Save(user domain.User) (domain.User, error) {
	const q = `INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?) RETURNING id`

	var id int64
	err := r.db.QueryRow(q, user.Email, user.PasswordHash, user.CreatedAt.Format(time.RFC3339)).Scan(&id)
	if err != nil {
		if isUniqueViolation(err) {
			return domain.User{}, domain.ErrConflict
		}
		return domain.User{}, err
	}

	user.ID = id
	return user, nil
}

func (r *UserRepo) FindByEmail(email string) (domain.User, error) {
	const q = `SELECT id, email, password_hash, created_at FROM users WHERE email = ?`
	return scanUser(r.db.QueryRow(q, email))
}

func (r *UserRepo) FindByID(id int64) (domain.User, error) {
	const q = `SELECT id, email, password_hash, created_at FROM users WHERE id = ?`
	return scanUser(r.db.QueryRow(q, id))
}

func scanUser(row *sql.Row) (domain.User, error) {
	var u domain.User
	var createdAt string

	err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &createdAt)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.User{}, domain.ErrNotFound
	}
	if err != nil {
		return domain.User{}, err
	}

	u.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
	return u, nil
}

// isUniqueViolation reports whether err is a SQLite UNIQUE constraint error.
func isUniqueViolation(err error) bool {
	return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}

Create adapters/sqlite/note_repository.go

touch adapters/sqlite/note_repository.go
package sqlite

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

	"github.com/yourname/go-notes-app/domain"
	"github.com/yourname/go-notes-app/ports/output"
)

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

// NewNoteRepo constructs a NoteRepo with a shared database connection.
func NewNoteRepo(db *sql.DB) output.NoteRepository {
	return &NoteRepo{db: db}
}

func (r *NoteRepo) Save(note domain.Note) (domain.Note, error) {
	const q = `INSERT INTO notes (owner_id, title, body, created_at, updated_at)
		VALUES (?, ?, ?, ?, ?) RETURNING id`

	var id int64
	err := r.db.QueryRow(q,
		note.OwnerID, note.Title, note.Body,
		note.CreatedAt.Format(time.RFC3339),
		note.UpdatedAt.Format(time.RFC3339),
	).Scan(&id)
	if err != nil {
		return domain.Note{}, err
	}
	note.ID = id
	return note, nil
}

func (r *NoteRepo) FindByID(id int64) (domain.Note, error) {
	const q = `SELECT id, owner_id, title, body, created_at, updated_at FROM notes WHERE id = ?`
	return scanNote(r.db.QueryRow(q, id))
}

func (r *NoteRepo) FindByOwner(ownerID int64) ([]domain.Note, error) {
	const q = `SELECT id, owner_id, title, body, created_at, updated_at
		FROM notes WHERE owner_id = ? ORDER BY updated_at DESC`
	return scanNotes(r.db.Query(q, ownerID))
}

func (r *NoteRepo) FindSharedWith(userID int64) ([]domain.Note, error) {
	const q = `SELECT n.id, n.owner_id, n.title, n.body, n.created_at, n.updated_at
		FROM notes n
		JOIN note_shares s ON s.note_id = n.id
		WHERE s.user_id = ?
		ORDER BY n.updated_at DESC`
	return scanNotes(r.db.Query(q, userID))
}

func (r *NoteRepo) Update(note domain.Note) (domain.Note, error) {
	note.UpdatedAt = time.Now().UTC()
	const q = `UPDATE notes SET title = ?, body = ?, updated_at = ? WHERE id = ?`
	res, err := r.db.Exec(q, note.Title, note.Body, note.UpdatedAt.Format(time.RFC3339), note.ID)
	if err != nil {
		return domain.Note{}, err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return domain.Note{}, domain.ErrNotFound
	}
	return note, nil
}

func (r *NoteRepo) Delete(id int64) error {
	const q = `DELETE FROM notes WHERE id = ?`
	res, err := r.db.Exec(q, id)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return domain.ErrNotFound
	}
	return nil
}

func (r *NoteRepo) Share(noteID, targetUserID int64) error {
	const q = `INSERT INTO note_shares (note_id, user_id) VALUES (?, ?)`
	_, err := r.db.Exec(q, noteID, targetUserID)
	if err != nil {
		if isUniqueViolation(err) {
			return domain.ErrConflict
		}
		return err
	}
	return nil
}

func scanNote(row *sql.Row) (domain.Note, error) {
	var n domain.Note
	var ca, ua string
	err := row.Scan(&n.ID, &n.OwnerID, &n.Title, &n.Body, &ca, &ua)
	if errors.Is(err, sql.ErrNoRows) {
		return domain.Note{}, domain.ErrNotFound
	}
	if err != nil {
		return domain.Note{}, err
	}
	n.CreatedAt, _ = time.Parse(time.RFC3339, ca)
	n.UpdatedAt, _ = time.Parse(time.RFC3339, ua)
	return n, nil
}

func scanNotes(rows *sql.Rows, err error) ([]domain.Note, error) {
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var notes []domain.Note
	for rows.Next() {
		var n domain.Note
		var ca, ua string
		if err := rows.Scan(&n.ID, &n.OwnerID, &n.Title, &n.Body, &ca, &ua); err != nil {
			return nil, err
		}
		n.CreatedAt, _ = time.Parse(time.RFC3339, ca)
		n.UpdatedAt, _ = time.Parse(time.RFC3339, ua)
		notes = append(notes, n)
	}
	return notes, rows.Err()
}

Create adapters/jwt/issuer.go

The JWT adapter implements output.TokenIssuer. It signs tokens with HMAC-SHA256 using the secret from the config. Tokens expire after 24 hours — long enough for comfortable use, short enough to limit damage if one is stolen.

mkdir -p adapters/jwt
touch adapters/jwt/issuer.go
package jwtadapter

import (
	"errors"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/yourname/go-notes-app/ports/output"
)

// Issuer implements output.TokenIssuer using HMAC-SHA256 signed JWTs.
type Issuer struct {
	secret []byte
}

// NewIssuer constructs an Issuer. secret must not be empty.
func NewIssuer(secret string) output.TokenIssuer {
	return &Issuer{secret: []byte(secret)}
}

// Issue creates a signed JWT encoding the given user ID.
// The token expires after 24 hours.
func (i *Issuer) Issue(userID int64) (string, error) {
	claims := jwt.MapClaims{
		"sub": userID,
		"exp": time.Now().Add(24 * time.Hour).Unix(),
		"iat": time.Now().Unix(),
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	signed, err := token.SignedString(i.secret)
	if err != nil {
		return "", fmt.Errorf("signing token: %w", err)
	}
	return signed, nil
}

// Verify parses and validates a JWT, returning the user ID from the "sub" claim.
// Returns an error if the token is invalid, expired, or tampered with.
func (i *Issuer) Verify(tokenStr string) (int64, error) {
	token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
		// Reject tokens signed with any algorithm other than HMAC.
		// Without this check, an attacker could send a token signed with
		// "none" and bypass verification entirely.
		if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
		}
		return i.secret, nil
	})
	if err != nil || !token.Valid {
		return 0, errors.New("invalid token")
	}

	claims, ok := token.Claims.(jwt.MapClaims)
	if !ok {
		return 0, errors.New("invalid claims")
	}

	// jwt.MapClaims stores numbers as float64 when decoded from JSON.
	subFloat, ok := claims["sub"].(float64)
	if !ok {
		return 0, errors.New("invalid subject claim")
	}

	return int64(subFloat), nil
}

The comment about "none" algorithm attacks is not defensive commentary for its own sake. This is CVE-2015-9235 — a real vulnerability that affected production JWT libraries. Always explicitly verify the signing method.


Part 8 — The HTTP adapter: middleware, handlers, DTOs

The HTTP adapter is the outermost layer. It receives HTTP requests, decodes JSON bodies into Go structs, calls the input ports (use cases), and encodes responses back to JSON. It knows about HTTP status codes. It knows about Authorization headers. It knows nothing about bcrypt or SQLite.

This clean separation means you can test all the business logic without starting an HTTP server. When an HTTP test fails, you know the problem is in request decoding or response encoding — not in the business logic.

Create adapters/http/response.go

Start with a small helper that writes consistent JSON responses across all handlers. This prevents duplicating status-code and content-type logic in every handler.

touch adapters/http/response.go
package httpadapter

import (
	"encoding/json"
	"net/http"
)

// writeJSON encodes v as JSON and writes it with the given status code.
// If encoding fails, it writes a plain 500 response. This should never
// happen in practice — the response types are all simple structs.
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 response with a single "error" field.
func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

Create adapters/http/middleware.go

The authentication middleware extracts the JWT from the Authorization header, verifies it using the token issuer, and stores the user ID in the request context. Handlers that need the current user read it from the context.

touch adapters/http/middleware.go
package httpadapter

import (
	"context"
	"net/http"
	"strings"

	"github.com/yourname/go-notes-app/ports/output"
)

// contextKey is an unexported type for context keys in this package.
// Using a custom type prevents collisions with keys from other packages.
type contextKey string

const userIDKey contextKey = "userID"

// AuthMiddleware returns an HTTP middleware that verifies the JWT
// in the Authorization header. On success, it stores the user ID
// in the request context and calls next. On failure, it responds 401.
func AuthMiddleware(tokens output.TokenIssuer) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			header := r.Header.Get("Authorization")
			if header == "" {
				writeError(w, http.StatusUnauthorized, "missing authorization header")
				return
			}

			// The header must be: "Bearer <token>"
			parts := strings.SplitN(header, " ", 2)
			if len(parts) != 2 || parts[0] != "Bearer" {
				writeError(w, http.StatusUnauthorized, "invalid authorization format")
				return
			}

			userID, err := tokens.Verify(parts[1])
			if err != nil {
				writeError(w, http.StatusUnauthorized, "invalid or expired token")
				return
			}

			// Store the user ID in the context so handlers can read it.
			ctx := context.WithValue(r.Context(), userIDKey, userID)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// userIDFromContext retrieves the authenticated user's ID from the context.
// Returns 0 if the ID is not present — callers should protect routes
// with AuthMiddleware to ensure this never returns 0 in a protected handler.
func userIDFromContext(ctx context.Context) int64 {
	id, _ := ctx.Value(userIDKey).(int64)
	return id
}

Create adapters/http/auth_handler.go

touch adapters/http/auth_handler.go
package httpadapter

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

	"github.com/yourname/go-notes-app/domain"
	"github.com/yourname/go-notes-app/ports/input"
)

// AuthHandler handles HTTP requests for authentication operations.
type AuthHandler struct {
	auth input.AuthUseCase
}

// NewAuthHandler constructs an AuthHandler with its required use case.
func NewAuthHandler(auth input.AuthUseCase) *AuthHandler {
	return &AuthHandler{auth: auth}
}

// registerRequest is the JSON body expected by POST /api/auth/register.
type registerRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

// authResponse is the JSON body returned after successful auth.
type authResponse struct {
	Token string   `json:"token"`
	User  userDTO  `json:"user"`
}

type userDTO struct {
	ID    int64  `json:"id"`
	Email string `json:"email"`
}

// Register handles POST /api/auth/register.
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
	var req registerRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}

	result, err := h.auth.Register(input.RegisterInput{
		Email:    req.Email,
		Password: req.Password,
	})
	if err != nil {
		switch {
		case errors.Is(err, domain.ErrConflict):
			writeError(w, http.StatusConflict, "email already registered")
		case errors.Is(err, domain.ErrValidation):
			writeError(w, http.StatusBadRequest, "invalid email or password")
		default:
			writeError(w, http.StatusInternalServerError, "registration failed")
		}
		return
	}

	writeJSON(w, http.StatusCreated, authResponse{
		Token: result.Token,
		User:  userDTO{ID: result.User.ID, Email: result.User.Email},
	})
}

// Login handles POST /api/auth/login.
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
	var req registerRequest // same shape: email + password
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}

	result, err := h.auth.Login(input.LoginInput{
		Email:    req.Email,
		Password: req.Password,
	})
	if err != nil {
		// Always 401 on login failure — do not reveal whether the email exists.
		writeError(w, http.StatusUnauthorized, "invalid credentials")
		return
	}

	writeJSON(w, http.StatusOK, authResponse{
		Token: result.Token,
		User:  userDTO{ID: result.User.ID, Email: result.User.Email},
	})
}

Create adapters/http/note_handler.go

touch adapters/http/note_handler.go
package httpadapter

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

	"github.com/yourname/go-notes-app/domain"
	"github.com/yourname/go-notes-app/ports/input"
)

// NoteHandler handles HTTP requests for note operations.
// All methods require an authenticated user — protect them with AuthMiddleware.
type NoteHandler struct {
	notes input.NoteUseCase
}

// NewNoteHandler constructs a NoteHandler with its required use case.
func NewNoteHandler(notes input.NoteUseCase) *NoteHandler {
	return &NoteHandler{notes: notes}
}

// noteDTO is the JSON representation of a note in API responses.
type noteDTO struct {
	ID        int64  `json:"id"`
	OwnerID   int64  `json:"owner_id"`
	Title     string `json:"title"`
	Body      string `json:"body"`
	CreatedAt string `json:"created_at"`
	UpdatedAt string `json:"updated_at"`
}

func toNoteDTO(n domain.Note) noteDTO {
	return noteDTO{
		ID:        n.ID,
		OwnerID:   n.OwnerID,
		Title:     n.Title,
		Body:      n.Body,
		CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
		UpdatedAt: n.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

// Create handles POST /api/notes.
func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
	userID := userIDFromContext(r.Context())

	var body struct {
		Title string `json:"title"`
		Body  string `json:"body"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}

	note, err := h.notes.Create(input.CreateNoteInput{
		OwnerID: userID,
		Title:   body.Title,
		Body:    body.Body,
	})
	if err != nil {
		if errors.Is(err, domain.ErrValidation) {
			writeError(w, http.StatusBadRequest, "title is required and must be under 200 characters")
			return
		}
		writeError(w, http.StatusInternalServerError, "could not create note")
		return
	}

	writeJSON(w, http.StatusCreated, toNoteDTO(note))
}

// GetByID handles GET /api/notes/{id}.
func (h *NoteHandler) GetByID(w http.ResponseWriter, r *http.Request) {
	userID := userIDFromContext(r.Context())
	noteID, err := parseID(r)
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid note id")
		return
	}

	note, err := h.notes.GetByID(noteID, userID)
	if err != nil {
		switch {
		case errors.Is(err, domain.ErrNotFound):
			writeError(w, http.StatusNotFound, "note not found")
		case errors.Is(err, domain.ErrUnauthorized):
			writeError(w, http.StatusForbidden, "access denied")
		default:
			writeError(w, http.StatusInternalServerError, "could not retrieve note")
		}
		return
	}

	writeJSON(w, http.StatusOK, toNoteDTO(note))
}

// ListOwned handles GET /api/notes — returns the caller's notes.
func (h *NoteHandler) ListOwned(w http.ResponseWriter, r *http.Request) {
	userID := userIDFromContext(r.Context())

	notes, err := h.notes.ListOwned(userID)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not list notes")
		return
	}

	dtos := make([]noteDTO, len(notes))
	for i, n := range notes {
		dtos[i] = toNoteDTO(n)
	}
	writeJSON(w, http.StatusOK, dtos)
}

// ListShared handles GET /api/notes/shared — returns notes shared with the caller.
func (h *NoteHandler) ListShared(w http.ResponseWriter, r *http.Request) {
	userID := userIDFromContext(r.Context())

	notes, err := h.notes.ListShared(userID)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not list shared notes")
		return
	}

	dtos := make([]noteDTO, len(notes))
	for i, n := range notes {
		dtos[i] = toNoteDTO(n)
	}
	writeJSON(w, http.StatusOK, dtos)
}

// Update handles PATCH /api/notes/{id}.
func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
	userID := userIDFromContext(r.Context())
	noteID, err := parseID(r)
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid note id")
		return
	}

	var body struct {
		Title string `json:"title"`
		Body  string `json:"body"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}

	note, err := h.notes.Update(input.UpdateNoteInput{
		NoteID:    noteID,
		RequestBy: userID,
		Title:     body.Title,
		Body:      body.Body,
	})
	if err != nil {
		switch {
		case errors.Is(err, domain.ErrNotFound):
			writeError(w, http.StatusNotFound, "note not found")
		case errors.Is(err, domain.ErrUnauthorized):
			writeError(w, http.StatusForbidden, "access denied")
		case errors.Is(err, domain.ErrValidation):
			writeError(w, http.StatusBadRequest, "invalid title")
		default:
			writeError(w, http.StatusInternalServerError, "could not update note")
		}
		return
	}

	writeJSON(w, http.StatusOK, toNoteDTO(note))
}

// Delete handles DELETE /api/notes/{id}.
func (h *NoteHandler) Delete(w http.ResponseWriter, r *http.Request) {
	userID := userIDFromContext(r.Context())
	noteID, err := parseID(r)
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid note id")
		return
	}

	if err := h.notes.Delete(input.DeleteNoteInput{NoteID: noteID, RequestBy: userID}); err != nil {
		switch {
		case errors.Is(err, domain.ErrNotFound):
			writeError(w, http.StatusNotFound, "note not found")
		case errors.Is(err, domain.ErrUnauthorized):
			writeError(w, http.StatusForbidden, "access denied")
		default:
			writeError(w, http.StatusInternalServerError, "could not delete note")
		}
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// Share handles POST /api/notes/{id}/share.
func (h *NoteHandler) Share(w http.ResponseWriter, r *http.Request) {
	userID := userIDFromContext(r.Context())
	noteID, err := parseID(r)
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid note id")
		return
	}

	var body struct {
		TargetUserID int64 `json:"target_user_id"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}

	if err := h.notes.Share(input.ShareNoteInput{
		NoteID:       noteID,
		OwnerID:      userID,
		TargetUserID: body.TargetUserID,
	}); err != nil {
		switch {
		case errors.Is(err, domain.ErrNotFound):
			writeError(w, http.StatusNotFound, "note or user not found")
		case errors.Is(err, domain.ErrUnauthorized):
			writeError(w, http.StatusForbidden, "access denied")
		case errors.Is(err, domain.ErrConflict):
			writeError(w, http.StatusConflict, "note already shared with this user")
		default:
			writeError(w, http.StatusInternalServerError, "could not share note")
		}
		return
	}

	writeJSON(w, http.StatusOK, map[string]string{"status": "shared"})
}

// parseID reads the path parameter {id} from a Go 1.22+ pattern match.
func parseID(r *http.Request) (int64, error) {
	return strconv.ParseInt(r.PathValue("id"), 10, 64)
}

The r.PathValue("id") call is a Go 1.22 standard library feature. It reads the named path parameter directly from the request — no third-party router needed. In Go 1.26 this is fully stable and the recommended approach.


Part 9 — The router and main.go

The router registers every HTTP route in one place. main.go builds the dependency graph — connecting all the adapters to the use cases — and starts the server. This is the wiring step. If the architecture were a circuit board, main.go is where the components are soldered together.

Create adapters/http/router.go

touch adapters/http/router.go
package httpadapter

import (
	"net/http"

	"github.com/yourname/go-notes-app/ports/output"
)

// NewRouter builds and returns the application's HTTP ServeMux.
// It registers all routes and applies middleware to protected routes.
// The frontend SPA handler is registered separately in main.go after
// the embed is available.
func NewRouter(
	auth *AuthHandler,
	notes *NoteHandler,
	tokens output.TokenIssuer,
) *http.ServeMux {
	mux := http.NewServeMux()

	// Public routes — no authentication required.
	mux.HandleFunc("POST /api/auth/register", auth.Register)
	mux.HandleFunc("POST /api/auth/login", auth.Login)

	// Protected routes — wrapped with AuthMiddleware.
	protected := AuthMiddleware(tokens)

	mux.Handle("GET /api/notes", protected(http.HandlerFunc(notes.ListOwned)))
	mux.Handle("GET /api/notes/shared", protected(http.HandlerFunc(notes.ListShared)))
	mux.Handle("POST /api/notes", protected(http.HandlerFunc(notes.Create)))
	mux.Handle("GET /api/notes/{id}", protected(http.HandlerFunc(notes.GetByID)))
	mux.Handle("PATCH /api/notes/{id}", protected(http.HandlerFunc(notes.Update)))
	mux.Handle("DELETE /api/notes/{id}", protected(http.HandlerFunc(notes.Delete)))
	mux.Handle("POST /api/notes/{id}/share", protected(http.HandlerFunc(notes.Share)))

	return mux
}

The route patterns use Go 1.22’s enhanced net/http ServeMux syntax. The method is part of the pattern ("GET /api/notes"), so the router automatically returns 405 Method Not Allowed for requests with wrong methods. No library code was needed to achieve this.

Create the web embed wrapper

The web/ package wraps the go:embed directive that bakes the compiled SolidJS output into the binary. Create the folder and file:

mkdir -p web
touch web/embed.go
package web

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

//go:embed dist
var distFS embed.FS

// Handler returns an http.Handler that serves the SolidJS application.
// It serves the dist/ folder embedded at compile time. Any path that
// does not match a file falls back to index.html — this is required
// for client-side routing in SolidJS.
func Handler() http.Handler {
	sub, err := fs.Sub(distFS, "dist")
	if err != nil {
		panic("web: could not sub dist: " + err.Error())
	}
	return http.FileServerFS(sub)
}

The //go:embed dist directive is a compile-time instruction. When you run go build, the compiler reads every file inside web/dist/ and embeds their bytes directly into the binary. The web/dist/ folder is populated by the SolidJS build step you will run in Part 11.

Create cmd/server/main.go

touch cmd/server/main.go
package main

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

	"github.com/yourname/go-notes-app/adapters/http"
	jwtadapter "github.com/yourname/go-notes-app/adapters/jwt"
	"github.com/yourname/go-notes-app/adapters/sqlite"
	"github.com/yourname/go-notes-app/application"
	"github.com/yourname/go-notes-app/config"
	"github.com/yourname/go-notes-app/web"
)

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

	// Refuse to start without a JWT secret — an empty secret would sign
	// tokens that anyone could forge.
	if cfg.JWTSecret == "" {
		slog.Error("JWT_SECRET must be set before starting the server")
		os.Exit(1)
	}

	// 2. Open the database and run migrations.
	db, err := sqlite.Open(cfg.DBPath)
	if err != nil {
		slog.Error("failed to open database", "error", err)
		os.Exit(1)
	}
	defer db.Close()

	// 3. Build the adapters (infrastructure implementations).
	userRepo := sqlite.NewUserRepo(db)
	noteRepo := sqlite.NewNoteRepo(db)
	tokenIssuer := jwtadapter.NewIssuer(cfg.JWTSecret)

	// 4. Build the use cases (application layer), injecting the adapters.
	authSvc := application.NewAuthService(userRepo, tokenIssuer)
	noteSvc := application.NewNoteService(noteRepo, userRepo)

	// 5. Build the HTTP handlers (adapter layer), injecting the use cases.
	authHandler := httpadapter.NewAuthHandler(authSvc)
	noteHandler := httpadapter.NewNoteHandler(noteSvc)

	// 6. Build the router and register all routes.
	mux := httpadapter.NewRouter(authHandler, noteHandler, tokenIssuer)

	// 7. Register the SolidJS frontend. The "/" pattern matches every path
	//    not already matched by an API route, serving the SPA files.
	mux.Handle("/", web.Handler())

	// 8. Build the HTTP server with production-safe timeouts.
	server := &http.Server{
		Addr:         fmt.Sprintf(":%s", cfg.Port),
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  120 * time.Second,
	}

	// 9. Start in a goroutine so the main goroutine can listen for shutdown signals.
	go func() {
		slog.Info("server starting", "port", cfg.Port, "env", cfg.Env)
		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			slog.Error("server error", "error", err)
			os.Exit(1)
		}
	}()

	// 10. Wait for SIGINT or SIGTERM, then shut down gracefully.
	//     Go 1.26 signal.NotifyContext integrates cleanly with context cancellation.
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	<-ctx.Done()
	slog.Info("shutdown signal received")

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

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

Read the numbered steps in main.go. They follow the dependency order: first the infrastructure (database, adapters), then the application use cases, then the HTTP layer. Each component receives only what it needs — no global variables, no init functions, no singletons.

The server timeouts are not optional. Without ReadTimeout, a slow client can hold a connection open for minutes. Without WriteTimeout, a stuck handler prevents the connection from closing. These are hardened defaults appropriate for a public-facing API.


Part 10 — The SolidJS frontend

The frontend is a single-page application built with SolidJS, TypeScript, and plain CSS. It communicates with the Go API through fetch calls. The JWT token is stored in localStorage — appropriate for this use case, and simpler than cookie management on both ends.

SolidJS is chosen here because it is small, fast, and its reactivity model is explicit: there are no hidden re-renders or stale closures. What you write is what runs.

Configure the Vite proxy

During development, the SolidJS dev server runs on port 5173 and your Go server runs on port 8080. Without a proxy, every fetch('/api/...') call would fail due to CORS. Configure Vite to forward /api requests to the Go server.

Open frontend/vite.config.ts and replace its contents:

import { defineConfig } from "vite";
import solid from "vite-plugin-solid";

export default defineConfig({
  plugins: [solid()],
  server: {
    proxy: {
      // All requests starting with /api are forwarded to the Go server.
      "/api": {
        target: "http://localhost:8080",
        changeOrigin: true,
      },
    },
  },
  build: {
    // Output to a path Go can embed. This folder lives inside frontend/
    // but is symlinked (or copied) into backend/web/dist before go build.
    outDir: "../backend/web/dist",
    emptyOutDir: true,
  },
});

The outDir points directly at backend/web/dist — the folder that Go embeds. When you run npm run build in the frontend folder, the compiled assets land exactly where go:embed expects them.

Create the API client

Create frontend/src/api.ts. This file contains all communication with the backend. Keeping it in one place means you change the base URL or headers in exactly one file when something changes.

const BASE = "/api";

// token helpers — stored in localStorage as a simple approach for this use case.
export const getToken = (): string | null => localStorage.getItem("token");
export const setToken = (t: string): void => { localStorage.setItem("token", t); };
export const clearToken = (): void => { localStorage.removeItem("token"); };

// authHeaders returns the Authorization header when a token is present.
function authHeaders(): HeadersInit {
  const token = getToken();
  return token ? { Authorization: `Bearer ${token}` } : {};
}

async function request<T>(path: string, options?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    headers: { "Content-Type": "application/json", ...authHeaders() },
    ...options,
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: "unknown error" }));
    throw new Error(err.error ?? "request failed");
  }
  return res.json() as Promise<T>;
}

// Auth
export interface AuthResponse {
  token: string;
  user: { id: number; email: string };
}

export const register = (email: string, password: string) =>
  request<AuthResponse>("/auth/register", {
    method: "POST",
    body: JSON.stringify({ email, password }),
  });

export const login = (email: string, password: string) =>
  request<AuthResponse>("/auth/login", {
    method: "POST",
    body: JSON.stringify({ email, password }),
  });

// Notes
export interface Note {
  id: number;
  owner_id: number;
  title: string;
  body: string;
  created_at: string;
  updated_at: string;
}

export const listNotes = () => request<Note[]>("/notes");
export const listShared = () => request<Note[]>("/notes/shared");
export const getNote = (id: number) => request<Note>(`/notes/${id}`);
export const createNote = (title: string, body: string) =>
  request<Note>("/notes", { method: "POST", body: JSON.stringify({ title, body }) });
export const updateNote = (id: number, title: string, body: string) =>
  request<Note>(`/notes/${id}`, { method: "PATCH", body: JSON.stringify({ title, body }) });
export const deleteNote = (id: number) =>
  request<void>(`/notes/${id}`, { method: "DELETE" });
export const shareNote = (id: number, target_user_id: number) =>
  request<{ status: string }>(`/notes/${id}/share`, {
    method: "POST",
    body: JSON.stringify({ target_user_id }),
  });

Create the main App component

Replace the contents of frontend/src/App.tsx:

import { createSignal, Show } from "solid-js";
import { getToken, clearToken } from "./api";
import AuthForm from "./components/AuthForm";
import NotesList from "./components/NotesList";
import "./App.css";

export default function App() {
  // isLoggedIn is a reactive signal. When it changes, SolidJS re-renders
  // only the parts of the DOM that depend on it.
  const [isLoggedIn, setIsLoggedIn] = createSignal(!!getToken());

  function handleLogin() {
    setIsLoggedIn(true);
  }

  function handleLogout() {
    clearToken();
    setIsLoggedIn(false);
  }

  return (
    <div class="app">
      <header class="app-header">
        <h1>Notes</h1>
        <Show when={isLoggedIn()}>
          <button onClick={handleLogout} class="btn-secondary">
            Log out
          </button>
        </Show>
      </header>

      <main>
        <Show when={!isLoggedIn()} fallback={<NotesList />}>
          <AuthForm onSuccess={handleLogin} />
        </Show>
      </main>
    </div>
  );
}

Create the AuthForm component

mkdir -p frontend/src/components
touch frontend/src/components/AuthForm.tsx
import { createSignal } from "solid-js";
import { login, register, setToken } from "../api";

interface Props {
  onSuccess: () => void;
}

export default function AuthForm(props: Props) {
  const [mode, setMode] = createSignal<"login" | "register">("login");
  const [email, setEmail] = createSignal("");
  const [password, setPassword] = createSignal("");
  const [error, setError] = createSignal("");

  async function handleSubmit(e: SubmitEvent) {
    e.preventDefault();
    setError("");

    try {
      const fn = mode() === "login" ? login : register;
      const result = await fn(email(), password());
      setToken(result.token);
      props.onSuccess();
    } catch (err: any) {
      setError(err.message ?? "Something went wrong");
    }
  }

  return (
    <div class="auth-container">
      <h2>{mode() === "login" ? "Log in" : "Create account"}</h2>
      <form onSubmit={handleSubmit} class="auth-form">
        <input
          type="email"
          placeholder="Email"
          value={email()}
          onInput={(e) => setEmail(e.currentTarget.value)}
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password()}
          onInput={(e) => setPassword(e.currentTarget.value)}
          required
        />
        {error() && <p class="error">{error()}</p>}
        <button type="submit" class="btn-primary">
          {mode() === "login" ? "Log in" : "Register"}
        </button>
      </form>
      <button
        class="btn-link"
        onClick={() => setMode(mode() === "login" ? "register" : "login")}
      >
        {mode() === "login" ? "Need an account? Register" : "Have an account? Log in"}
      </button>
    </div>
  );
}

Create the NotesList component

touch frontend/src/components/NotesList.tsx
import { createSignal, createResource, For, Show } from "solid-js";
import { listNotes, createNote, updateNote, deleteNote, shareNote, type Note } from "../api";

export default function NotesList() {
  const [notes, { refetch }] = createResource(listNotes);
  const [selected, setSelected] = createSignal<Note | null>(null);
  const [title, setTitle] = createSignal("");
  const [body, setBody] = createSignal("");
  const [shareTarget, setShareTarget] = createSignal("");
  const [error, setError] = createSignal("");

  async function handleCreate(e: SubmitEvent) {
    e.preventDefault();
    setError("");
    try {
      await createNote(title(), body());
      setTitle("");
      setBody("");
      refetch();
    } catch (err: any) {
      setError(err.message);
    }
  }

  async function handleUpdate(note: Note) {
    setError("");
    try {
      await updateNote(note.id, title(), body());
      setSelected(null);
      refetch();
    } catch (err: any) {
      setError(err.message);
    }
  }

  async function handleDelete(id: number) {
    if (!confirm("Delete this note?")) return;
    try {
      await deleteNote(id);
      refetch();
    } catch (err: any) {
      setError(err.message);
    }
  }

  async function handleShare(noteId: number) {
    const targetId = parseInt(shareTarget(), 10);
    if (!targetId) return;
    try {
      await shareNote(noteId, targetId);
      setShareTarget("");
      alert("Note shared successfully");
    } catch (err: any) {
      setError(err.message);
    }
  }

  function selectNote(note: Note) {
    setSelected(note);
    setTitle(note.title);
    setBody(note.body);
  }

  return (
    <div class="notes-layout">
      <aside class="notes-sidebar">
        <form onSubmit={handleCreate} class="create-form">
          <input
            placeholder="Note title"
            value={title()}
            onInput={(e) => setTitle(e.currentTarget.value)}
            required
          />
          <textarea
            placeholder="Write something..."
            value={body()}
            onInput={(e) => setBody(e.currentTarget.value)}
          />
          <button type="submit" class="btn-primary">Add note</button>
        </form>

        <Show when={!notes.loading} fallback={<p>Loading...</p>}>
          <ul class="note-list">
            <For each={notes()}>
              {(note) => (
                <li
                  class={`note-item ${selected()?.id === note.id ? "active" : ""}`}
                  onClick={() => selectNote(note)}
                >
                  <span class="note-title">{note.title}</span>
                  <button
                    class="btn-danger-sm"
                    onClick={(e) => { e.stopPropagation(); handleDelete(note.id); }}
                  >
                    Delete
                  </button>
                </li>
              )}
            </For>
          </ul>
        </Show>
      </aside>

      <Show when={selected()}>
        {(note) => (
          <section class="note-editor">
            <input
              value={title()}
              onInput={(e) => setTitle(e.currentTarget.value)}
              class="note-title-input"
            />
            <textarea
              value={body()}
              onInput={(e) => setBody(e.currentTarget.value)}
              class="note-body-input"
            />
            <div class="note-actions">
              <button class="btn-primary" onClick={() => handleUpdate(note())}>Save</button>
              <button class="btn-secondary" onClick={() => setSelected(null)}>Cancel</button>
            </div>
            <div class="share-section">
              <input
                type="number"
                placeholder="User ID to share with"
                value={shareTarget()}
                onInput={(e) => setShareTarget(e.currentTarget.value)}
              />
              <button class="btn-secondary" onClick={() => handleShare(note().id)}>Share</button>
            </div>
          </section>
        )}
      </Show>

      {error() && <p class="error-banner">{error()}</p>}
    </div>
  );
}

Create the CSS

Replace frontend/src/App.css with a minimal but functional stylesheet:

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

body {
  font-family: system-ui, sans-serif;
  background: #f5f5f5;
  color: #222;
  min-height: 100vh;
}

.app { display: flex; flex-direction: column; min-height: 100vh; }

.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 2rem;
  background: #1a1a2e;
  color: #fff;
}

main { flex: 1; padding: 2rem; }

/* Auth */
.auth-container { max-width: 400px; margin: 4rem auto; }
.auth-form { display: flex; flex-direction: column; gap: 0.75rem; margin: 1.5rem 0; }
.auth-form input { padding: 0.6rem 0.8rem; border: 1px solid #ccc; border-radius: 4px; }

/* Notes layout */
.notes-layout { display: grid; grid-template-columns: 320px 1fr; gap: 1.5rem; }
.notes-sidebar { background: #fff; border-radius: 8px; padding: 1rem; }
.create-form { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; }
.create-form input, .create-form textarea { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
.create-form textarea { min-height: 80px; resize: vertical; }
.note-list { list-style: none; display: flex; flex-direction: column; gap: 0.5rem; }
.note-item {
  display: flex; align-items: center; justify-content: space-between;
  padding: 0.6rem 0.8rem; background: #f0f0f0; border-radius: 4px; cursor: pointer;
}
.note-item.active { background: #d0e8ff; }
.note-title { font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Editor */
.note-editor { background: #fff; border-radius: 8px; padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; }
.note-title-input { font-size: 1.2rem; padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; }
.note-body-input { min-height: 300px; padding: 0.75rem; border: 1px solid #ddd; border-radius: 4px; resize: vertical; font-size: 0.95rem; }
.note-actions { display: flex; gap: 0.5rem; }
.share-section { display: flex; gap: 0.5rem; padding-top: 1rem; border-top: 1px solid #eee; }
.share-section input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }

/* Buttons */
.btn-primary { padding: 0.5rem 1rem; background: #2563eb; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.btn-primary:hover { background: #1d4ed8; }
.btn-secondary { padding: 0.5rem 1rem; background: #e5e7eb; color: #374151; border: none; border-radius: 4px; cursor: pointer; }
.btn-link { background: none; border: none; color: #2563eb; cursor: pointer; margin-top: 0.5rem; }
.btn-danger-sm { padding: 0.2rem 0.5rem; background: #dc2626; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 0.75rem; }

.error { color: #dc2626; font-size: 0.875rem; margin-top: 0.25rem; }
.error-banner { position: fixed; bottom: 1rem; right: 1rem; background: #dc2626; color: #fff; padding: 0.75rem 1rem; border-radius: 6px; }

Part 11 — Building and running the application

Everything is written. Now you connect the pieces, compile, and run.

Step 1: Build the SolidJS frontend

The frontend must be compiled before Go can embed it. Run from the frontend/ folder:

cd frontend
npm run build
cd ..

After this runs, you will find HTML, CSS, and JS files inside backend/web/dist/. These are the files Go will embed into the binary at compile time.

Step 2: Build the Go binary

cd backend
go build -o server ./cmd/server

If go build reports errors about missing imports, run go mod tidy first, then re-run go build.

Step 3: Start the server

Make sure your .env file is in the backend/ folder, then run:

./server
# INFO server starting port=8080 env=development

Open a browser at http://localhost:8080. You should see the SolidJS login form.

Step 4: Test every endpoint with httpie

Open a second terminal.

Register:

http POST localhost:8080/api/auth/register \
  email=alice@example.com \
  password=secret123

Copy the token field from the response and export it:

TOKEN="paste-your-token-here"

Login:

http POST localhost:8080/api/auth/login \
  email=alice@example.com \
  password=secret123

Create a note:

http POST localhost:8080/api/notes \
  "Authorization:Bearer $TOKEN" \
  title="My first note" \
  body="Go is a great language for APIs."

List your notes:

http GET localhost:8080/api/notes \
  "Authorization:Bearer $TOKEN"

Get a single note:

http GET localhost:8080/api/notes/1 \
  "Authorization:Bearer $TOKEN"

Update a note:

http PATCH localhost:8080/api/notes/1 \
  "Authorization:Bearer $TOKEN" \
  title="Updated title" \
  body="Updated content."

Register a second user and share a note with them:

http POST localhost:8080/api/auth/register \
  email=bob@example.com \
  password=secret456
# Note the user ID — should be 2

http POST localhost:8080/api/notes/1/share \
  "Authorization:Bearer $TOKEN" \
  target_user_id:=2

Note: httpie uses := for raw JSON numbers and booleans.

Log in as Bob and view shared notes:

BOB_TOKEN=$(http --body POST localhost:8080/api/auth/login \
  email=bob@example.com password=secret456 \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

http GET localhost:8080/api/notes/shared \
  "Authorization:Bearer $BOB_TOKEN"

Delete a note:

http DELETE localhost:8080/api/notes/1 \
  "Authorization:Bearer $TOKEN"
# 204 No Content

Test unauthenticated access:

http GET localhost:8080/api/notes
# 401 Unauthorized

Part 12 — Writing tests

Tests in Go are in files that end in _test.go. They use the testing package from the standard library. No test framework or assertion library is needed.

Domain tests are pure — they call Go functions and check the results. HTTP integration tests use net/http/httptest to start a real server and send real requests to it. Both approaches are standard Go practice.

Test names follow the BDD naming convention: Test_when_condition_should_behavior. A failing test with this name tells you exactly what broke and under what condition.

Test the domain

Create backend/domain/note_test.go:

touch domain/note_test.go
package domain_test

import (
	"errors"
	"testing"

	"github.com/yourname/go-notes-app/domain"
)

func TestNewNote_when_title_is_empty_should_return_validation_error(t *testing.T) {
	_, err := domain.NewNote(1, "", "some body")
	if !errors.Is(err, domain.ErrValidation) {
		t.Errorf("expected ErrValidation, got %v", err)
	}
}

func TestNewNote_when_title_exceeds_200_chars_should_return_validation_error(t *testing.T) {
	longTitle := string(make([]byte, 201))
	_, err := domain.NewNote(1, longTitle, "")
	if !errors.Is(err, domain.ErrValidation) {
		t.Errorf("expected ErrValidation, got %v", err)
	}
}

func TestNewNote_when_valid_input_should_set_owner_id(t *testing.T) {
	note, err := domain.NewNote(42, "My note", "body")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if note.OwnerID != 42 {
		t.Errorf("expected OwnerID 42, got %d", note.OwnerID)
	}
}

func TestNote_BelongsTo_when_owner_matches_should_return_true(t *testing.T) {
	note, _ := domain.NewNote(5, "title", "body")
	if !note.BelongsTo(5) {
		t.Error("expected BelongsTo(5) to be true")
	}
}

func TestNote_BelongsTo_when_different_user_should_return_false(t *testing.T) {
	note, _ := domain.NewNote(5, "title", "body")
	if note.BelongsTo(99) {
		t.Error("expected BelongsTo(99) to be false")
	}
}

Run the domain tests:

go test ./domain/... -v

Test HTTP handlers with in-memory repositories

Create a test helper that wires the full application without a real database:

touch adapters/http/testserver_test.go
package httpadapter_test

import (
	"net/http/httptest"
	"sync"
	"time"

	httpadapter "github.com/yourname/go-notes-app/adapters/http"
	jwtadapter "github.com/yourname/go-notes-app/adapters/jwt"
	"github.com/yourname/go-notes-app/application"
	"github.com/yourname/go-notes-app/domain"
	"github.com/yourname/go-notes-app/ports/output"
)

// newTestServer builds a fully wired server backed by in-memory stores.
// No file system or network is used.
func newTestServer() (*httptest.Server, func()) {
	userRepo := &memUserRepo{
		users:      map[int64]domain.User{},
		emailIndex: map[string]int64{},
	}
	noteRepo := &memNoteRepo{
		notes:  map[int64]domain.Note{},
		shares: map[int64][]int64{},
	}
	tokens := jwtadapter.NewIssuer("test-secret-only")

	authSvc := application.NewAuthService(userRepo, tokens)
	noteSvc := application.NewNoteService(noteRepo, userRepo)

	authH := httpadapter.NewAuthHandler(authSvc)
	noteH := httpadapter.NewNoteHandler(noteSvc)
	mux := httpadapter.NewRouter(authH, noteH, tokens)

	srv := httptest.NewServer(mux)
	return srv, srv.Close
}

// ── in-memory user repository ─────────────────────────────────────────────────

type memUserRepo struct {
	mu         sync.Mutex
	seq        int64
	users      map[int64]domain.User
	emailIndex map[string]int64
}

func (r *memUserRepo) Save(u domain.User) (domain.User, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.emailIndex[u.Email]; ok {
		return domain.User{}, domain.ErrConflict
	}
	r.seq++
	u.ID = r.seq
	u.CreatedAt = time.Now()
	r.users[u.ID] = u
	r.emailIndex[u.Email] = u.ID
	return u, nil
}

func (r *memUserRepo) FindByEmail(email string) (domain.User, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	id, ok := r.emailIndex[email]
	if !ok {
		return domain.User{}, domain.ErrNotFound
	}
	return r.users[id], nil
}

func (r *memUserRepo) FindByID(id int64) (domain.User, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	u, ok := r.users[id]
	if !ok {
		return domain.User{}, domain.ErrNotFound
	}
	return u, nil
}

var _ output.UserRepository = (*memUserRepo)(nil)

// ── in-memory note repository ──────────────────────────────────────────────────

type memNoteRepo struct {
	mu     sync.Mutex
	seq    int64
	notes  map[int64]domain.Note
	shares map[int64][]int64
}

func (r *memNoteRepo) Save(n domain.Note) (domain.Note, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.seq++
	n.ID = r.seq
	r.notes[n.ID] = n
	return n, nil
}

func (r *memNoteRepo) FindByID(id int64) (domain.Note, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	n, ok := r.notes[id]
	if !ok {
		return domain.Note{}, domain.ErrNotFound
	}
	return n, nil
}

func (r *memNoteRepo) FindByOwner(ownerID int64) ([]domain.Note, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []domain.Note
	for _, n := range r.notes {
		if n.OwnerID == ownerID {
			out = append(out, n)
		}
	}
	return out, nil
}

func (r *memNoteRepo) FindSharedWith(userID int64) ([]domain.Note, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []domain.Note
	for noteID, users := range r.shares {
		for _, uid := range users {
			if uid == userID {
				if n, ok := r.notes[noteID]; ok {
					out = append(out, n)
				}
			}
		}
	}
	return out, nil
}

func (r *memNoteRepo) Update(n domain.Note) (domain.Note, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.notes[n.ID]; !ok {
		return domain.Note{}, domain.ErrNotFound
	}
	r.notes[n.ID] = n
	return n, nil
}

func (r *memNoteRepo) Delete(id int64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.notes[id]; !ok {
		return domain.ErrNotFound
	}
	delete(r.notes, id)
	return nil
}

func (r *memNoteRepo) Share(noteID, userID int64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	for _, uid := range r.shares[noteID] {
		if uid == userID {
			return domain.ErrConflict
		}
	}
	r.shares[noteID] = append(r.shares[noteID], userID)
	return nil
}

var _ output.NoteRepository = (*memNoteRepo)(nil)

Create adapters/http/auth_handler_test.go:

touch adapters/http/auth_handler_test.go
package httpadapter_test

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

func TestRegister_when_valid_input_should_return_201_with_token(t *testing.T) {
	srv, done := newTestServer()
	defer done()

	body := `{"email":"alice@test.com","password":"password123"}`
	resp, err := http.Post(srv.URL+"/api/auth/register", "application/json", bytes.NewBufferString(body))
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		t.Errorf("expected 201, got %d", resp.StatusCode)
	}
	var result map[string]any
	json.NewDecoder(resp.Body).Decode(&result)
	if result["token"] == nil || result["token"] == "" {
		t.Error("expected a non-empty token in response")
	}
}

func TestRegister_when_duplicate_email_should_return_409(t *testing.T) {
	srv, done := newTestServer()
	defer done()

	body := `{"email":"dup@test.com","password":"pass"}`
	http.Post(srv.URL+"/api/auth/register", "application/json", bytes.NewBufferString(body))
	resp, _ := http.Post(srv.URL+"/api/auth/register", "application/json", bytes.NewBufferString(body))
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusConflict {
		t.Errorf("expected 409, got %d", resp.StatusCode)
	}
}

func TestLogin_when_wrong_password_should_return_401(t *testing.T) {
	srv, done := newTestServer()
	defer done()

	http.Post(srv.URL+"/api/auth/register", "application/json",
		bytes.NewBufferString(`{"email":"u@test.com","password":"correct"}`))

	resp, _ := http.Post(srv.URL+"/api/auth/login", "application/json",
		bytes.NewBufferString(`{"email":"u@test.com","password":"wrong"}`))
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d", resp.StatusCode)
	}
}

func TestNoteCreate_when_no_auth_header_should_return_401(t *testing.T) {
	srv, done := newTestServer()
	defer done()

	resp, _ := http.Post(srv.URL+"/api/notes", "application/json",
		bytes.NewBufferString(`{"title":"Test"}`))
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusUnauthorized {
		t.Errorf("expected 401, got %d", resp.StatusCode)
	}
}

Run all tests:

go test ./... -v

Closing

You now have a complete, production-ready pattern for full-stack Go applications.

The architecture separates concerns cleanly: the domain knows nothing about HTTP or SQLite; the application layer knows nothing about JSON or bcrypt’s cost parameter; the adapters know nothing about business rules. Each layer is testable in isolation, replaceable without touching the others, and readable without understanding the whole system.

This is not a toy pattern. These are the same principles used in production systems handling millions of daily requests. The difference between a toy and a production system is not the technology — it is the discipline with which you apply the same ideas at larger scale.

A few things worth continuing from here:

  • Rate limiting. Add a middleware that limits failed login attempts by IP.
  • HTTPS. Use autocert or put a reverse proxy (nginx, Caddy) in front for TLS.
  • Note search. SQLite supports full-text search via FTS5. Add a GET /api/notes?q=keyword endpoint.
  • Refresh tokens. 24-hour JWT expiry is a simplification. A production system uses short-lived access tokens and long-lived refresh tokens stored server-side.
  • Pagination. The FindByOwner query returns all notes. Add LIMIT and OFFSET parameters when note volume grows.

The foundation is solid. The next move is yours.