template/README.md
2026-04-07 20:32:27 +00:00

303 lines
9.5 KiB
Markdown

# 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](https://golangci-lint.run) | Aggregated linters, one config file |
| Security | [gosec](https://github.com/securego/gosec) + [govulncheck](https://pkg.go.dev/golang.org/x/vuln/cmd/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
- Go 1.25+
- Git
- (Optional) [VSCode](https://code.visualstudio.com/) + [Go extension](https://marketplace.visualstudio.com/items?itemName=golang.Go)
- (Optional) Docker for devcontainer
### 1. Clone and rename
```bash
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)
```bash
make init # fetches deps, configures git hooks
make tools # (optional) install tool binaries to GOPATH/bin for IDE integration
```
### 3. Build and run
```bash
make build # compiles to ./bin/app
make run # go run with default flags
```
---
## Daily workflow
```bash
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:
```bash
./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:
```bash
./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:
```go
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.
```go
// 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.
```go
// 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:
```go
port, err := parsePort("443").Unwrap() // back to (T, error)
```
See [`pkg/result/example_test.go`](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:
```bash
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
```bash
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](https://github.com/go-chi/chi) or [gin](https://github.com/gin-gonic/gin)
- **Database** — add [sqlx](https://github.com/jmoiron/sqlx) or [ent](https://entgo.io/)
- **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`