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

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

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:

  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, ) // ... } 

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 { ifSource:https://dev.to/amayo_clinton/full-stack-go-domain-driven-applications-with-sqlc-and-templ-2gff

Related posts