A production-ready Go backend template built on the standard library net/http. It implements a strict layered architecture — models, repositories, services, domains — wired together by a central app container with full support for dependency injection, generated mocks, and real-Postgres integration tests.
Two conventions drive the whole design and are worth stating up front:
- Interfaces live in
models/, not next to their implementation. Themodelspackage has zero internal imports, so every other layer depends only on it for contracts. Services and handlers never import the concreterepositories/servicespackages just to name a type — which is what makes mocking and swapping implementations trivial and cycle-free. - Domain errors are typed sentinels, mapped to HTTP once. Repositories/services return typed errors (
*models.ErrNotFound,*models.ErrConflict,*models.ErrValidation); a singlehttputils.FromDomainErrturns them into status codes. Handlers never hand-pick a 404 vs 400.
This template is designed to be cloned and extended. The tasks domain is the reference implementation. Every new feature you add should follow the same patterns exactly.
- Architecture Overview
- Layer Rules
- Domain Errors
- Project Structure
- Adding a New Domain — Step by Step
- Testing
- Configuration
- Getting Started
- Makefile Commands
- Tech Stack
- Deployment
Every HTTP request flows through the same pipeline without exception:
HTTP Request
|
v
+--------------------+
| Middleware Stack | Logger -> CORS -> Recovery
+--------------------+
|
v
+--------------------+
| Domain Handler | Parse input, call service, write response
+--------------------+
|
v
+--------------------+
| Service | Business logic, validation, orchestration
+--------------------+
|
v
+--------------------+
| Repository | SQL queries, returns models
+--------------------+
|
v
+--------------------+
| PostgreSQL |
+--------------------+
Dependencies always point inward. Outer layers depend on inner ones, never the reverse.
cmd/main.go
|
v
app/app.go (wires everything)
|
+---> config/
+---> database/
+---> models/ <--- structs, DTOs, typed errors, AND the layer interfaces
+---> repositories/ <--- implements models.*Repository
+---> services/ <--- implements models.*Service, depends on models.*Repository
+---> domains/ <--- depends on models.*Service
+---> middleware/
Every interface (models.TaskRepository, models.TaskService) lives in models/, which imports nothing internal. repositories/ and services/ implement those interfaces; services/ and domains/ consume them — but nobody imports a sibling concrete package just to name a type. A repository cannot call a service. A service cannot import net/http.
This is the most important section. Violating these rules breaks the architecture and makes the codebase untestable.
Purpose: Data structures, request DTOs, typed errors, and the layer interfaces — everything shared across all layers. This package imports nothing internal.
Rules:
- Pure structs with
db,jsonfield tags. - Typed constants and enums for any status or category field — never raw strings.
- Pointer types (
*time.Time,*string) for nullable database columns. - Define the repository and service interfaces here — not next to their implementation. Attach a
//go:generatemockgen directive to each so mocks stay in sync. - Request DTOs carry their own
.Validate() errorright next to the struct, returning*ErrValidation. Handlers call it immediately after decoding; validation logic never lives inline in a handler body. - No methods that contain business logic or call other internal packages. (Enum
.Valid()helpers and DTO.Validate()/.ToModel()mappers are fine — they are pure and self-contained.)
// Typed constant enum + a pure validity helper
type TaskStatus string
const (
TaskStatusPending TaskStatus = "PENDING"
TaskStatusInProgress TaskStatus = "IN_PROGRESS"
TaskStatusCompleted TaskStatus = "COMPLETED"
)
func (s TaskStatus) Valid() bool {
switch s {
case TaskStatusPending, TaskStatusInProgress, TaskStatusCompleted:
return true
default:
return false
}
}
type Task struct {
ID string `db:"id" json:"id"`
Title string `db:"title" json:"title"`
Status TaskStatus `db:"status" json:"status"`
CreatedAt *time.Time `db:"created_at" json:"created_at"` // pointer = nullable
}
// Request DTO with validation living on the model.
type CreateTaskRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Status TaskStatus `json:"status"`
OrgID string `json:"org_id"`
}
func (r CreateTaskRequest) Validate() error {
details := map[string]any{}
if strings.TrimSpace(r.Title) == "" {
details["title"] = "title is required"
}
if strings.TrimSpace(r.OrgID) == "" {
details["org_id"] = "org_id is required"
}
if r.Status != "" && !r.Status.Valid() {
details["status"] = "invalid status"
}
if len(details) > 0 {
return &ErrValidation{Message: "invalid task request", Details: details}
}
return nil
}
func (r CreateTaskRequest) ToTask() *Task {
return &Task{Title: r.Title, Description: r.Description, Status: r.Status, OrgID: r.OrgID}
}
// The layer interfaces live here too, with mockgen directives.
//go:generate go tool mockgen -destination mocks/mock_task_repository.go -package mocks github.com/aviorp/go-template/models TaskRepository
//go:generate go tool mockgen -destination mocks/mock_task_service.go -package mocks github.com/aviorp/go-template/models TaskService
type TaskRepository interface {
GetAll(orgID string) ([]Task, error)
GetByID(id string) (*Task, error)
Count(orgID string) (int, error)
Create(task *Task) error
Update(id string, task *Task) error
Delete(id string) error
}
type TaskService interface {
GetTasks(orgID string, page, limit int) (map[string]any, error)
GetTaskByID(id string) (*Task, error)
CreateTask(task *Task) error
UpdateTask(id string, task *Task) error
DeleteTask(id string) error
}Never:
- Hardcode status strings as bare string literals anywhere else in the codebase.
- Define a repository/service interface inside
repositories/orservices/. - Add methods that call other internal packages.
Purpose: The only layer that talks to the database.
Rules:
- Implement
models.TaskRepository— never define the interface here. - The constructor returns the interface (
models.TaskRepository), not the concrete struct — the guardvar _ models.TaskRepository = (*SQLTaskRepository)(nil)keeps it honest. - Use
sqlxfor queries (Select,Get,NamedExec). - Return
models.*types, never rawsql.Rowsormap[string]any. - A missing single row returns a typed
*models.ErrNotFound(viaerrors.Is(err, sql.ErrNoRows)), so the HTTP layer maps it to a 404. An emptyGetAlllist is not an error — return the empty slice. - Wrap all other errors with context:
fmt.Errorf("getting task by id: %w", err). - One repository per database table/entity.
// The interface lives in models/ — here we only implement it.
var _ models.TaskRepository = (*SQLTaskRepository)(nil)
type SQLTaskRepository struct {
db *sqlx.DB
}
// Constructor returns the interface type.
func NewTaskRepository(db *sqlx.DB) models.TaskRepository {
return &SQLTaskRepository{db: db}
}
func (r *SQLTaskRepository) GetByID(id string) (*models.Task, error) {
var task models.Task
const query = `SELECT ... FROM tasks WHERE id = $1`
if err := r.db.Get(&task, query, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &models.ErrNotFound{Entity: "task", ID: id} // -> 404
}
return nil, fmt.Errorf("getting task by id: %w", err)
}
return &task, nil
}Never:
- Define the repository interface here — it belongs in
models/. - Import
net/http. - Call a service.
- Contain conditional business logic (that belongs in the service).
- Return raw
maptypes from queries.
Purpose: All business logic lives here.
Rules:
- Implement
models.TaskService— never define the interface here. - Accept the repository as
models.TaskRepositoryvia constructor injection — never a concrete type, never therepositoriespackage. - The constructor returns the interface (
models.TaskService); the implementation struct is unexported. - Implement all defaulting, orchestration, and business-rule validation logic.
- Wrap errors with
%w— this preserves the typed sentinel soerrors.Asstill finds*models.ErrNotFoundafter the wrap. - Add the guard
var _ models.TaskService = (*taskService)(nil).
// The interface lives in models/ — here we only implement it.
var _ models.TaskService = (*taskService)(nil)
type taskService struct {
repo models.TaskRepository // interface, injected
}
func NewTaskService(repo models.TaskRepository) models.TaskService {
return &taskService{repo: repo}
}
// Business logic lives here, not in the handler.
func (s *taskService) CreateTask(task *models.Task) error {
if task.Status == "" {
task.Status = models.TaskStatusPending // default status
}
if err := s.repo.Create(task); err != nil {
return fmt.Errorf("creating task: %w", err)
}
return nil
}Never:
- Define the service interface here, or import the
repositoriespackage. - Import
net/httpor touchhttp.Request/http.ResponseWriter. - Call
sqlxor open database connections directly. - Read from environment variables.
Purpose: HTTP handlers. Translate between HTTP and the service layer.
Rules:
- Depend on
models.TaskServicevia constructor injection — the handler never imports theservicespackage. - Each handler: parse input, validate the DTO, call one service method, write the response. Nothing else.
- Decode request bodies into a
models.*RequestDTO and call.Validate(), then map to the entity with.ToTask(). Do not decode straight intomodels.Task. - Forward any service/domain error through
httputils.FromDomainErr(w, err)— never hand-pick a status code for a business error. Reserve rawhttputils.WriteErrorfor input-shape problems the handler owns (missing query param, unparseable ID, malformed JSON). - Register routes via
RegisterRoutes(mux *http.ServeMux); applymiddleware.ClerkMiddlewareto authenticated routes there.
type TaskHandler struct {
service models.TaskService // interface from models, injected
}
func NewTaskHandler(service models.TaskService) *TaskHandler {
return &TaskHandler{service: service}
}
func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
var req models.CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputils.WriteError(w, http.StatusBadRequest, "Invalid request payload")
return
}
if err := req.Validate(); err != nil {
httputils.FromDomainErr(w, err) // *ErrValidation -> 400 (+details)
return
}
task := req.ToTask()
task.ID = uuid.New().String()
if err := h.service.CreateTask(task); err != nil {
httputils.FromDomainErr(w, err) // sentinel -> right status, else 500
return
}
httputils.WriteJSON(w, http.StatusCreated, task)
}Never:
- Import
sqlx/servicesor call a repository directly. - Contain business logic (status defaulting, calculations, validations beyond input shape).
- Copy-paste an error type-switch into a handler — that logic lives once in
FromDomainErr. - Write raw JSON strings — always use
httputils.
Purpose: Cross-cutting HTTP concerns applied to every request or to specific routes.
Global middleware (applied to the entire server via CreateStack):
| Middleware | What it does |
|---|---|
Logger |
Logs method, path, status code, and duration for every request |
Cors |
Sets CORS headers; handles OPTIONS preflight with 204 |
RecoveryMiddleware |
Catches panics, logs the stack trace, responds with 500 |
Route-level middleware:
| Middleware | What it does |
|---|---|
ClerkMiddleware |
Validates the Clerk JWT, fetches the user, injects user_id into the context |
Composing middleware via CreateStack:
// middlewares are applied outermost-first
stack := middleware.CreateStack(
middleware.Logger,
middleware.Cors,
middleware.RecoveryMiddleware,
)
server := &http.Server{
Handler: stack(mux),
}CreateStack iterates in reverse so that the first argument wraps all subsequent ones. The Logger runs first (outermost), so it sees the final status code written by any inner middleware or handler.
Purpose: The single place where every dependency is created and wired together.
Key patterns:
1. Functional options for dependency injection
type AppOption func(*App)
func WithTaskRepository(repo models.TaskRepository) AppOption {
return func(a *App) { a.taskRepo = repo }
}
// In tests (mocks are generated into models/mocks):
ctrl := gomock.NewController(t)
a := NewApp(cfg,
WithTaskRepository(mocks.NewMockTaskRepository(ctrl)),
WithTaskService(mocks.NewMockTaskService(ctrl)),
)2. Nil-guard pattern in init methods
Injected mocks are never overwritten. If a field is already set (via an option), the init method skips it.
func (a *App) InitRepositories() error {
if a.taskRepo == nil { // only create if not already injected
a.taskRepo = repositories.NewTaskRepository(a.DB)
}
return nil
}3. Ordered initialization chain
InitServerConfig → InitDB → InitRepositories → InitServices → InitDomains
Each step depends on the previous one. This order is enforced by Initialize(), which calls each step sequentially and aborts on the first error.
4. Graceful shutdown
Start() blocks on OS signal (SIGINT, SIGTERM). On signal, it calls server.Shutdown with a 5-second timeout and then closes the database connection pool.
Business and lookup failures are typed structs in models/errors.go, not bare errors.New(...) strings and not per-handler string matching. Three sentinels ship with the template; add more as new failure modes appear.
// models/errors.go
type ErrNotFound struct{ Entity, ID string }
func (e *ErrNotFound) Error() string { return fmt.Sprintf("%s not found: %s", e.Entity, e.ID) }
type ErrConflict struct{ Entity, Message string }
func (e *ErrConflict) Error() string { return e.Message }
type ErrValidation struct {
Message string
Details map[string]any
}
func (e *ErrValidation) Error() string { return e.Message }Producing them: repositories return *ErrNotFound on a missing row (see the Repositories rules); DTO .Validate() methods return *ErrValidation; services can return *ErrConflict on a uniqueness/state violation. Services wrap with %w, which keeps the sentinel discoverable through errors.As.
Mapping them — exactly once. httputils.FromDomainErr is the single place that turns a domain error into an HTTP status. Every handler forwards through it; no handler re-derives a status code.
func FromDomainErr(w http.ResponseWriter, err error) {
var nf *models.ErrNotFound
if errors.As(err, &nf) {
WriteError(w, http.StatusNotFound, nf.Error())
return
}
var c *models.ErrConflict
if errors.As(err, &c) {
WriteError(w, http.StatusConflict, c.Error())
return
}
var v *models.ErrValidation
if errors.As(err, &v) {
if len(v.Details) > 0 {
WriteErrorWithDetails(w, http.StatusBadRequest, v.Error(), v.Details)
return
}
WriteError(w, http.StatusBadRequest, v.Error())
return
}
// Unrecognised -> 500, without leaking internals.
WriteError(w, http.StatusInternalServerError, "Internal server error")
}| Sentinel | HTTP status | Notes |
|---|---|---|
*ErrNotFound |
404 |
Missing entity |
*ErrConflict |
409 |
Uniqueness / state violation |
*ErrValidation |
400 |
Includes a details map when populated |
| anything else | 500 |
Generic message, no internal detail |
Adding a new failure mode = add a sentinel type in models/errors.go and one more errors.As branch here. Never elsewhere.
go-template/
├── cmd/
│ └── main.go # Entry point: load config, build app, start
│
├── app/
│ ├── app.go # App container: wiring, init chain, graceful shutdown
│ └── app_test.go # Tests for DI options and init order
│
├── config/
│ └── config.go # Env-var config struct (go-envconfig)
│
├── database/
│ └── database.go # Connection pool setup + health check
│
├── models/
│ ├── task.go # Task struct, DTOs+Validate, TaskStatus consts, layer interfaces + go:generate
│ ├── errors.go # Typed sentinel errors (ErrNotFound/ErrConflict/ErrValidation)
│ └── mocks/ # mockgen-generated mocks (go generate ./...)
│ ├── mock_task_repository.go
│ └── mock_task_service.go
│
├── repositories/
│ └── task.go # SQLTaskRepository (implements models.TaskRepository)
│
├── services/
│ ├── task.go # taskService (implements models.TaskService)
│ └── task_test.go # Service unit tests with generated mock repository
│
├── domains/
│ ├── tasks.go # TaskHandler, RegisterRoutes, HTTP handlers
│ └── tasks_test.go # Handler tests with httptest + generated mock service
│
├── tests/
│ └── integration/
│ └── task_repository_test.go # Real-Postgres repo tests (//go:build integration)
│
├── middleware/
│ ├── stack.go # CreateStack composer
│ ├── auth.go # ClerkMiddleware (JWT validation + user fetch)
│ ├── cors.go # CORS headers + OPTIONS preflight
│ ├── logger.go # Request/response logger
│ └── recover.go # Panic recovery
│
├── utils/
│ └── httputils/
│ └── httputils.go # WriteJSON, WriteError, WriteErrorWithDetails, FromDomainErr
│
├── logger/
│ └── logger.go # Logger interface + zerolog implementation
│
├── Dockerfile # Multi-stage build (golang:1.24-alpine → alpine)
├── docker-compose.test.yml # Disposable Postgres for integration tests (port 5433)
├── Makefile # generate, build, run, test, test-integration, docker targets
├── go.mod
└── .env.example
This walkthrough adds a projects domain. Replace "project"/"Project" with your entity name throughout.
models/project.go holds the entity, its request DTO(s), and the repository/service interfaces with their mockgen directives. Typed errors go in the shared models/errors.go.
package models
import (
"strings"
"time"
)
type ProjectStatus string
const (
ProjectStatusActive ProjectStatus = "ACTIVE"
ProjectStatusArchived ProjectStatus = "ARCHIVED"
)
func (s ProjectStatus) Valid() bool {
switch s {
case ProjectStatusActive, ProjectStatusArchived:
return true
default:
return false
}
}
type Project struct {
ID string `db:"id" json:"id"`
Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"`
Status ProjectStatus `db:"status" json:"status"`
OrgID string `db:"org_id" json:"org_id"`
CreatedAt *time.Time `db:"created_at" json:"created_at"`
UpdatedAt *time.Time `db:"updated_at" json:"updated_at"`
}
type CreateProjectRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Status ProjectStatus `json:"status"`
OrgID string `json:"org_id"`
}
func (r CreateProjectRequest) Validate() error {
details := map[string]any{}
if strings.TrimSpace(r.Name) == "" {
details["name"] = "name is required"
}
if strings.TrimSpace(r.OrgID) == "" {
details["org_id"] = "org_id is required"
}
if r.Status != "" && !r.Status.Valid() {
details["status"] = "invalid status"
}
if len(details) > 0 {
return &ErrValidation{Message: "invalid project request", Details: details}
}
return nil
}
func (r CreateProjectRequest) ToProject() *Project {
return &Project{Name: r.Name, Description: r.Description, Status: r.Status, OrgID: r.OrgID}
}
//go:generate go tool mockgen -destination mocks/mock_project_repository.go -package mocks github.com/aviorp/go-template/models ProjectRepository
//go:generate go tool mockgen -destination mocks/mock_project_service.go -package mocks github.com/aviorp/go-template/models ProjectService
type ProjectRepository interface {
GetAll(orgID string) ([]Project, error)
GetByID(id string) (*Project, error)
Create(project *Project) error
Update(id string, project *Project) error
Delete(id string) error
}
type ProjectService interface {
GetProjects(orgID string) ([]Project, error)
GetProjectByID(id string) (*Project, error)
CreateProject(project *Project) error
UpdateProject(id string, project *Project) error
DeleteProject(id string) error
}repositories/project.go — implements models.ProjectRepository; the interface is not redefined here.
package repositories
import (
"database/sql"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/aviorp/go-template/models"
)
var _ models.ProjectRepository = (*SQLProjectRepository)(nil)
type SQLProjectRepository struct {
db *sqlx.DB
}
func NewProjectRepository(db *sqlx.DB) models.ProjectRepository {
return &SQLProjectRepository{db: db}
}
func (r *SQLProjectRepository) GetByID(id string) (*models.Project, error) {
var project models.Project
const query = `SELECT id, name, description, status, org_id, created_at, updated_at
FROM projects WHERE id = $1`
if err := r.db.Get(&project, query, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, &models.ErrNotFound{Entity: "project", ID: id}
}
return nil, fmt.Errorf("getting project by id: %w", err)
}
return &project, nil
}
// ... GetAll, Create, Update, Delete follow the same patternservices/project.go — implements models.ProjectService, accepts models.ProjectRepository. No repositories import, no interface definition.
package services
import (
"fmt"
"github.com/aviorp/go-template/models"
)
var _ models.ProjectService = (*projectService)(nil)
type projectService struct {
repo models.ProjectRepository
}
func NewProjectService(repo models.ProjectRepository) models.ProjectService {
return &projectService{repo: repo}
}
func (s *projectService) CreateProject(project *models.Project) error {
if project.Status == "" {
project.Status = models.ProjectStatusActive
}
if err := s.repo.Create(project); err != nil {
return fmt.Errorf("creating project: %w", err)
}
return nil
}
// ... remaining methodsdomains/projects.go
package domains
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/aviorp/go-template/middleware"
"github.com/aviorp/go-template/models"
"github.com/aviorp/go-template/utils/httputils"
)
type ProjectHandler struct {
service models.ProjectService
}
func NewProjectHandler(service models.ProjectService) *ProjectHandler {
return &ProjectHandler{service: service}
}
func (h *ProjectHandler) RegisterRoutes(mux *http.ServeMux) {
mux.Handle("GET /api/projects", middleware.ClerkMiddleware(http.HandlerFunc(h.ListProjects)))
mux.Handle("POST /api/projects", middleware.ClerkMiddleware(http.HandlerFunc(h.CreateProject)))
mux.Handle("GET /api/projects/{id}", middleware.ClerkMiddleware(http.HandlerFunc(h.GetProject)))
mux.Handle("PATCH /api/projects/{id}", middleware.ClerkMiddleware(http.HandlerFunc(h.UpdateProject)))
mux.Handle("DELETE /api/projects/{id}", middleware.ClerkMiddleware(http.HandlerFunc(h.DeleteProject)))
}
func (h *ProjectHandler) ListProjects(w http.ResponseWriter, r *http.Request) {
orgID := r.URL.Query().Get("org_id")
if orgID == "" {
httputils.WriteError(w, http.StatusBadRequest, "org_id query parameter is required")
return
}
projects, err := h.service.GetProjects(orgID)
if err != nil {
httputils.FromDomainErr(w, err)
return
}
httputils.WriteJSON(w, http.StatusOK, projects)
}
func (h *ProjectHandler) CreateProject(w http.ResponseWriter, r *http.Request) {
var req models.CreateProjectRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputils.WriteError(w, http.StatusBadRequest, "Invalid request payload")
return
}
if err := req.Validate(); err != nil {
httputils.FromDomainErr(w, err)
return
}
project := req.ToProject()
project.ID = uuid.New().String()
if err := h.service.CreateProject(project); err != nil {
httputils.FromDomainErr(w, err)
return
}
httputils.WriteJSON(w, http.StatusCreated, project)
}
// ... GetProject, UpdateProject, DeleteProjectapp/app.go — add fields, option, nil guard, getter, and registration:
// Add to App struct
type App struct {
// ... existing fields
projectRepo models.ProjectRepository
projectService models.ProjectService
}
// Add functional option
func WithProjectRepository(repo models.ProjectRepository) AppOption {
return func(a *App) { a.projectRepo = repo }
}
func WithProjectService(svc models.ProjectService) AppOption {
return func(a *App) { a.projectService = svc }
}
// Add nil guard in InitRepositories
func (a *App) InitRepositories() error {
if a.taskRepo == nil {
a.taskRepo = repositories.NewTaskRepository(a.DB)
}
if a.projectRepo == nil {
a.projectRepo = repositories.NewProjectRepository(a.DB)
}
return nil
}
// Add nil guard in InitServices
func (a *App) InitServices() error {
if a.taskService == nil {
a.taskService = services.NewTaskService(a.taskRepo)
}
if a.projectService == nil {
a.projectService = services.NewProjectService(a.projectRepo)
}
return nil
}
// Register routes in InitDomains
func (a *App) InitDomains() error {
// ... existing registrations
projectHandler := domains.NewProjectHandler(a.projectService)
projectHandler.RegisterRoutes(a.mux)
return nil
}
// Add getters (interface types from models)
func (a *App) GetProjectRepository() models.ProjectRepository { return a.projectRepo }
func (a *App) GetProjectService() models.ProjectService { return a.projectService }Also add the new getter signatures to the AppInterface interface at the top of app.go.
The //go:generate directives you added in step 1 produce the mocks — no hand-written mock file to maintain:
go generate ./... # writes models/mocks/mock_project_*.gomake build and make test run this for you, so mocks never drift from an interface signature.
Write services/project_test.go and domains/projects_test.go using the generated mocks (mocks.NewMockProjectRepository(gomock.NewController(t)), .EXPECT()...Return(...)), following the same patterns as the task tests. Add a real-Postgres test in tests/integration/ for the repository. Every service method and every handler success/error path should have a case.
make build # go generate + compile
make test # fast unit tests (mocks only)
make test-integration # real Postgres via docker-compose.test.ymlTwo tiers: fast unit tests with generated mocks (the default make test), and integration tests against a real Postgres (make test-integration).
Mocks are generated by go.uber.org/mock, registered as a Go tool dependency (go get -tool go.uber.org/mock/mockgen) so there is no binary to install — invoke it with go tool mockgen. The //go:generate directives live next to each interface in models/:
//go:generate go tool mockgen -destination mocks/mock_task_repository.go -package mocks github.com/aviorp/go-template/models TaskRepository
//go:generate go tool mockgen -destination mocks/mock_task_service.go -package mocks github.com/aviorp/go-template/models TaskServiceRun go generate ./... (wired into make build/make test) to (re)generate models/mocks/. When a method signature changes, the mock is regenerated automatically — no hand-maintained mock file to keep in sync. Use testify/require and testify/assert for assertions.
Service tests use mocks.MockTaskRepository to isolate the service from the database. A setup helper wires the controller, mock, and service; tests declare expectations with .EXPECT().
func setupTaskServiceTest(t *testing.T) (*mocks.MockTaskRepository, models.TaskService) {
t.Helper()
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockTaskRepository(ctrl)
return mockRepo, NewTaskService(mockRepo)
}
func TestCreateTask(t *testing.T) {
t.Run("sets default status when none provided", func(t *testing.T) {
mockRepo, svc := setupTaskServiceTest(t)
var created *models.Task
mockRepo.EXPECT().Create(gomock.Any()).DoAndReturn(func(task *models.Task) error {
created = task
return nil
})
task := &models.Task{ID: "1", Title: "Test", OrgID: "org1"}
require.NoError(t, svc.CreateTask(task))
assert.Equal(t, models.TaskStatusPending, created.Status)
})
t.Run("returns error when repo fails", func(t *testing.T) {
mockRepo, svc := setupTaskServiceTest(t)
mockRepo.EXPECT().Create(gomock.Any()).Return(errors.New("insert error"))
require.Error(t, svc.CreateTask(&models.Task{ID: "1"}))
})
}gomock.NewController(t) auto-registers cleanup, so unmet expectations fail the test with no explicit Finish() call.
Handler tests use mocks.MockTaskService and httptest to exercise the full HTTP layer without a running server or database. Use req.SetPathValue("id", value) to simulate path parameters — http.ServeMux normally injects these, but httptest does not.
func TestGetTask_Success(t *testing.T) {
taskID := "550e8400-e29b-41d4-a716-446655440000"
ctrl := gomock.NewController(t)
mockSvc := mocks.NewMockTaskService(ctrl)
mockSvc.EXPECT().GetTaskByID(taskID).
Return(&models.Task{ID: taskID, Title: "Found"}, nil)
handler := NewTaskHandler(mockSvc)
req := httptest.NewRequest(http.MethodGet, "/api/tasks/"+taskID, nil)
req.SetPathValue("id", taskID) // required for {id} path params
rr := httptest.NewRecorder()
handler.GetTask(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
}To assert the error mapping, return a sentinel from the mock and check the status: mockSvc.EXPECT().GetTaskByID(id).Return(nil, &models.ErrNotFound{...}) should yield a 404 via FromDomainErr.
App tests verify that functional options are applied and that nil guards work — without starting a real database or server.
func TestInitRepositories_SkipsIfSet(t *testing.T) {
ctrl := gomock.NewController(t)
mockRepo := mocks.NewMockTaskRepository(ctrl)
a := NewApp(&config.Config{}, WithTaskRepository(mockRepo))
require.NoError(t, a.InitRepositories())
// The injected mock must survive InitRepositories unchanged.
if a.GetTaskRepository() != mockRepo {
t.Error("expected the injected mock to be preserved")
}
}Repository SQL can't be verified against a mock (column names, constraints, sql.ErrNoRows handling, timestamp defaults). Those tests run against a real, disposable Postgres and live in tests/integration/, behind a //go:build integration tag so go test ./... never compiles them — the unit-test target stays fast and hermetic.
docker-compose.test.ymlspins uppostgres:16-alpineon host port 5433 with an in-memorytmpfsvolume and apg_isreadyhealthcheck.- Each test connects via the
TEST_DB_*env vars, (re)creates thetaskstable with a rawCREATE TABLE(this template ships no migration tooling on purpose), and exercises the realrepositories.SQLTaskRepository. make test-integrationbrings the stack up (--waitblocks on the healthcheck), runsgo test -tags=integration ./tests/integration/..., and always tears the stack down afterward — even on failure.
make test-integration//go:build integration
func TestSQLTaskRepository_GetByID_NotFound(t *testing.T) {
db := newTestDB(t) // connects via TEST_DB_*, recreates the table
repo := repositories.NewTaskRepository(db)
_, err := repo.GetByID("does-not-exist")
var nf *models.ErrNotFound
require.ErrorAs(t, err, &nf) // real ErrNoRows -> typed sentinel
assert.Equal(t, "task", nf.Entity)
}# Fast unit tests (generated mocks, no DB)
make test
# Real-Postgres integration tests (needs Docker)
make test-integration
# Regenerate mocks only
make generate
# A single package / test, or with the race detector
go test ./services/... -run TestCreateTask -v
go test -race ./...Configuration is loaded from .env (via godotenv) and then validated against environment variables (via go-envconfig). Fields tagged required will cause startup to fail if absent.
Copy .env.example to .env and fill in your values:
cp .env.example .env| Variable | Required | Default | Description |
|---|---|---|---|
ENVIRONMENT |
No | development |
Runtime environment label |
DB_HOST |
Yes | — | PostgreSQL host |
DB_PORT |
No | 5432 |
PostgreSQL port |
DB_USER |
Yes | — | PostgreSQL username |
DB_PASSWORD |
Yes | — | PostgreSQL password |
DB_NAME |
Yes | — | PostgreSQL database name |
PORT |
No | 8080 |
HTTP server listen port |
LOG_LEVEL |
No | debug |
Zerolog level: debug, info, warn, error |
CLERK_SECRET_KEY |
Yes | — | Clerk secret key for JWT validation |
The config.Config struct also exposes two helpers:
cfg.IsDevelopment() // true when ENVIRONMENT=development
cfg.IsProduction() // true when ENVIRONMENT=productionThe pool is configured in database/database.go with production-safe defaults:
| Setting | Value |
|---|---|
| Max open connections | 25 |
| Max idle connections | 25 |
| Connection max lifetime | 5 minutes |
| SSL mode | require |
- Go 1.24+ (required for
go tool/ tool dependencies) - PostgreSQL 14+ (or any Postgres-compatible database, e.g. Neon, Supabase)
- A Clerk account for the
CLERK_SECRET_KEY - (Optional) Air for live reload
# 1. Clone the repository
git clone https://github.com/aviorp/go-template.git
cd go-template
# 2. Copy and configure environment variables
cp .env.example .env
# Edit .env with your database credentials and Clerk secret key
# 3. Download dependencies
go mod download
# 4. Create the database and run your schema migrations
# (this template does not include migrations — use goose, atlas, or raw SQL)
createdb go_template
# 5. Run the server
make runThe server starts on http://localhost:8080. Verify it is running:
curl http://localhost:8080/healthcheck
# OKInstall Air and run:
make watch# Build and start the container
make docker-run
# Stop the container
make docker-downThe Dockerfile uses a two-stage build: a golang:1.24-alpine build stage produces a statically linked binary, which is then copied into a minimal alpine runtime image. The final image contains only the binary.
| Target | Command | Description |
|---|---|---|
generate |
make generate |
Regenerate mockgen mocks (go generate ./...) |
build |
make build |
Generate mocks, then compile to bin/go-template |
run |
make run |
Run the server directly with go run |
test |
make test |
Fast unit tests (generated mocks, no DB) |
test-integration |
make test-integration |
Real-Postgres tests via docker-compose.test.yml |
clean |
make clean |
Remove the bin/ directory |
watch |
make watch |
Start Air live-reload server |
docker-run |
make docker-run |
Start containers with docker compose |
docker-down |
make docker-down |
Stop and remove containers |
| Dependency | Version | Purpose |
|---|---|---|
net/http (stdlib) |
— | HTTP server and routing — no framework needed |
github.com/jmoiron/sqlx |
1.4.0 | Struct scanning on top of database/sql; named queries |
github.com/jackc/pgx/v5 |
5.7.2 | PostgreSQL driver (used as stdlib adapter for sqlx) |
github.com/clerk/clerk-sdk-go/v2 |
2.3.1 | JWT verification and user fetching for Clerk auth |
github.com/google/uuid |
1.6.0 | UUID generation for entity IDs |
github.com/rs/zerolog |
1.33.0 | Structured, zero-allocation JSON logger |
github.com/sethvargo/go-envconfig |
1.1.0 | Declarative env-var config loading with validation |
github.com/joho/godotenv |
1.5.1 | Load .env file into environment in development |
go.uber.org/mock |
0.6.0 | mockgen-generated interface mocks (registered as a Go tool) |
github.com/stretchr/testify |
1.11.1 | require/assert test assertions |
Go 1.22+ added method and path-parameter routing directly to http.ServeMux (GET /api/tasks/{id}). Combined with a small middleware composer (CreateStack), this provides everything a typical REST API needs without the complexity, opinions, or performance overhead of a full framework.
The repository includes a GitHub Actions workflow (.github/workflows/deploy_workspace.yml) that deploys to Amazon EKS:
- Terraform job — syncs infrastructure via Terraform Cloud, exports the EKS cluster name and ECR repository URL as artifacts.
- API job — builds the Docker image, pushes it to ECR, updates
kubeconfig, and applies the Kubernetes manifests fromk8s/<stage>/.
Deployment is triggered automatically on push to master (deploys to dev) or manually via workflow_dispatch with a choice of dev, staging, or prod.
Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, TF_CLOUD_ORGANIZATION, TF_API_TOKEN, TF_WORKSPACE.