Go to file Use this template
2026-04-07 20:43:04 +00:00
.devcontainer init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
.githooks init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
.vscode init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
cmd/app init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
internal init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
pkg/result init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
.editorconfig init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
.gitignore init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
.golangci.yml init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
CLAUDE.md init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
go.mod init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
go.sum init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
LICENSE init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
Makefile init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
README.md init: go/template v0.1.1 2026-04-07 20:32:27 +00:00
rename.sh rename.sh git push --force to publish repo 2026-04-07 20:43:04 +00:00
tools.versions init: go/template v0.1.1 2026-04-07 20:32:27 +00:00

template

A Go project template for PoC, hobby projects, and small publishable packages. Clone it, rename the module, run make init, and you're coding.

Demonstrates idiomatic Go patterns: stdlib-only dependencies, consumer-defined interfaces, manual fakes for testing, and a clean go.mod that stays free of dev tool noise — so packages extracted from this template can be published without module graph pollution.


Features

Area Tool Purpose
Language Go 1.25 Modules, toolchain directive
Logging standard log/slog Structured JSON/text logging + WithField extension
Config standard flag CLI flags with defaults, no config files
Linting golangci-lint Aggregated linters, one config file
Security gosec + govulncheck SAST + dependency vulnerability scan
Tests standard testing Table-driven tests, manual fakes, no third-party test framework
Git hooks custom pre-push gofmt + go vet + golangci-lint + gosec on every push
Tool versions tools.versions + go run Pinned versions without polluting go.mod
Dev environment devcontainer Reproducible VSCode / GitHub Codespaces setup
IDE VSCode Launch configs, tasks, recommended settings

Getting started

Prerequisites

1. Clone and rename

git clone https://gitea.djmil.dev/go/template my-project
cd my-project

# Interactive rename — updates module path, config, devcontainer, and docs:
./rename.sh

2. Init (run once)

make init          # fetches deps, configures git hooks
make tools         # (optional) install tool binaries to GOPATH/bin for IDE integration

3. Build and run

make build  # compiles to ./bin/app
make run    # go run with default flags

Daily workflow

make test          # run all tests
make test-race     # … with race detector
make lint          # go vet + golangci-lint
make lint-fix      # go fix + golangci-lint auto-fix
make security      # gosec + govulncheck
make release       # list tags, or run full checks then tag+push (make release VERSION=v0.1.0)

Keyboard shortcut (VSCode): Ctrl+Shift+B → build, Ctrl+Shift+T → test.


Project structure

.
├── cmd/
│   └── app/
│       └── main.go          # Composition root (thin — just wiring)
├── internal/
│   ├── config/              # flag-based config (config.Load)
│   ├── logger/              # slog wrapper with WithField / WithFields
│   └── greeter/             # Example domain package (replace with yours)
├── .devcontainer/           # VSCode / Codespaces container definition
├── .githooks/               # pre-push hook (installed by `make setup`)
├── .vscode/                 # launch, tasks, editor settings
├── pkg/
│   └── result/              # Example publishable package (Result/Expect types)
│       ├── doc.go           #   package-level godoc
│       ├── result.go        #   implementation
│       └── example_test.go  #   runnable examples (shown on pkg.go.dev)
├── tools.versions           # Pinned tool versions (Makefile + hook source this)
├── .golangci.yml            # Linter configuration
└── Makefile                 # All development commands

Configuration

All configuration is via CLI flags with sensible defaults:

./bin/app -help
  -env string       environment: dev | staging | prod (default "dev")
  -log-level string log level: debug | info | warn | error (default "info")
  -name string      greeter name (default "Gopher")
  -port int         listen port (default 8080)

Override at runtime:

./bin/app -port 9090 -env prod -log-level warn

Logging

The logger wrapper adds WithField and WithFields for ergonomic context chaining on top of the standard log/slog package:

log.WithField("request_id", rid).
    WithField("user", uid).
    Info("handling request")

log.WithFields(map[string]any{
    "component": "greeter",
    "name":      name,
}).Debug("generating greeting")

In production (app.env != dev) the output is JSON (slog.NewJSONHandler). In development, logger.NewDevelopment() uses the human-friendly text handler.


Testing

Tests use the standard testing package. Concrete types are returned from constructors — consumers (including tests) define their own minimal interfaces and satisfy them with manual fakes. No code generation required.

// Interface declared in the consumer (or _test.go), not in greeter package.
type greeter interface {
    Greet(name string) result.Expect[string]
}

type fakeGreeter struct {
    greetFn func(name string) result.Expect[string]
}

func (f *fakeGreeter) Greet(name string) result.Expect[string] {
    return f.greetFn(name)
}

func TestSomething(t *testing.T) {
    fake := &fakeGreeter{
        greetFn: func(name string) result.Expect[string] {
            return result.Ok("Hello, " + name + "!")
        },
    }
    // pass fake to the system under test
}

pkg/result — happy-path error handling

pkg/result is a small publishable package that demonstrates the pattern of keeping go.mod clean. It provides Expect[T], a generic type that holds either a success value or an error.

The idea: deep call stacks write for the happy path, unwrapping results with Expect or Must (which panic on failure). A single defer result.Catch(&err) at the entry point recovers those panics and returns them as a normal Go error, with the stack trace captured at the original failure site.

// Wrap any (value, error) function:
data := result.Of(os.ReadFile("config.json")).Expect("read config")

// Or construct explicitly:
func parsePort(s string) result.Expect[int] {
    n, err := strconv.Atoi(s)
    if err != nil {
        return result.Fail[int](fmt.Errorf("parsePort: %w", err))
    }
    return result.Ok(n)
}

// Entry point catches panics from the whole call stack:
func run() (err error) {
    defer result.Catch(&err)
    port := parsePort(cfg.Port).Expect("load config")
    _ = port // happy path continues …
    return nil
}

Catch only intercepts error panics produced by this package. Real runtime panics (nil-pointer dereferences, etc.) are re-panicked so bugs are never silently swallowed.

At boundaries where you need to re-join normal Go code:

port, err := parsePort("443").Unwrap()  // back to (T, error)

See pkg/result/example_test.go for the full set of runnable examples.


Git hooks

The pre-push hook runs gofmt + go vet + golangci-lint + gosec against the full codebase before every git push. Running on push (not commit) keeps the inner commit loop fast while still blocking bad code from reaching the remote.

govulncheck is intentionally excluded (it makes network calls and can be slow). Run it manually with make security.

To skip the hook in an emergency:

git push --no-verify

Tool version management

Tool versions are pinned in tools.versions — a plain KEY=value file that both the Makefile and the pre-push hook source directly.

Why not tools.go + go.mod?

The conventional //go:build tools approach adds development tools (linters, scanners, debuggers) as entries in go.mod. This is fine for applications, but it's a problem when the repo is intended to publish reusable packages: consumers who go get your package see those tool dependencies in the module graph, even though they are never compiled into the binary. Keeping go.mod clean makes the module honest — it declares only what the package code actually needs.

Instead, go run tool@version is used in Makefile targets and the pre-push hook. Go caches downloaded tools in the module cache, so after the first run there is no extra download cost.

To update a tool, edit the version in tools.versions:

GOLANGCI_LINT_VERSION=v1.64.8   ← change this

Both make lint and the pre-push hook pick up the new version automatically. Run make tools if you also want the updated binary installed to GOPATH/bin (e.g. for IDE integration or direct CLI use).


Releasing

make release                    # list all existing tags
make release VERSION=v0.1.0     # run full checks, then tag and push

The release target validates the version format (semver vX.Y.Z), ensures the working tree is clean, and runs make test-race lint security before tagging. The tag is only created and pushed if every check passes.


Devcontainer

Open this repo in VSCode and choose "Reopen in Container" — the Go toolchain is installed automatically. Run make init to configure git hooks, then make tools to install linter/scanner binaries to GOPATH/bin.

Works with GitHub Codespaces out of the box.


Next steps (not included)

  • HTTP server — add chi or gin
  • Database — add sqlx or ent
  • CI — add a Gitea Actions / CI pipeline running make lint test security
  • Docker — add a multi-stage Dockerfile to publish the binary as a container image (separate from the devcontainer)
  • OpenTelemetry — add tracing with go.opentelemetry.io/otel