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
- Create model in
/internal/model/product.go - Create repository in
/internal/repository/product.go, then wire it in/internal/app/app.go - Create service in
/internal/service/product.go, wire it in/internal/app/app.goand add it to theAppstruct - Create handler in
/internal/handler/product.go - Create UI in
/internal/ui/pages/products.templ - Add routes in
/internal/routes/routes.go(build the handler fromapp.ProductService) - 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
/internalprivate - All application code goes here - Separate concerns - Handler → Service → Repository (no shortcuts)
- Tests alongside code -
user.go+user_test.goin 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.DBfor 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, $2which 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
.envfiles