Skip to main content

Layer Guide

Detailed guide to each layer in a Tracks-generated application.

HTTP Layer (internal/http/)

The HTTP layer handles all web-facing concerns. It converts HTTP requests into domain operations and domain results back into HTTP responses.

Server (server.go)

Sets up the HTTP server with dependency injection:

type Server struct {
cfg *config.ServerConfig
logger interfaces.Logger
router chi.Router

// Service dependencies (injected)
healthService interfaces.HealthService
// userService interfaces.UserService (added incrementally)
}

func NewServer(cfg *config.ServerConfig, logger interfaces.Logger) *Server {
return &Server{
cfg: cfg,
logger: logger,
router: chi.NewRouter(),
}
}

// Builder pattern for dependency injection
func (s *Server) WithHealthService(svc interfaces.HealthService) *Server {
s.healthService = svc
return s
}

func (s *Server) RegisterRoutes() *Server {
registerRoutes(s)
return s
}

func (s *Server) Start(ctx context.Context) error {
// Graceful shutdown logic
}

Key Points:

  • Builder pattern allows incremental service registration
  • Graceful shutdown with context cancellation
  • No global state - everything is passed in

Routes (routes.go)

File structure:

  • internal/http/routes.go - Route registration and middleware chain
  • internal/http/routes/ - Domain-based route files (health.go, users.go, etc.) with constants and helpers

Registers routes and applies middleware chain. Routes serve HTML by default (HYPERMEDIA-first):

func registerRoutes(s *Server) {
r := s.router

// Global middleware (runs for all requests)
r.Use(middleware.RequestID)
r.Use(middleware.NewLogging(s.logger))
r.Use(middleware.Recoverer)
r.Use(middleware.CORS())
r.Use(middleware.Security())

// Health check (no auth required)
r.Get(routes.APIHealth, handlers.NewHealthHandler(s.healthService).Handle)

// User routes (HYPERMEDIA - serve HTML via templ)
userHandler := handlers.NewUserHandler(s.userService)
r.Get(routes.UserIndex, userHandler.HandleIndex) // List users
r.Get(routes.UserShow, userHandler.HandleShow) // Show user profile
r.Get(routes.UserNew, userHandler.HandleNew) // New user form
r.Post(routes.UserCreate, userHandler.HandleCreate) // Create user
r.Get(routes.UserEdit, userHandler.HandleEdit) // Edit user form
r.Post(routes.UserUpdate, userHandler.HandleUpdate) // Update user
r.Post(routes.UserDelete, userHandler.HandleDelete) // Delete user
}

Key Points:

  • Middleware order matters - RequestID first, auth last
  • Group routes with r.Route() to share middleware
  • Route patterns use route constants (not magic strings)

Route Constants (routes/)

Routes are organized by domain in separate files. Each file contains route constants, slug constants (for parameterized routes), and helper functions for type-safe URL generation.

Simple Domain (routes/health.go - API endpoint, no parameters):

package routes

const (
APIHealth = "/api/health" // JSON endpoint
)

Complex Domain (routes/users.go - HYPERMEDIA routes with helpers):

package routes

import "net/url"

// UserSlugParam is exported so handlers can extract parameters without magic strings.
// usersPath remains unexported as it's an internal routing detail.
const (
usersPath = "users"
UserSlugParam = "username"
)

// HYPERMEDIA route constants (serve HTML via templ)
const (
UserIndex = "/" + usersPath // GET /users
UserShow = "/" + usersPath + "/:" + UserSlugParam // GET /users/:username
UserNew = "/" + usersPath + "/new" // GET /users/new
UserCreate = "/" + usersPath // POST /users
UserEdit = "/" + usersPath + "/:" + UserSlugParam + "/edit" // GET /users/:username/edit
UserUpdate = "/" + usersPath + "/:" + UserSlugParam // POST /users/:username
UserDelete = "/" + usersPath + "/:" + UserSlugParam // POST /users/:username
)

// RouteURL substitutes parameters and URL-encodes values
func RouteURL(route string, params ...string) string {
// ... implementation with url.PathEscape
}

// Typed helper functions for type safety
func UserShowURL(username string) string {
return RouteURL(UserShow, UserSlugParam, username)
}

func UserEditURL(username string) string {
return RouteURL(UserEdit, UserSlugParam, username)
}
// ... other helpers

Key Benefits:

  • Domain-based organization - All user routes in one file
  • Type safety - Compile-time checks prevent typos
  • URL encoding - Automatic via RouteURL helper
  • Typed helpers - IDE autocomplete, refactoring support
  • HYPERMEDIA-first - Form routes (/new, /edit) for HTML

See the Routing Guide for complete details on domain-based routing patterns.

Handlers (handlers/)

Convert HTTP to domain operations:

type UserHandler struct {
userService interfaces.UserService
}

func NewUserHandler(userService interfaces.UserService) *UserHandler {
return &UserHandler{userService: userService}
}

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
// 1. Extract parameters - use exported constant to avoid magic strings
username := chi.URLParam(r, routes.UserSlugParam)
if username == "" {
http.Error(w, "username required", http.StatusBadRequest)
return
}

// 2. Call service
user, err := h.userService.GetByUsername(r.Context(), username)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// 3. Return response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}

Handler Responsibilities:

  • ✅ Extract and validate request parameters
  • ✅ Call service methods
  • ✅ Format responses (JSON, HTML, redirects)
  • ✅ Orchestrate multiple services for complex operations
  • NO business logic
  • NO direct database access

Middleware (middleware/)

Single-responsibility composable functions:

// Logging middleware
func NewLogging(logger interfaces.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()

logger.Info("request started",
"method", r.Method,
"path", r.URL.Path,
"remote_addr", r.RemoteAddr,
)

// Wrap response writer to capture status
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)

logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}

Middleware Best Practices:

  • One responsibility per middleware
  • Composable - can be reordered
  • Pass dependencies via closure (logger, config, etc.)
  • Use context for request-scoped values

Interfaces Package (internal/interfaces/)

The interfaces package defines contracts between layers. This is a critical architectural choice that prevents import cycles.

Why Separate Interfaces?

Problem: If interfaces are with implementations, you get import cycles:

❌ WITHOUT interfaces/ package:

handlers/ imports domain/users/
domain/users/ imports domain/posts/
domain/posts/ imports domain/users/ ← CYCLE!

Solution: Interfaces in a separate package breaks the cycle:

✅ WITH interfaces/ package:

handlers/ imports interfaces/
domain/users/ implements interfaces.UserService
domain/posts/ implements interfaces.PostService
No cycles!

Interface Organization

One file per domain:

interfaces/
├── health.go # HealthService, HealthRepository
├── user.go # UserService, UserRepository
└── post.go # PostService, PostRepository

Example (interfaces/user.go):

package interfaces

import "context"

//go:generate mockery --name=UserService --outpkg=mocks --output=../../tests/mocks

type UserService interface {
Create(ctx context.Context, req CreateUserRequest) (*User, error)
GetByID(ctx context.Context, id string) (*User, error)
List(ctx context.Context, limit, offset int) ([]*User, error)
Update(ctx context.Context, id string, req UpdateUserRequest) (*User, error)
Delete(ctx context.Context, id string) error
}

type UserRepository interface {
Insert(ctx context.Context, user *User) error
FindByID(ctx context.Context, id string) (*User, error)
FindAll(ctx context.Context, limit, offset int) ([]*User, error)
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}

Key Rules:

  • Zero implementations - only interface definitions
  • go:generate directive - for automatic mock generation
  • Context first - always first parameter
  • Return errors - explicit error handling

Interface Compliance

Ensure implementations satisfy interfaces at compile time:

// In domain/users/service.go
var _ interfaces.UserService = (*Service)(nil)

// In domain/users/repository.go
var _ interfaces.UserRepository = (*Repository)(nil)

If the interface changes and implementation doesn't match, you get a compile error.

Domain Layer (internal/domain/)

Business logic lives here, organized by domain (feature area).

Directory Structure

domain/
├── health/
│ ├── service.go
│ ├── service_test.go
│ ├── repository.go
│ └── dto.go
├── users/
│ ├── service.go
│ ├── service_test.go
│ ├── repository.go
│ ├── repository_test.go
│ └── dto.go
└── posts/
├── service.go
├── service_test.go
├── repository.go
└── dto.go

Organize by domain, not by layer - all user-related code in users/.

Service (service.go)

Contains business logic:

type Service struct {
repo interfaces.UserRepository
}

func NewService(repo interfaces.UserRepository) *Service {
return &Service{repo: repo}
}

func (s *Service) Create(ctx context.Context, req CreateUserRequest) (*User, error) {
if req.Age < 18 {
return nil, ErrUserTooYoung
}

user := &User{
ID: uuid.New().String(),
Name: req.Name,
Email: req.Email,
Age: req.Age,
CreatedAt: time.Now(),
}

if err := s.repo.Insert(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}

return user, nil
}

Service Responsibilities:

  • ✅ Business rules and validations
  • ✅ Coordinate repository calls
  • ✅ Transaction boundaries
  • NO HTTP knowledge (no http.Request, http.Response)
  • NO direct database access (use repository)

Repository (repository.go)

Wraps SQLC-generated code:

type Repository struct {
db *sql.DB
queries *db.Queries
}

func NewRepository(database *sql.DB) *Repository {
return &Repository{
db: database,
queries: db.New(database),
}
}

func (r *Repository) Insert(ctx context.Context, user *User) error {
params := db.CreateUserParams{
ID: user.ID,
Name: user.Name,
Email: user.Email,
Age: int32(user.Age),
CreatedAt: user.CreatedAt,
}

if err := r.queries.CreateUser(ctx, params); err != nil {
return fmt.Errorf("creating user: %w", err)
}

return nil
}

func (r *Repository) FindByID(ctx context.Context, id string) (*User, error) {
dbUser, err := r.queries.GetUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("getting user: %w", err)
}

return &User{
ID: dbUser.ID,
Name: dbUser.Name,
Email: dbUser.Email,
Age: int(dbUser.Age),
CreatedAt: dbUser.CreatedAt,
}, nil
}

Repository Responsibilities:

  • ✅ Wrap SQLC-generated queries
  • ✅ Convert between SQLC types and domain types
  • ✅ Handle sql.ErrNoRows → domain errors
  • NO business logic
  • NO manual SQL (use SQLC)

DTOs (dto.go)

Request/response data transfer objects:

type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
}

type UpdateUserRequest struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Age *int `json:"age,omitempty"`
}

type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
CreatedAt time.Time `json:"created_at"`
}

Why separate DTOs?

  • HTTP layer uses DTOs (JSON tags, validation)
  • Domain layer uses entities (business logic)
  • Decouples HTTP representation from domain model

Database Layer (internal/db/)

Manages database connections and SQL queries.

Connection Setup (db.go)

func New(ctx context.Context, cfg config.DatabaseConfig) (*sql.DB, error) {
db, err := sql.Open(cfg.Driver, cfg.URL)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}

db.SetMaxOpenConns(cfg.MaxOpenConns)
db.SetMaxIdleConns(cfg.MaxIdleConns)
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)

if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}

return db, nil
}

Migrations (migrations/)

Goose SQL migrations with timestamp prefixes:

migrations/
├── 20250108120000_create_users_table.sql
└── 20250108120100_create_posts_table.sql

Example migration:

-- +goose Up
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
age INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- +goose Down
DROP TABLE users;

Queries (queries/)

Hand-written SQL queries for SQLC:

-- name: GetUser :one
SELECT id, name, email, age, created_at
FROM users
WHERE id = ?;

-- name: ListUsers :many
SELECT id, name, email, age, created_at
FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?;

-- name: CreateUser :exec
INSERT INTO users (id, name, email, age, created_at)
VALUES (?, ?, ?, ?, ?);

SQLC generates type-safe Go code from these queries.

Generated Code (generated/)

DO NOT EDIT - Generated by SQLC from queries/*.sql:

// Code generated by sqlc. DO NOT EDIT.

type GetUserRow struct {
ID string
Name string
Email string
Age int32
CreatedAt time.Time
}

func (q *Queries) GetUser(ctx context.Context, id string) (GetUserRow, error) {
row := q.db.QueryRowContext(ctx, getUser, id)
var i GetUserRow
err := row.Scan(&i.ID, &i.Name, &i.Email, &i.Age, &i.CreatedAt)
return i, err
}

Dependency Injection in main.go

The cmd/server/main.go file wires everything together:

func main() {
ctx := context.Background()

cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}

logger := logging.New(cfg.Logging.Level)

// TRACKS:DB:BEGIN
database, err := db.New(ctx, cfg.Database)
if err != nil {
logger.Fatal("failed to connect to database", "error", err)
}
defer database.Close()
// TRACKS:DB:END

// TRACKS:REPOSITORIES:BEGIN
healthRepo := health.NewRepository(database)
// userRepo := users.NewRepository(database) (added incrementally)
// TRACKS:REPOSITORIES:END

// TRACKS:SERVICES:BEGIN
healthService := health.NewService(healthRepo)
// userService := users.NewService(userRepo) (added incrementally)
// TRACKS:SERVICES:END

srv := http.NewServer(&cfg.Server, logger).
WithHealthService(healthService).
// WithUserService(userService). (added incrementally)
RegisterRoutes()

if err := srv.Start(ctx); err != nil {
logger.Fatal("server error", "error", err)
}
}

Marker comments (// TRACKS:X:BEGIN / // TRACKS:X:END) enable incremental code generation.

Next Steps

See Also