Architecture

Simple layers, clear structure, standard Go patterns

goilerplate follows standard Go project layout with clear separation of concerns.

Project Structure

goilerplate/
├── cmd/server/          # Main web server entry point
├── internal/            # Private application code
│   ├── handler/         # HTTP handlers (presentation layer)
│   ├── service/         # Business logic
│   ├── repository/      # Data access layer
│   ├── model/           # Data models and structs
│   ├── middleware/      # HTTP middleware
│   ├── routes/          # Route definitions
│   ├── ui/              # templ templates
│   │   ├── blocks/      # Reusable UI blocks
│   │   ├── components/  # Reusable components
│   │   ├── layouts/     # Page layouts
│   │   └── pages/       # Full page templates
│   ├── db/              # Database setup & migrations
│   ├── storage/         # S3-compatible file storage
│   ├── validation/      # Input validators
│   ├── config/          # Configuration management
│   ├── ctxkeys/         # Context key definitions
│   ├── logger/          # Structured logging
│   ├── markdown/        # Markdown processing
│   ├── app/             # App initialization
│   └── utils/           # Shared utilities
├── assets/              # Static files (CSS, JS, images)
├── content/             # Markdown the app serves (blog, docs, legal)
├── docs/                # Developer documentation (this codebase)
├── static/              # Public files (robots.txt, sitemap.xml)
├── data/                # SQLite database (gitignored)
└── AGENTS.md            # Project map & rules for AI coding agents

The Three Layers

goilerplate uses a simple layered architecture:

HTTP Request
    ↓
Handler (presentation)
    ↓
Service (business logic)
    ↓
Repository (data access)
    ↓
Database

Why Layers over DDD?

We use layer-based architecture instead of domain-driven design (DDD):

  • Simpler - Everyone understands Handler → Service → Repository
  • No circular dependencies - Dependencies only flow down, never up
  • Easier refactoring - Move functions, not entire packages
  • Clear onboarding - New devs know exactly where to look
  • Good enough - Most SaaS apps don’t need DDD complexity

DDD makes sense for large teams (10+ devs) with complex business logic. For a boilerplate? Layers are faster and clearer.

1. Handler Layer

Handles HTTP concerns:

  • Parse request
  • Validate input
  • Call service
  • Render response
func (h *BlogHandler) ListPosts(w http.ResponseWriter, r *http.Request) {
    posts, err := h.blogService.Posts()
    if err != nil {
        http.Error(w, "Failed to load posts", 500)
        return
    }
    ui.Render(w, r, pages.BlogList(posts))
}

2. Service Layer

Contains business logic:

  • Coordinate operations
  • Enforce business rules
  • Manage transactions
type AuthService struct {
    userRepo  UserRepository
    jwtSecret string
}

func (s *AuthService) Login(email, password string) (string, error) {
    user, err := s.userRepo.GetByEmail(email)
    // Authentication logic...
}

3. Repository Layer

Handles data access:

  • Database queries
  • Data mapping
  • No business logic
type UserRepository interface {
    Create(user *User) error
    ByID(id string) (*User, error)
    GetByEmail(email string) (*User, error)
}

Dependency Injection

Constructor-based injection. Services get dependencies via New*() functions:

// Handler needs service
func NewBlogHandler(service *BlogService) *BlogHandler {
    return &BlogHandler{service: service}
}

// Service needs repository
func NewAuthService(repo UserRepository, jwtSecret string) *AuthService {
    return &AuthService{
        userRepo:  repo,
        jwtSecret: jwtSecret,
    }
}

No framework, no magic. Just function parameters.

Routing

Routes are centralized in /internal/routes:

func SetupRoutes(app *app.App) http.Handler {
    mux := http.NewServeMux()

    // Blog routes (services are created once in internal/app/app.go and passed in)
    blog := handler.NewBlogHandler(app.BlogService)
    mux.HandleFunc("GET /blog", blog.ListPosts)
    mux.HandleFunc("GET /blog/{slug}", blog.ShowPost)

    return mux
}

Using stdlib net/http router - no third-party packages.

Adding New Features

Example: Adding a “products” feature

  1. Create model in /internal/model/product.go
  2. Create repository in /internal/repository/product.go, then wire it in /internal/app/app.go
  3. Create service in /internal/service/product.go, wire it in /internal/app/app.go and add it to the App struct
  4. Create handler in /internal/handler/product.go
  5. Create UI in /internal/ui/pages/products.templ
  6. Add routes in /internal/routes/routes.go (build the handler from app.ProductService)
  7. Add a migration in /internal/db/migrations/ if it needs storage
internal/
├── model/product.go
├── repository/product.go
├── service/product.go
├── handler/product.go
└── ui/pages/products.templ

Best Practices

  • Keep /internal private - All application code goes here
  • Separate concerns - Handler → Service → Repository (no shortcuts)
  • Tests alongside code - user.go + user_test.go in same directory
  • No circular dependencies - Handlers depend on services, services depend on repositories (not the other way)

When to Use Interfaces

  • Use interfaces where change is likely - Database layer (SQLite → Postgres), external APIs (Polar → Stripe), storage (R2 → S3 → DO Spaces)
  • Use concrete types where stable - Services and handlers rarely need swapping
  • Producer-side interfaces - Define at repository layer, not at every consumer (avoids duplication, easier testing)

Database Layer

  • Minimal abstraction - We use sqlx.DB for multi-database support, but write raw SQL
  • No ORM helpers - Explicit SQL is clearer than query builders (db.Get, db.NamedExec, etc.)
  • PostgreSQL-style placeholders - Use $1, $2 which sqlx auto-converts for SQLite
  • Repository pattern - Clean interface makes switching databases trivial

Security

  • Keep sensitive logic in /internal (cannot be imported externally)
  • Use environment variables for secrets (see Configuration)
  • Validate all inputs in handlers
  • Use prepared statements in repositories
  • Never commit .env files