A todo list app demonstrating the Postgres + Elm + Go (PEG) stack end to end.
Every seam in it is generated:
- DB → Go generated withsqlc
- Go → Elm generated by
protoc, i.e. Protobufs. - The API comes from the
serviceblock inpeg.proto, viaconnect-go andprotoc-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"}'
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
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.