# PEG: Postgres and Elm and Go

> Source: <https://github.com/ramblingenzyme/peg/tree/main>
> Published: 2026-09-10 00:28:18+00:00

A todo list app demonstrating the Postgres + Elm + Go (PEG) stack end to end.

Every seam in it is generated:

- **DB → Go** generated with[sqlc](https://sqlc.dev)
- **Go → Elm** generated by`protoc` , i.e. Protobufs.
- **The API** comes from the`service` block in`peg.proto` , via[connect-go](https://connectrpc.com) and`protoc-gen-elm` . No routing table or URL
strings written by hand on either side.

The wire is binary protobuf over gRPC-Web.

Generating code is now easy and cheap with LLMs, which makes it cheap to explore new languages and see how they work, or get an idea for how they can approach problems differently.

Codebases can also get messy very quickly and easily when using LLMs to generate the majority of your code. Go and Elm are both very strongly typed at compile time & are much more opinionated languages than Javascript/Typescript, which hopefully translates to less wiggle room for an LLM to go off the rails. Additionally, the Elm Architecture (TEA) of a Model (state), Update & View is a single direction architecture, and without a JS interop, all changes go through it, whereas in a React application, you can bypass the virtual DOM and directly mutate it yourself.

Note: of course Elm compiles down into Javascript, but we're insulated from the madness of the package ecosystem and it doesn't have the escape hatches that Typescript has which allows LLMs to suppress legitimate errors.

By leaning on traditional code generation an LLM has less code to generated non-deterministically to build an application. This also lets people & LLMs focus more on the higher level details and increases their leverage/effectiveness when making changes.

In this case, we're using `dbsql` to generate the DB code, and protobufs to define the wire protocol and API. By having the messages and actions (API) defined by the protobuf definitions, we also couple the frontend and backend together with strong types, which makes it very hard for an LLM to cause them to drift apart and end up in a loop trying to fix it.

I chose protobufs because I haven't used them before and wanted to learn, but GraphQL is likely the more appropriate choice, and still has tooling to generate code for servers & clients.

Also, I just threw Postgres in there to make the acronym work. I mean, what other DB are you going to use unless you can use SQLite, have very specialised requirements or are already working at a ridiculous scale?

`packages/proto/peg.proto` is the contract between server and client:

```
message Todo {
  uint32 id        = 1;
  string title     = 2;
  bool   done      = 3;
  double createdAt = 4;   // Unix milliseconds
}

service Todos {
  rpc List    (ListRequest)    returns (TodoList);
  rpc Create  (NewTodo)        returns (Todo);
  rpc SetDone (SetDoneRequest) returns (Todo);
  rpc Delete  (DeleteRequest)  returns (DeleteResponse);
}
```

Errors are gRPC status codes: `invalid_argument` for an empty title, `not_found` for
a missing id. They travel in trailers, so the client shows the message the handler
wrote.

`connect-go` serves Connect, gRPC and gRPC-Web from one `net/http` handler at
`/peg.Todos/*`, with no proxy. The browser speaks gRPC-Web. The Connect protocol
also accepts JSON, so the binary wire stays debuggable:

```
curl -X POST localhost:8080/peg.Todos/Create \
  -H 'Content-Type: application/json' -d '{"title":"buy milk"}'
# {"id":1,"title":"buy milk","createdAt":1788926311237}
```

Generated code is not committed, so the generators are required. First time:

```
npm install                                                      # protoc-gen-elm
npm run db                                                       # Postgres 18
export DATABASE_URL='postgres://postgres:peg@localhost:5432/postgres'
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest
# plus protoc, sqlc, elm, node from your package manager
```

`npm run check` names whichever tool is missing, with its install command. Every
build runs it first.

```
npm start                       # → http://localhost:8080
```

| command | what it does | 
|---|---|
| `npm start` | build, then serve | 
| `npm run build` | regenerate, then build client and server | 
| `npm test` | full endpoint walk against a scratch schema | 
| `npm run generate` | run the four code generators only | 
| `npm run check` | verify the toolchain | 
| `npm run clean` | delete build output and generated code | 
| `npm run db` /`db:rm` | start / remove the Postgres container | 

`PORT` and `DATABASE_URL` are the only knobs.

Edit `packages/proto/peg.proto` or `packages/server/sql/schema.sql`, then
`npm run generate`. Nothing else needs touching.

Everything you would edit lives under `packages/`. Everything at the root is
machinery. Generated code lives under a `gen/` directory and is not checked in.

```
package.json             every task; `npm run` to list them
scripts/                 check-tools, gen, build, dist
build/                   all output; build/dist is the deployable client

packages/proto/peg.proto the contract, owned by neither side

packages/server/         everything Go, module peg/server
  main.go                wiring: db, handler, file server
  rpc.go                 the four RPC methods, and pbTodo
  sql/schema.sql         sqlc's input, and applied at startup
  sql/query.sql          sqlc's input
  internal/gen/          GENERATED by protoc-gen-go, -connect-go, sqlc

packages/client/         everything Elm/JS
  index.html             template; dist.sh rewrites the script tag
  minify.mjs             oxc wrapper
  src/Main.elm           hand-written
  gen/Proto/             GENERATED by protoc-gen-elm
```

`go.mod` lives in `packages/server`, so `go test ./...` runs from there. Or use
`npm test`.
