Common Patterns
Learn how to extend and enhance Tracks-generated applications.
Adding a New Domain
Future versions of Tracks will include tracks generate resource, but for now, follow the health check pattern.
Step 1: Define Interfaces
Create internal/interfaces/user.go:
package interfaces
import "context"
//go:generate mockery --name=UserService --outpkg=mocks --output=../../tests/mocks
//go:generate mockery --name=UserRepository --outpkg=mocks --output=../../tests/mocks
type UserService interface {
Create(ctx context.Context, name, email string) (*User, error)
GetByID(ctx context.Context, id string) (*User, error)
List(ctx context.Context) ([]*User, error)
}
type UserRepository interface {
Insert(ctx context.Context, user *User) error
FindByID(ctx context.Context, id string) (*User, error)
FindAll(ctx context.Context) ([]*User, error)
}
type User struct {
ID string
Name string
Email string
}
Step 2: Create Migration
Create internal/db/migrations/YYYYMMDDHHMMSS_create_users.sql:
-- +goose Up
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
-- +goose Down
DROP TABLE users;
Run migration:
make db-migrate
Step 3: Add SQL Queries
Create internal/db/queries/users.sql:
-- name: GetUser :one
SELECT id, name, email, created_at
FROM users
WHERE id = ?;
-- name: ListUsers :many
SELECT id, name, email, created_at
FROM users
ORDER BY created_at DESC;
-- name: CreateUser :exec
INSERT INTO users (id, name, email, created_at)
VALUES (?, ?, ?, ?);
Generate code:
make generate
Step 4: Implement Repository
Create internal/domain/users/repository.go:
package users
import (
"context"
"database/sql"
"fmt"
"github.com/youruser/yourproject/internal/db"
"github.com/youruser/yourproject/internal/interfaces"
)
var _ interfaces.UserRepository = (*Repository)(nil)
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 *interfaces.User) error {
params := db.CreateUserParams{
ID: user.ID,
Name: user.Name,
Email: user.Email,
CreatedAt: time.Now(),
}
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) (*interfaces.User, error) {
row, 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 &interfaces.User{
ID: row.ID,
Name: row.Name,
Email: row.Email,
}, nil
}
Step 5: Implement Service
Create internal/domain/users/service.go:
package users
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/youruser/yourproject/internal/interfaces"
)
var _ interfaces.UserService = (*Service)(nil)
type Service struct {
repo interfaces.UserRepository
}
func NewService(repo interfaces.UserRepository) *Service {
return &Service{repo: repo}
}
func (s *Service) Create(ctx context.Context, name, email string) (*interfaces.User, error) {
// Validation
if name == "" || email == "" {
return nil, ErrInvalidInput
}
user := &interfaces.User{
ID: uuid.New().String(),
Name: name,
Email: email,
}
if err := s.repo.Insert(ctx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
return user, nil
}
Step 6: Add Handler
Create internal/http/handlers/user.go:
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/youruser/yourproject/internal/interfaces"
)
type UserHandler struct {
userService interfaces.UserService
}
func NewUserHandler(userService interfaces.UserService) *UserHandler {
return &UserHandler{userService: userService}
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
user, err := h.userService.Create(r.Context(), req.Name, req.Email)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
Step 7: Register Routes (Domain-Based)
Create internal/http/routes/users.go with domain-specific routes:
package routes
const userSlug = "users"
// HYPERMEDIA routes serve HTML via templ (default pattern)
const (
UserIndex = "/" + userSlug
UserShow = "/" + userSlug + "/:" + userSlug
UserNew = "/" + userSlug + "/new"
UserCreate = "/" + userSlug
UserEdit = "/" + userSlug + "/:" + userSlug + "/edit"
UserUpdate = "/" + userSlug + "/:" + userSlug
)
func UserShowURL(username string) string {
return RouteURL(UserShow, userSlug, username)
}
// ... other helpers
Update internal/http/routes.go:
func registerRoutes(s *Server) {
r := s.router
// ... existing middleware ...
// Health (API endpoint)
r.Get(routes.APIHealth, handlers.NewHealthHandler(s.healthService).Handle)
// Users (HYPERMEDIA routes - serve HTML)
userHandler := handlers.NewUserHandler(s.userService)
r.Get(routes.UserIndex, userHandler.HandleIndex)
r.Get(routes.UserShow, userHandler.HandleShow)
r.Get(routes.UserNew, userHandler.HandleNew)
r.Post(routes.UserCreate, userHandler.HandleCreate)
}
See Routing Guide for complete domain-based routing patterns.
Step 8: Wire Dependencies
Update cmd/server/main.go:
// TRACKS:REPOSITORIES:BEGIN
healthRepo := health.NewRepository(database)
userRepo := users.NewRepository(database)
// TRACKS:REPOSITORIES:END
// TRACKS:SERVICES:BEGIN
healthService := health.NewService(healthRepo)
userService := users.NewService(userRepo)
// TRACKS:SERVICES:END
srv := http.NewServer(&cfg.Server, logger).
WithHealthService(healthService).
WithUserService(userService).
RegisterRoutes()
Step 9: Generate Mocks
make generate-mocks
This creates tests/mocks/mock_UserService.go and mock_UserRepository.go.
Cross-Domain Orchestration
Handlers can use multiple services from different domains.
Example: Dashboard handler using multiple services
type DashboardHandler struct {
userService interfaces.UserService
postService interfaces.PostService
statsService interfaces.StatsService
}
func NewDashboardHandler(
userService interfaces.UserService,
postService interfaces.PostService,
statsService interfaces.StatsService,
) *DashboardHandler {
return &DashboardHandler{
userService: userService,
postService: postService,
statsService: statsService,
}
}
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Get current user
user, err := h.userService.GetCurrent(ctx)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// Get user's recent posts
posts, err := h.postService.ListByAuthor(ctx, user.ID, 5)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Get user stats
stats, err := h.statsService.GetForUser(ctx, user.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
response := DashboardResponse{
User: user,
Posts: posts,
Stats: stats,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
Key Point: Handlers orchestrate, services contain logic. This is safe because all dependencies use interfaces.
Adding Middleware
Middleware are composable functions that wrap HTTP handlers.
Example: Rate limiting middleware
package middleware
import (
"net/http"
"sync"
"time"
)
type RateLimiter struct {
mu sync.Mutex
requests map[string][]time.Time
limit int
window time.Duration
}
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
return &RateLimiter{
requests: make(map[string][]time.Time),
limit: limit,
window: window,
}
}
func (rl *RateLimiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
windowStart := now.Add(-rl.window)
// Get recent requests for this IP
requests := rl.requests[ip]
// Remove old requests
var recent []time.Time
for _, t := range requests {
if t.After(windowStart) {
recent = append(recent, t)
}
}
// Check limit
if len(recent) >= rl.limit {
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
// Add this request
recent = append(recent, now)
rl.requests[ip] = recent
next.ServeHTTP(w, r)
})
}
Add to routes:
func registerRoutes(s *Server) {
r := s.router
// Global middleware
r.Use(middleware.RequestID)
r.Use(middleware.NewLogging(s.logger))
r.Use(middleware.NewRateLimiter(100, time.Minute).Middleware) // NEW
// ... routes ...
}
Transaction Boundaries
For operations spanning multiple repositories, use transactions.
Example: Create user with initial profile
func (s *Service) CreateUserWithProfile(ctx context.Context, req CreateUserRequest) (*User, error) {
// Start transaction
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("starting transaction: %w", err)
}
defer tx.Rollback() // No-op if tx.Commit() succeeds
// Create user
user := &User{
ID: uuid.New().String(),
Name: req.Name,
Email: req.Email,
}
if err := s.userRepo.InsertTx(ctx, tx, user); err != nil {
return nil, fmt.Errorf("inserting user: %w", err)
}
// Create profile
profile := &Profile{
UserID: user.ID,
Bio: req.Bio,
}
if err := s.profileRepo.InsertTx(ctx, tx, profile); err != nil {
return nil, fmt.Errorf("inserting profile: %w", err)
}
// Commit
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("committing transaction: %w", err)
}
return user, nil
}
Repository with transaction support:
func (r *Repository) InsertTx(ctx context.Context, tx *sql.Tx, user *User) error {
queries := db.New(tx) // Use transaction instead of database
// ... rest of insert logic
}
Complete Example: Get User Profile
Here's the complete flow from HTTP request to database query.
Request
GET /u/johndoe HTTP/1.1
Handler
// Route is registered using domain route constant: routes.UserShow
func (h *UserHandler) HandleShow(w http.ResponseWriter, r *http.Request) {
username := chi.URLParam(r, routes.UserSlugParam) // No magic strings - use exported constant
user, err := h.userService.GetByUsername(r.Context(), username)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// Use helper function to generate edit URL for template
editURL := routes.UserEditURL(user.Username)
// Render HYPERMEDIA response (HTML via templ)
views.UserProfile(user, editURL).Render(r.Context(), w)
}
Service
func (s *Service) GetByUsername(ctx context.Context, username string) (*interfaces.User, error) {
if username == "" {
return nil, ErrInvalidUsername
}
return s.repo.FindByUsername(ctx, username)
}
Repository
func (r *Repository) FindByUsername(ctx context.Context, username string) (*interfaces.User, error) {
row, err := r.queries.GetUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("getting user: %w", err)
}
return &interfaces.User{
ID: row.ID,
Username: row.Username,
Name: row.Name,
Email: row.Email,
}, nil
}
SQLC Query
-- name: GetUserByUsername :one
SELECT id, username, name, email FROM users WHERE username = ?;
Generated Code
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) {
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
var i GetUserByUsernameRow
err := row.Scan(&i.ID, &i.Username, &i.Name, &i.Email)
return i, err
}
Best Practices
DO
- ✅ Use dependency injection for all dependencies
- ✅ Define interfaces in
internal/interfaces/ - ✅ Pass context as first parameter
- ✅ Wrap errors with
%wfor error chains - ✅ Use route constants instead of magic strings
- ✅ Keep handlers thin (orchestration only)
- ✅ Put business logic in services
- ✅ Use SQLC for all database queries
- ✅ Generate mocks after interface changes
- ✅ Write tests before implementation
DON'T
- ❌ Store context in struct fields
- ❌ Use global variables or singletons
- ❌ Put business logic in handlers
- ❌ Define interfaces in implementation packages
- ❌ Use string literals for route paths
- ❌ Write raw SQL in repository methods
- ❌ Skip error wrapping
- ❌ Ignore
golangci-lintwarnings - ❌ Commit without running tests
Troubleshooting
Import cycle detected
Problem:
import cycle not allowed
package yourmodule/internal/http/handlers
imports yourmodule/internal/domain/users
imports yourmodule/internal/domain/posts
imports yourmodule/internal/domain/users
Solution: Move interfaces to internal/interfaces/. Handlers import interfaces, services implement them.
Mock generation fails
Problem:
$ make generate-mocks
Error: could not import yourmodule/internal/interfaces
Solution:
- Ensure interfaces package has no implementation code
- Run
go mod tidy - Check
//go:generatedirectives are correct - Run
make generate-mocksagain
Tests fail after interface change
Problem: Changed interface signature, tests now fail.
Solution:
- Update interface in
internal/interfaces/ - Update implementation in service/repository
- Regenerate mocks:
make generate-mocks - Update test code to match new signature
Route not found
Problem: Getting 404 for a route you just added.
Solution:
- Check route is registered in
routes.go - Verify route constant matches pattern
- Check middleware isn't blocking the route
- Use
chi.Walk()to debug registered routes:
chi.Walk(r, func(method, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
fmt.Printf("%s %s\n", method, route)
return nil
})
Next Steps
- Architecture Overview - Core principles
- Layer Guide - Deep dive on each layer
- Routing Guide - HYPERMEDIA-first routing and domain-based organization
- Testing - Testing strategies
See Also
- CLI: tracks new - Creating projects