DEV Community

Cover image for Full-Stack Go: Domain-Driven Applications with sqlc and templ
Amayo Clinton
Amayo Clinton

Posted on

Full-Stack Go: Domain-Driven Applications with sqlc and templ

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 generate produces Go files. go build produces a single binary. There is no webpack, no npm install, no node_modules. The "frontend" is compiled into the same binary as the backend.
  • The database is the only source of truth. Your .sql query files, your migrations/*.sql, and your sqlc.yaml config are the spec. Run make sqlc and the generated code reflects them exactly. Run make templ and your templates reflect your .templ files.
  • Hot reload without a frontend build pipeline. make web-hot runs templier, which watches your source files, recompiles the Go app on any change, and proxies to the browser with a live-reload script injected. Edit sighting_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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode
// internal/domain/sighting/stores/sightingsqlite/store.go:21
var _ sighting.Store = (*Store)(nil) // Compile-time interface check
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 *;
Enter fullscreen mode Exit fullscreen mode

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 with Sighting, Species, and Weather fields, all populated from the JOIN.
  • SightingList(ctx, SightingListParams) ([]SightingListRow, error) is the list query with filter params.
  • SightingCreate(ctx, SightingCreateParams) (Sighting, error) is the INSERT...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"`
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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>
    }
}
Enter fullscreen mode Exit fullscreen mode

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.")
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The handler:

  1. Calls the service (through the SightingsLister interface).
  2. On error, renders an error page with the appropriate status code.
  3. 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,
    )
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
            // ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • make web builds and serves the site on :8080.
  • make web-hot runs templier, which watches your source files, recompiles the Go app on any change, and serves behind a live-reload proxy on :8080. Edit a .templ file, save, and the browser refreshes.
  • make templ regenerates all _templ.go files from .templ files. Run this after editing templates if you're not using hot reload.
  • make sqlc regenerates all sqlc output from .sql files and queries, then builds to verify.
  • make test runs 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

  • sqlc generates compile-time Go structs from your .sql query files
  • templ renders 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. No node_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
Enter fullscreen mode Exit fullscreen mode

Dependencies point inward. The domain declares the Store interface. The adapter implements it. The compiler checks it:

var _ sighting.Store = (*Store)(nil)
Enter fullscreen mode Exit fullscreen mode

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 ...
Enter fullscreen mode Exit fullscreen mode
// 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{...},
}
Enter fullscreen mode Exit fullscreen mode
// passed to templ:
templ Home(sightings []sighting.Sighting) {
    for _, s := range sightings {
        @components.SightingCard(s)
    }
}
Enter fullscreen mode Exit fullscreen mode

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 with huh + urfave/cli/v3)
  • workshop sightings ingest -n 500 -c KE → same pipeline, non-interactive
  • --live flag → 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)