Full-Stack Go: DDD + sqlc + templ
A full-stack Go web application for animal sightings: occurrence data from GBIF, persisted in SQLite via sqlc, rendered server-side with templ, and enriched with weather from Open-Meteo.
1. The Modern Go Stack Breakdown
There is a quiet paradigm shift happening in the Go web ecosystem, and this workshop application is its poster child. For years, the "modern" Go stack meant something like: a SPA (React, Vue, or Svelte) in the browser, talking to a Go REST API backed by JSON. It worked, but it came with a familiar tax: a separate TypeScript codebase, duplicated types between client and server, runtime JSON parsing, and an entire discipline of OpenAPI/Swagger contract management just to keep the two worlds in sync.
This project asks a simple question: What if we never left Go?
The stack
| Layer | Tool | What it does |
|---|---|---|
| Database | SQLite (via modernc.org/sqlite) |
Pure-Go, zero-dependency SQL database |
| Query layer | sqlc |
Compile-time, type-safe SQL → Go |
| View layer | templ |
Server-side rendering with type-safe Go components |
| Routing | chi/v5 |
HTTP routing |
| Migrations | goose/v3 |
Database schema migrations |
| CLI |
urfave/cli/v3 + huh
|
Interactive command-line interface |
The paradigm shift: type-safety from DB to HTML
The traditional stack's type system looks like this:
SQL → JSON → TypeScript types → runtime fetch → React props
Every arrow is a place where types lie, and the compiler can't help you. Change a column name in SQL and nothing breaks at compile time. You find out at runtime, in production, from a user complaint.
This stack collapses the chain to:
SQL → sqlc (compile-time Go structs) → domain types → templ (compile-time HTML)
There is no JSON in the middle. There is no TypeScript. The sqlc tool reads your .sql query files and your schema, and generates Go code with the exact types you need. The templ tool reads your .templ files, which are Go functions returning HTML, and generates Go code that the compiler type-checks against your domain types. If your Sighting struct has a field HappenedAt time.Time, then @components.SightingCard(s) in a template must be passed a sighting.Sighting with that field, or the program won't compile.
This is the key insight: the boundary that used to be a runtime concern (JSON serialization, API contracts) has been pushed back to compile time, where it belongs.
Why this beats the SPA + REST API pattern
- No API contract drift. There is no API. The handler calls a Go service, which returns a Go struct, which is passed to a Go template function. The compiler is the contract.
-
No build step for the frontend.
templ generateproduces Go files.go buildproduces a single binary. There is no webpack, nonpm install, nonode_modules. The "frontend" is compiled into the same binary as the backend. -
The database is the only source of truth. Your
.sqlquery files, yourmigrations/*.sql, and yoursqlc.yamlconfig are the spec. Runmake sqlcand the generated code reflects them exactly. Runmake templand your templates reflect your.templfiles. -
Hot reload without a frontend build pipeline.
make web-hotrunstemplier, which watches your source files, recompiles the Go app on any change, and proxies to the browser with a live-reload script injected. Editsighting_card.templ, save, and the browser refreshes. No npm, no HMR server, no bundler.
2. Architectural Tour: Domain-Driven Layout
The project's layout is a deliberate, opinionated implementation of Domain-Driven Design (DDD) in Go. The README for each internal/ subpackage states its role explicitly. Here is the full tree:
workshop/
├── main.go # 9 lines: hands off to internal/cmd
├── migrations/ # goose migrations (0001_init.sql, 0002_weather.sql)
├── queries/ # sqlc query files, one per table
├── sqlc.yaml # sqlc configuration
├── templier.yml # templier (hot reload) configuration
├── Makefile # dev workflows: web, web-hot, templ, sqlc, test, load, etc.
├── internal/
│ ├── cmd/ # CLI commands and interactive menu (urfave/cli + huh)
│ │ ├── cmd.go # Root command, bootstrap, interactive menu loop
│ │ ├── app.go # Wiring: services, stores, clients
│ │ ├── web.go # `workshop web` command
│ │ ├── sightings.go # `workshop sightings` (find, list, ingest)
│ │ ├── species.go # `workshop species`
│ │ ├── animals.go # `workshop animals`
│ │ ├── stats.go # `workshop stats`
│ │ ├── db.go # `workshop db` (migrate, seed, reset, ping)
│ │ ├── migrate.go # migration status/version/up/down + checkMigrations
│ │ ├── flags.go # shared CLI flags
│ │ └── internal/
│ │ ├── prompt/ # interactive forms (huh wrappers)
│ │ ├── lookup/ # UUID prefix lookup
│ │ └── view/ # CLI rendering (tables, cards, hints)
│ ├── domain/ # THE RULES: what's true if you delete the DB and API
│ │ ├── sighting/ # Sighting entity, Store interface, Service, ingest, validate
│ │ │ ├── sighting.go # Store interface, Service, observers() formatting
│ │ │ ├── model.go # Sighting, Location, Coordinates, CreateParams, Media
│ │ │ ├── ingest.go # GBIF → sighting pipeline (Ingest, speciesPerSighting, etc.)
│ │ │ ├── filter.go # ListFilter, TaxonKeys()
│ │ │ ├── validate.go # CreateParams.Validate(): invariants
│ │ │ ├── summary.go # sighting summaries
│ │ │ ├── country/ # country.Code type with parsing
│ │ │ └── stores/
│ │ │ └── sightingsqlite/ # sqlc-backed implementation of sighting.Store
│ │ │ ├── store.go # Find, FindByGbifKey, List, Create
│ │ │ └── transform.go # db.SightingListRow → sighting.Sighting
│ │ ├── species/ # Species entity, Store, Service, Animal catalog
│ │ │ ├── species.go # Store interface, Service
│ │ │ ├── model.go # Species struct
│ │ │ ├── animal.go # 80+ Animal constants + GBIF taxon keys
│ │ │ └── stores/
│ │ │ └── speciessqlite/
│ │ ├── weather/ # Weather entity, Store, Service
│ │ │ ├── weather.go # Store interface, Service, errors
│ │ │ ├── model.go # Weather, Temperature, Precipitation, Wind, Condition
│ │ │ ├── validate.go
│ │ │ └── stores/
│ │ │ └── weathersqlite/
│ │ └── README.md # "The rules. Everything that would still be true if we deleted the database and the API."
│ ├── clients/ # OUTBOUND ADAPTERS: gbif, openmeteo
│ │ ├── gbif/
│ │ │ ├── client.go # Client struct, BaseURL, New()
│ │ │ ├── occurrences.go # Occurrence type (300+ fields!), Occurrences() search
│ │ │ ├── species.go # GBIF Species lookup
│ │ │ ├── media.go
│ │ │ ├── pagination.go
│ │ │ ├── common.go
│ │ │ └── testdata/ # JSON fixtures for offline tests
│ │ ├── openmeteo/
│ │ │ ├── client.go # Client struct, BaseURL
│ │ │ ├── reading.go # Reading, Request, query builder
│ │ │ ├── codes.go # WeatherCode enum
│ │ │ └── testdata/
│ │ └── README.md # "Anything that talks to somebody else."
│ ├── infra/ # PLUMBING: config, sqlite, sqlc output
│ │ ├── config/config.go # Config from env (caarlos0/env)
│ │ ├── db/sqlite/ # SQLite connection, error mapping
│ │ ├── db/sqlc/ # AUTOGENERATED sqlc output (db package)
│ │ │ ├── models.go # Sighting, Species, Weather (sqlc-generated structs)
│ │ │ ├── sightings.sql.go
│ │ │ ├── species.sql.go
│ │ │ ├── weather.sql.go
│ │ │ ├── querier.go # Querier interface
│ │ │ └── db.go # New() constructor
│ │ └── README.md # "The plumbing of the app... decisions don't."
│ └── common/ # GENERIC HELPERS (httputil, logs, server, printer)
│ ├── httputil/ # JSON HTTP client (works in any project)
│ ├── logs/ # request logger, slog setup
│ ├── server/ # HTTP server with graceful shutdown
│ ├── printer/ # CLI console output (tables, trees, styles)
│ └── README.md # "Generic helpers that happen to be ours."
└── web/ # VIEW LAYER: routing, handlers, templates, assets
├── web.go # New(): ServeMux, static assets, route registration
├── handlers/
│ ├── home.go # Home() handler: calls service, renders template
│ └── error.go # renderError(): error page rendering
└── views/
├── layout/layout.templ # Base layout (HTML shell, slots)
├── pages/
│ ├── home.templ # Homepage: iterates sightings, calls SightingCard
│ └── error.templ # Error page
└── components/
├── sighting_card.templ # Individual sighting card
└── gallery.templ # Media gallery (StillImage vs link)
The DDD principles in action
1. Domain owns the rules. internal/domain/sighting/validate.go contains the invariants that every sighting must follow: species ID required, happened-at required, happened-at not in the future, coordinates in range, individual count non-negative. These rules exist regardless of the database or the API. If you deleted SQLite and GBIF, the rules would still be true. That is the DDD definition of a domain rule, and it lives in domain/, not in infra/ or clients/.
2. Ports and adapters (Hexagonal architecture). The domain declares interfaces (ports), and the infrastructure provides implementations (adapters):
// internal/domain/sighting/sighting.go:19
type Store interface {
Find(ctx context.Context, id uuid.UUID) (Sighting, error)
FindByGbifKey(ctx context.Context, gbifKey int) (Sighting, error)
List(ctx context.Context, filter ListFilter) ([]Sighting, error)
Create(ctx context.Context, id uuid.UUID, in CreateParams) (Sighting, error)
}
// internal/domain/sighting/stores/sightingsqlite/store.go:21
var _ sighting.Store = (*Store)(nil) // Compile-time interface check
The var _ sighting.Store = (*Store)(nil) line is the hexagon. The compiler verifies that sightingsqlite.Store satisfies sighting.Store. If you change the interface, the adapter breaks at compile time. If you swap SQLite for Postgres, you write a new adapter and the domain never changes.
3. Dependencies point inward. sightingsqlite imports sighting. sighting has never heard of sightingsqlite. Only internal/cmd/app.go (the wiring) names both. This is enforced by Go's package system: you literally cannot create a circular import.
4. External adapters are isolated. internal/clients/gbif/occurrences.go contains a 350-field Occurrence struct that mirrors GBIF's JSON wire format. The domain's Sighting struct is a different, smaller, Go-idiomatic shape. They meet in exactly one place: sighting.go:saveSighting(). If GBIF renames decimalLatitude, the blast radius is one file in clients/gbif/. The domain, the templates, the CLI: none of them know or care.
5. The handler is dependency-injected. web/handlers/home.go:15 doesn't take a *sighting.Service. It takes a SightingsLister:
type SightingsLister interface {
List(ctx context.Context, filter sighting.ListFilter) ([]sighting.Sighting, error)
}
This is the single method the handler needs. main.go is the only file that knows *sighting.Service satisfies it. This means the handler can be unit-tested with a fake that implements one method, without a database, without GBIF, without weather.
3. Deep Dive: The Core Mechanics
3.1 sqlc: type-safe queries from .sql files
The queries live in queries/sightings.sql, queries/species.sql, and queries/weather.sql: plain SQL files annotated with sqlc directives. Here is the full sightings.sql:
-- name: SightingFind :one
SELECT sqlc.embed(sightings), sqlc.embed(species), sqlc.embed(weather)
FROM sightings
JOIN species ON species.id = sightings.species_id
JOIN weather ON weather.id = sightings.weather_id
WHERE sightings.id = ?;
-- name: SightingFindByGbifKey :one
SELECT sqlc.embed(sightings), sqlc.embed(species), sqlc.embed(weather)
FROM sightings
JOIN species ON species.id = sightings.species_id
JOIN weather ON weather.id = sightings.weather_id
WHERE sightings.gbif_key = ?;
-- name: SightingList :many
SELECT sqlc.embed(sightings), sqlc.embed(species), sqlc.embed(weather)
FROM sightings
JOIN species ON species.id = sightings.species_id
JOIN weather ON weather.id = sightings.weather_id
WHERE (sqlc.narg('from_date') IS NULL OR happened_at >= sqlc.narg('from_date'))
AND (sqlc.narg('to_date') IS NULL OR happened_at <= sqlc.narg('to_date'))
AND (sqlc.arg('country_code') = '' OR sightings.country_code = sqlc.arg('country_code'))
AND (CAST(sqlc.arg('any_species') AS BOOLEAN) OR species.gbif_key IN (SELECT value FROM json_each(sqlc.arg('species_keys'))))
ORDER BY sightings.happened_at DESC, sightings.id DESC
LIMIT sqlc.arg('limit');
-- name: SightingCreate :one
INSERT INTO sightings (...) VALUES (?, ?, ..., ?) RETURNING *;
The sqlc.embed(sightings) directive tells sqlc to flatten the sightings table columns into the generated struct. The sqlc.narg('from_date') and sqlc.arg('country_code') directives let you build optional filters: the generated Go function takes an interface{} for nullable params, and the SQL handles the NULL checks.
The generated output (internal/infra/db/sqlc/sightings.sql.go) contains:
-
SightingFind(ctx, id) (SightingFindRow, error)returns a struct withSighting,Species, andWeatherfields, all populated from the JOIN. -
SightingList(ctx, SightingListParams) ([]SightingListRow, error)is the list query with filter params. -
SightingCreate(ctx, SightingCreateParams) (Sighting, error)is theINSERT...RETURNING.
The generated SightingListParams struct:
type SightingListParams struct {
FromDate interface{} `db:"from_date"`
ToDate interface{} `db:"to_date"`
CountryCode interface{} `db:"country_code"`
AnySpecies bool `db:"any_species"`
SpeciesKeys interface{} `db:"species_keys"`
Limit int64 `db:"limit"`
}
Notice SpeciesKeys interface{}. The store marshals the taxon keys to a JSON array string and passes it as a single argument, which the SQL then parses with json_each(). This is a deliberate choice: sqlc's slice expansion would use numbered placeholders, which SQLite rejects when followed by the LIMIT clause.
3.2 The transform: sqlc rows → domain types
The generated db.SightingListRow is a flat struct with Sighting, Species, and Weather sub-structs. The domain's sighting.Sighting is a richer type with nested value objects (sighting.Location, sighting.Coordinates, species.Species, weather.Weather). The transform.go file bridges them:
// internal/domain/sighting/stores/sightingsqlite/transform.go:14
func transform(row db.SightingListRow) (sighting.Sighting, error) {
r, sp, w := row.Sighting, row.Species, row.Weather
var media []sighting.Media
if err := json.Unmarshal([]byte(r.Media), &media); err != nil {
return sighting.Sighting{}, fmt.Errorf("decode media: %w", err)
}
if media == nil {
media = []sighting.Media{}
}
return sighting.Sighting{
ID: r.ID,
GBIFKey: r.GbifKey,
// ... all 30+ fields mapped ...
Location: sighting.Location{
CountryCode: country.Code(r.CountryCode),
Coordinates: sighting.Coordinates{
Latitude: r.Latitude,
Longitude: r.Longitude,
},
},
Species: species.Species{
GBIFKey: int(sp.GbifKey),
// ...
},
Weather: weather.Weather{
Temperature: weather.Temperature{
Actual: w.Temperature,
Apparent: w.ApparentTemperature,
},
Condition: weather.Condition{
Code: int(w.ConditionCode),
Description: w.Condition,
},
},
Media: media,
}, nil
}
This is the only place the sqlc types and the domain types meet. The country.Code(r.CountryCode) conversion is a value-object parse: it ensures the domain's CountryCode field holds a validated country code, not a raw string.
3.3 The ingest pipeline: GBIF → weather → species → sighting
The sighting.Service.Ingest() method (internal/domain/sighting/ingest.go:84) is the heart of the application. It orchestrates a multi-step pipeline:
GBIF Occurrence Search
↓
For each occurrence:
├─ 1. Check skippers (status must be PRESENT, must have media)
├─ 2. Resolve species (find or fetch from GBIF and create)
├─ 3. Resolve weather (fetch from Open-Meteo and create)
├─ 4. Save sighting (validate, parse coordinates/country, insert)
└─ 5. Count: created / skipped / duplicates / failed
Step 2: species resolution (speciesPerSighting, line 172). The service first tries s.species.FindByGbifKey(). If the species exists, it returns the ID. If not (ErrNotFound), it fetches the species from GBIF's species endpoint and creates it. This is a classic "find or create" pattern that keeps the database self-populating.
Step 3: weather resolution (weatherPerSighting, line 209). For each sighting, the service calls Open-Meteo's archive API with the occurrence's coordinates and event date. It requests a single hour of data (start_hour = end_hour = the truncated hour), and maps the response into a weather.CreateParams with Temperature, Precipitation, Wind, and Condition value objects.
Step 4: sighting creation (saveSighting, line 253). The service parses coordinates with ParseCoordinates() (which enforces the -90/90 and -180/180 bounds at parse time, the "parse, don't validate" pattern), parses the country code with country.Parse(), maps the GBIF media, and calls s.Create(). The Service.Create() method validates the params and assigns a UUID before handing to the store.
The result is an IngestResult with counts of created, fetched, skipped, duplicates, and failed occurrences, surfaced in the CLI with color-coded output.
3.4 templ: type-safe server-side rendering
The templ files are Go functions that return HTML. They are not strings. They are typed Go code that the templ tool compiles into Go source files that the go build toolchain type-checks.
The layout (web/views/layout/layout.templ):
package layout
type BaseProps struct {
Title string
}
templ Base(props BaseProps) {
<!DOCTYPE html>
<html lang="en-GB">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{ props.Title }</title>
<link rel="stylesheet" href="/assets/css/app.css"/>
</head>
<body>
<main class="container">
{ children... }
</main>
<script src="/assets/js/app.js"></script>
</body>
</html>
}
The { children... } expression is a slot: templ's mechanism for parent components to render child content. The Base layout defines the HTML shell, and pages fill the slot.
The homepage (web/views/pages/home.templ):
package pages
import (
"workshop/internal/domain/sighting"
"workshop/web/views/components"
"workshop/web/views/layout"
)
templ Home(sightings []sighting.Sighting) {
@layout.Base(layout.BaseProps{
Title: "Sightings",
}) {
<h1>Sightings</h1>
if len(sightings) == 0 {
<p>No sightings have been ingested yet.</p>
} else {
<p>Total: { len(sightings) }</p>
<ul class="sightings">
for _, s := range sightings {
<li>
@components.SightingCard(s)
</li>
}
</ul>
}
}
}
This is the key integration point. The Home function takes []sighting.Sighting, a domain type. It passes each sighting to @components.SightingCard(s). The templ compiler verifies that SightingCard expects a sighting.Sighting and that s is a sighting.Sighting. If you rename a field in the domain struct, every template that references it fails to compile.
The sighting card (web/views/components/sighting_card.templ):
templ SightingCard(s sighting.Sighting) {
<article class="card">
<header class="card__header">
<h2>{ displayName(s.Species) }</h2>
if s.Species.ScientificName != "" {
<p class="card__scientific">{ s.Species.ScientificName }</p>
}
</header>
<dl class="card__meta">
<dt>Seen</dt>
<dd>{ s.HappenedAt.Format("2 January 2006") }</dd>
<dt>Where</dt>
<dd>{ place(s.Location) }</dd>
if s.IndividualCount != nil {
<dt>Count</dt>
<dd>{ individuals(*s.IndividualCount) }</dd>
}
if s.RecordedBy != "" {
<dt>Recorded by</dt>
<dd>{ s.RecordedBy }</dd>
}
</dl>
<div class="card__gallery">
@Gallery(s.Media)
</div>
</article>
}
func displayName(sp species.Species) string {
return cmp.Or(sp.VernacularName, sp.CanonicalName, sp.ScientificName, "Unknown species")
}
The card uses Go's cmp.Or for the display-name fallback chain, slices.DeleteFunc for the location join, and fmt.Sprintf for the individual count. These are standard Go functions, not template-specific DSLs. The templ compiler inlines them into the generated HTML output.
The gallery (web/views/components/gallery.templ):
templ Gallery(items []sighting.Media) {
if len(items) == 0 {
<p class="gallery__empty">No media recorded.</p>
} else {
<ul class="gallery">
for _, m := range items {
<li class="gallery__item">
if m.Type == "StillImage" {
<img src={ m.URL } alt={ altText(m) } loading="lazy"/>
} else {
<a href={ templ.URL(m.URL) } rel="noopener">{ altText(m) }</a>
}
if m.Creator != "" {
<figcaption class="gallery__credit">{ m.Creator }</figcaption>
}
</li>
}
</ul>
}
}
Note the conditional rendering based on m.Type == "StillImage": images get <img> with lazy loading, other media types get links. The templ.URL(m.URL) call is a URL sanitizer: it marks the value as a safe URL so templ doesn't strip it, while still blocking unsafe schemes such as javascript:.
3.5 The handler: glue between service and template
web/handlers/home.go is the thinnest layer in the application:
func Home(logger *slog.Logger, sightings SightingsLister) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
items, err := sightings.List(ctx, sighting.ListFilter{
Limit: 50,
})
if err != nil {
logger.ErrorContext(ctx, "handlers: listing sightings: "+err.Error())
renderError(ctx, w, logger, http.StatusInternalServerError, "Unable to load sightings.")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err = pages.Home(items).Render(ctx, w); err != nil {
logger.ErrorContext(ctx, "handlers: rendering sightings: "+err.Error())
renderError(ctx, w, logger, http.StatusInternalServerError, "Unable to render page.")
}
}
}
The handler:
- Calls the service (through the
SightingsListerinterface). - On error, renders an error page with the appropriate status code.
- On success, sets the content type and calls
pages.Home(items).Render(ctx, w).
The .Render(ctx, w) call is templ's runtime: it writes the generated HTML to the http.ResponseWriter. The pages.Home(items) call returns a templ.Component, which is an interface. The compiler knows the concrete type and its props.
3.6 The wiring: app.go
internal/cmd/app.go:bootstrap() is where everything comes together:
func bootstrap(ctx context.Context, cfg config.Config, level slog.Level, live bool) (*app, func(), error) {
conn, err := sqlite.New(ctx, cfg.DatabaseURL)
// ...
migrate, err := migrations.New(conn)
// ...
queries := db.New(conn)
speciesStore := speciessqlite.New(queries)
gbifURL, openMeteoURL := localGBIFBaseURL, localOpenMeteoBaseURL
if live {
gbifURL, openMeteoURL = gbif.BaseURL, openmeteo.BaseURL
}
openm := openmeteo.New(openMeteoURL)
speciesSvc := species.NewService(logger, speciesStore)
weatherSvc := weather.NewService(logger, weathersqlite.New(queries))
sightingSvc := sighting.NewService(logger,
sightingsqlite.New(queries),
speciesSvc,
weatherSvc,
gbif.New(gbifURL),
openm,
)
// ...
}
Every service and store is constructed here. The --live flag swaps the mock API URLs for the real GBIF and Open-Meteo URLs. The db.New(conn) call wraps the *sql.DB in sqlc's Queries struct, which is passed to every store. All three stores share the same Queries (and therefore the same *sql.DB connection): this is the single-database pattern.
3.7 The CLI: interactive commands
The CLI is built with urfave/cli/v3 for command structure and charmbracelet/huh for interactive forms. The cmd.go file implements an interactive menu loop:
func interactive(app *app) cli.ActionFunc {
return func(ctx context.Context, root *cli.Command) error {
if !prompt.CanInteract() {
return cli.ShowRootCommandHelp(root)
}
for {
chain, err := prompt.Menu(commands(app))
// ...
ran, err := runPicked(ctx, app, chain)
// ...
}
}
}
When you run make run (or just go run .), you get a menu-driven interface: pick sightings → ingest, answer the prompts, and watch the progress. When you run workshop sightings ingest -n 500 -c KE, you get the same command non-interactively. Both paths converge on the same runIngest() function, which calls the same sighting.Service.Ingest() method.
The CLI also has a --live flag that switches from the bundled mock APIs to the real GBIF and Open-Meteo endpoints, and a --verbose flag that enables debug-level logging.
3.8 Hot reload: the developer experience
The Makefile has these key targets:
web: go run main.go web
web-hot: go tool templier --config ./templier.yml
templ: go tool templ generate ./...
sqlc: go tool sqlc generate && go build ./...
test: go clean -testcache && go test ./... -coverprofile=coverage.out -covermode=atomic
-
make webbuilds and serves the site on:8080. -
make web-hotruns templier, which watches your source files, recompiles the Go app on any change, and serves behind a live-reload proxy on:8080. Edit a.templfile, save, and the browser refreshes. -
make templregenerates all_templ.gofiles from.templfiles. Run this after editing templates if you're not using hot reload. -
make sqlcregenerates all sqlc output from.sqlfiles and queries, then builds to verify. -
make testruns the full test suite with coverage.
The templier.yml config explicitly excludes *.db files from triggering rebuilds (SQLite writes shouldn't restart the app), and excludes vendor/ (thousands of files that only change with make vendor).
4. The Architecture Ladder
The architectures/ directory contains five progressively more sophisticated layouts of the same application, each with its own README.md explaining the tradeoffs:
| # | Name | Organization | DDD? | Key Insight |
|---|---|---|---|---|
| 01 | Flat | One package, files by kind | No | "Start here. Premature abstraction is worse than no abstraction." |
| 02 | Layered |
handlers/, services/, store/, models/
|
Partial | Layers enforce direction but not ownership. |
| 03 | Onion | Domain at center, everything points in | Yes | "The rules. Everything that would still be true if we deleted the database and the API." |
| 04 | Hexagonal | Domain per noun, adapters per technology | Yes |
var _ sighting.Store = (*Store)(nil): the compiler checks the hexagon. |
| 05 | Feature | Adapters move to where they belong | Yes | "A feature is a folder. Everything about a sighting is under domain/sighting, and nothing else is." |
The final production layout follows the 05-Feature model: domain/sighting/stores/sightingsqlite/ for persistence, clients/gbif/ for the external API, web/handlers/ for HTTP, and web/views/ for templates. The READMEs are candid about the tradeoffs: more folders for the same behavior, longer paths, and the risk of hiding coupling behind vertical silos.
5. Why This Stack Delivers
Performance. Server-side rendering with Go means no JavaScript bundle to download, no hydration, no API round-trips. The home page handler does one SQL query (with a 3-table JOIN) and renders HTML. Under load testing with hey, the page serves in single-digit milliseconds. The binary is a single static executable with no runtime dependencies.
Developer productivity. Change a column type in a migration? Run make sqlc. Change a template? Run make templ (or just edit and save with make web-hot). Add a new page? One file in web/handlers/ and one line in web/web.go. The compiler, not a linter, not a code review, not a CI job, enforces the boundaries between domain, infrastructure, and view.
Testability. The domain rules (validate.go) can be tested with no database, no network, no HTTP server. The SQL queries can be tested against a real SQLite database via internal/infra/db/dbtest/. The handlers can be tested with a SightingsLister fake. The external clients can be tested with JSON fixtures. There is barely a mock in this codebase, and the README explicitly calls that out as a deliberate choice: "most of what a mock would prove here is that we typed the interface out twice."
Maintainability. The DDD layout means that when GBIF changes its API, the change lands in internal/clients/gbif/. When you swap SQLite for Postgres, the change lands in internal/domain/sighting/stores/. When you add a new page, the change lands in web/handlers/ and web/web.go. The domain (the rules, the types, the invariants) never moves.
This article is based on the workshop repository by Ainsley Clark, presented at GopherCon Africa. The code is available at github.com/ainsleyclark/workshop-sqlc-templ-test.
Social Post Version
What if your frontend wasn't a JavaScript bundle, but Go itself?
At GopherCon Africa, Ainsley Clark's workshop app proved it: type-safety from the database all the way to the HTML. No TypeScript. No REST API contracts. No npm.
Just Go. Compiled. Fast.
The stack
sqlc → templ → chi → SQLite
-
sqlcgenerates compile-time Go structs from your.sqlquery files -
templrenders those structs into type-safe HTML, server-side - The whole frontend compiles into the same binary as the backend
- One
go build= working site. No build step. Nonode_modules.
Architecture: Domain-Driven Design, done right
internal/domain/ → The rules (sighting, species, weather)
internal/infra/ → SQLite + sqlc output (the adapter)
internal/clients/ → GBIF + Open-Meteo (outbound adapters)
web/ → Handlers + .templ views + assets
Dependencies point inward. The domain declares the Store interface. The adapter implements it. The compiler checks it:
var _ sighting.Store = (*Store)(nil)
External APIs are walled off behind one package. GBIF renames a field? The blast radius is one file.
The type-safety chain
-- SQL query (queries/sightings.sql)
SELECT sqlc.embed(sightings), sqlc.embed(species), sqlc.embed(weather)
FROM sightings JOIN species ... JOIN weather ...
// sqlc generates:
type SightingListRow struct {
Sighting Sighting
Species Species
Weather Weather
}
// transform maps to domain:
sighting.Sighting{
Location: sighting.Location{...},
Species: species.Species{...},
Weather: weather.Weather{...},
}
// passed to templ:
templ Home(sightings []sighting.Sighting) {
for _, s := range sightings {
@components.SightingCard(s)
}
}
Rename a field in the domain struct? Every template that uses it fails to compile. That's the compiler as your contract: not OpenAPI, not a code review.
Hot reload, Go-native
make web-hot runs templier. It watches your .templ files, recompiles the Go app on save, and proxies to the browser with live reload.
Edit sighting_card.templ. Save. Browser refreshes. No HMR server. No bundler. No JS toolchain.
The CLI is a first-class citizen
-
make run→ interactive menu (built withhuh+urfave/cli/v3) -
workshop sightings ingest -n 500 -c KE→ same pipeline, non-interactive -
--liveflag → swaps mock APIs for real GBIF + Open-Meteo
The ingest pipeline
GBIF occurrence search → resolve species (find-or-create) → resolve weather (Open-Meteo) → validate → save sighting → count created/skipped/duplicates.
Five architectures, one app
The architectures/ folder walks through a ladder: Flat → Layered → Onion → Hexagonal → Feature
Each README is brutally honest about tradeoffs. The production layout (Feature) wins because: "A feature is a folder. Everything about a sighting is under domain/sighting, and nothing else is."
Why this matters
- Performance: SSR with Go = single-digit ms page renders, no hydration, no JS bundle
-
Productivity: One
go build= site. Compiler enforces boundaries. No contract drift. - Testability: Domain rules testable without DB, network, or HTTP. SQL tested against real SQLite.
- Maintainability: GBIF changes → one file. Storage swap → one adapter. New page → one file + one line.
The takeaway
You don't need a frontend framework to build a modern web app. You need type-safety. You need boundaries the compiler can check. You need Go, all the way down.
Top comments (0)