ramblingenzyme/peg

Postgres + Elm + Go

0

stars

8

commits

Go

primary language

Sep 9, 2026

updated

README

peg

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
  • Go → Elm generated by protoc, i.e. Protobufs.
  • The API comes from the service block in peg.proto, via connect-go and protoc-gen-elm. No routing table or URL strings written by hand on either side.

The wire is binary protobuf over gRPC-Web.

The rationale

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?

The contract

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}

Running it

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
commandwhat it does
npm startbuild, then serve
npm run buildregenerate, then build client and server
npm testfull endpoint walk against a scratch schema
npm run generaterun the four code generators only
npm run checkverify the toolchain
npm run cleandelete build output and generated code
npm run db / db:rmstart / remove the Postgres container

PORT and DATABASE_URL are the only knobs.

Changing the schema

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

Layout

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.

Contributors

ramblingenzyme/peg

Postgres + Elm + Go

0

stars

8

commits

Go

primary language

Sep 9, 2026

updated

README

peg

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
  • Go → Elm generated by protoc, i.e. Protobufs.
  • The API comes from the service block in peg.proto, via connect-go and protoc-gen-elm. No routing table or URL strings written by hand on either side.

The wire is binary protobuf over gRPC-Web.

The rationale

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?

The contract

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}

Running it

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
commandwhat it does
npm startbuild, then serve
npm run buildregenerate, then build client and server
npm testfull endpoint walk against a scratch schema
npm run generaterun the four code generators only
npm run checkverify the toolchain
npm run cleandelete build output and generated code
npm run db / db:rmstart / remove the Postgres container

PORT and DATABASE_URL are the only knobs.

Changing the schema

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

Layout

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.

See what people are saying

Contributors

Languages

Go

38.5%

Elm

32.0%

Shell

19.6%

HTML

5.8%

JavaScript

4.1%