Skip to content

Repository files navigation

go-template

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. The models package has zero internal imports, so every other layer depends only on it for contracts. Services and handlers never import the concrete repositories/services packages 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 single httputils.FromDomainErr turns 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.


Table of Contents

  1. Architecture Overview
  2. Layer Rules
  3. Domain Errors
  4. Project Structure
  5. Adding a New Domain — Step by Step
  6. Testing
  7. Configuration
  8. Getting Started
  9. Makefile Commands
  10. Tech Stack
  11. Deployment

Architecture Overview

Request Lifecycle

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        |
+--------------------+

Dependency Graph

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.


Layer Rules

This is the most important section. Violating these rules breaks the architecture and makes the codebase untestable.

Models (models/)

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, json field 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:generate mockgen directive to each so mocks stay in sync.
  • Request DTOs carry their own .Validate() error right 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/ or services/.
  • Add methods that call other internal packages.

Repositories (repositories/)

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 guard var _ models.TaskRepository = (*SQLTaskRepository)(nil) keeps it honest.
  • Use sqlx for queries (Select, Get, NamedExec).
  • Return models.* types, never raw sql.Rows or map[string]any.
  • A missing single row returns a typed *models.ErrNotFound (via errors.Is(err, sql.ErrNoRows)), so the HTTP layer maps it to a 404. An empty GetAll list 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 map types from queries.

Services (services/)

Purpose: All business logic lives here.

Rules:

  • Implement models.TaskService — never define the interface here.
  • Accept the repository as models.TaskRepository via constructor injection — never a concrete type, never the repositories package.
  • 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 so errors.As still finds *models.ErrNotFound after 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 repositories package.
  • Import net/http or touch http.Request / http.ResponseWriter.
  • Call sqlx or open database connections directly.
  • Read from environment variables.

Domains (domains/)

Purpose: HTTP handlers. Translate between HTTP and the service layer.

Rules:

  • Depend on models.TaskService via constructor injection — the handler never imports the services package.
  • Each handler: parse input, validate the DTO, call one service method, write the response. Nothing else.
  • Decode request bodies into a models.*Request DTO and call .Validate(), then map to the entity with .ToTask(). Do not decode straight into models.Task.
  • Forward any service/domain error through httputils.FromDomainErr(w, err) — never hand-pick a status code for a business error. Reserve raw httputils.WriteError for input-shape problems the handler owns (missing query param, unparseable ID, malformed JSON).
  • Register routes via RegisterRoutes(mux *http.ServeMux); apply middleware.ClerkMiddleware to 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/services or 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.

Middleware (middleware/)

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.


App Container (app/app.go)

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.


Domain Errors

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.


Project Structure

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

Adding a New Domain — Step by Step

This walkthrough adds a projects domain. Replace "project"/"Project" with your entity name throughout.

1. Create the model — struct, DTO, and interfaces

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
}

2. Create the repository (implementation only)

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 pattern

3. Create the service (implementation only)

services/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 methods

4. Create the domain handler

domains/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, DeleteProject

5. Wire it into the app container

app/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.

6. Generate the mocks

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_*.go

make build and make test run this for you, so mocks never drift from an interface signature.

7. Write tests

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.

8. Run quality gates

make build             # go generate + compile
make test              # fast unit tests (mocks only)
make test-integration  # real Postgres via docker-compose.test.yml

Testing

Two tiers: fast unit tests with generated mocks (the default make test), and integration tests against a real Postgres (make test-integration).

Generated Mocks (mockgen)

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 TaskService

Run 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.

Writing Service Tests

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.

Writing Handler Tests

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.

Writing App Container Tests

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")
    }
}

Integration Testing — Real Postgres

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.yml spins up postgres:16-alpine on host port 5433 with an in-memory tmpfs volume and a pg_isready healthcheck.
  • Each test connects via the TEST_DB_* env vars, (re)creates the tasks table with a raw CREATE TABLE (this template ships no migration tooling on purpose), and exercises the real repositories.SQLTaskRepository.
  • make test-integration brings the stack up (--wait blocks on the healthcheck), runs go 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)
}

Running Tests

# 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

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

Available Configuration Fields

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=production

Database Connection Pool

The 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

Getting Started

Prerequisites

  • 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

Local Setup

# 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 run

The server starts on http://localhost:8080. Verify it is running:

curl http://localhost:8080/healthcheck
# OK

Live Reload (Optional)

Install Air and run:

make watch

Docker

# Build and start the container
make docker-run

# Stop the container
make docker-down

The 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.


Makefile Commands

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

Tech Stack

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

Why no framework?

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.


Deployment

The repository includes a GitHub Actions workflow (.github/workflows/deploy_workspace.yml) that deploys to Amazon EKS:

  1. Terraform job — syncs infrastructure via Terraform Cloud, exports the EKS cluster name and ECR repository URL as artifacts.
  2. API job — builds the Docker image, pushes it to ECR, updates kubeconfig, and applies the Kubernetes manifests from k8s/<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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages