My fix checker found two missing tests. Then it wrote them.
---
title: My fix checker found two missing tests. Then it wrote them.
tags: testing, ai, opensource, python
published: true
---
In my last post, Bite checked some well-known projects and found a few small gaps: changes that no test would notice if they broke. Two of them:
- In Rich, Markdown <kbd>Ctrl</kbd> is drawn in bold yellow, and no test checks it.
- In chi, a Go web router, a change made XML responses get compressed by default. The commit did add a test, but that test counts the list against itself, so it still passes with the change taken out.
Finding a gap is the easy half. Someone still has to write the test. So this week Bite got a new option, --write-tests, that tries to write it.
How it works
For each change no test noticed, a fresh AI worker writes one small test on a copy of the project. Then Bite doesn't take its word for it. It runs the test itself:
1. With the change in, the test must pass, twice.
2. With the change taken out, the test must fail, and fail for a real reason (not just a crash on loading).
If either part goes wrong, Bite throws the test away and says so. It uses your own Anthropic API key, and --budget caps what a run spends.
What happened
| Gap | Bite's test | With the change | Without it | Cost |
|---|---|---|---|---|
| Rich <kbd> | prints <kbd>Ctrl</kbd>, checks Ctrl is bold yellow | passes, twice | fails: Ctrl not bold yellow | $0.14 |
| chi XML | sends XML through chi's compressor, checks it comes back gzipped | passes, twice | fails: XML not gzipped | $0.10 |
Total: $0.34. That's more than the two rows add up to, because of the next part.
The first chi try got thrown away, and that was Bite's fault. Go runs tests by folder, not by file, and Bite asked Go to run one file alongside the whole project. Go refused before running anything. Bite's rule caught it: a test that fails before it even runs doesn't count. So it threw the test out instead of showing a fake result. The fix is in PR #67, and the second try worked.
Did it write the right test?
Before Bite could do this, Claude had already written a test for each gap the slow way, to confirm the gaps were real. So there was an answer key.
- Rich: Bite's test checks the same thing as the answer key: Ctrl comes out bold bright yellow.
- chi: the answer key (chi #1246, still open) adds XML to chi's existing list test. Bite's test goes further: it sends a real XML response through the compressor and checks it gets zipped. Different route, same claim.
Here's the Rich one, exactly as Bite wrote it:
# Guards that <kbd>…</kbd> in Markdown renders its text in the markdown.kbd style.
from rich.console import Console
from rich.markdown import Markdown
def test_kbd_inline_html_is_styled():
console = Console(
file=__import__("io").StringIO(),
force_terminal=True,
color_system="truecolor",
width=40,
)
console.print(Markdown("Press <kbd>Ctrl</kbd> now"))
output = console.file.getvalue()
assert "\x1b[1;93mCtrl\x1b[0m" in output
Then we tried the whole loop
On my own copy of Rich, Bite ran as a GitHub Action on a pull request. It found the <kbd> gap, wrote the test, and opened a small pull request with the test in it. I merged that pull request, and Bite's check on the original pull request turned green. Gap found, test written, test merged, without anyone typing a test.
What I took from it
- Don't trust a test you didn't watch fail. The pass-and-fail check is the whole point. An AI can write a test that looks right and checks nothing, just like the chi commit's own test did.
- Throwing things away is a feature. The chi test that never ran would have looked like a catch. Bite saying "no" there is what makes its "yes" worth something.
- Two gaps is a small trial. It worked twice, on two languages. That's a start, not proof it always works.
It's in v0.7.0 (pipx upgrade bite-check). On your own computer: bite --write-tests. In GitHub Actions, the setup is in Bite's README. Since this trial, Bite also asks a second fresh check whether each test is fair before showing it.
---
Disclosure: I'm not a coder. Claude ran these trials, wrote Bite's code and helped me draft this post. The numbers come from the run logs.
@aptabeenhere Use plain net/http or chi😋
go-chi/chi
5 likes · 497 views
🚨 New MEDIUM CVE detected in AWS Lambda 🚨
CVE-2026-114437 impacts github.com/go-chi/chi/v5 in 27 Lambda base images.
Details: aws-lambda-base-images#767
More: lambdawatchdog.com
#AWS #Lambda #CVE #CloudSecurity #Serverless
npm と比べて分かった Go の依存管理の「一番の違い」── go get の中身を 6 ステップで追う
2026 年 9 月 30 日の Go Bash vol.3 で「npm との違いを手がかりに Go の依存管理を理解したい」というタイトルで登壇しました。この記事はその内容を、登壇では時間の都合で省いた実測ログも足して書き直したものです。
この記事で扱うこと
普段 Go を書いていると、go get を打てば依存が入り、go.sum も勝手に増えていきます。それで困ったこともありません。ただ、次の質問に即答できるでしょうか。
go.sum は package-lock.json と同じものですか?
go get したモジュールの実体は、どこに置かれていますか?
そのディレクトリを、別のプロジェクトが書き換えたらどうなりますか?
私は答えられませんでした。この記事では、go get が内部でやっていることを 6 つのステップに分けて追いかけ、上の問いに答えていきます。
確認した環境は次のとおりです。
対象
バージョン
Go
1.26.8
依存に使うモジュール
github.com/go-chi/chi/v5 v5.3.2
比較対象の Node.js / npm
Node.js v26 系 / express 5.2.1
最初に、登壇の冒頭で会場に投げかけた問いを紹介します。
npm と Go の一番の違いは何だと思いますか?
答えは記事の最後で出します。少し考えてから読み進めてみてください。
同じサーバーを Go と npm で書いて比べる
「Hello」を返すだけの HTTP サーバーを、Go(chi)と Node.js(express)で書きました。やっていることは同じです。
main.go
package main
import (
"fmt"
"log"
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, "Hello, Go server!")
})
log.Fatal(http.ListenAndServe(":8080", r))
}
index.js
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.send("Hello, Node server!\n");
});
app.listen(8080);
依存の実体がどこにあるかを調べます。npm の場合は、プロジェクト直下の node_modules にあります。
$ npm ls express --parseable
/path/to/ex-npm/node_modules/express
$ ls -1 node_modules | wc -l
65
$ find node_modules -type f | wc -l
601
express を 1 つ指定しただけで、node_modules には 65 ディレクトリ・601 ファイル(3.8MB)が展開されていました。
一方、Go のプロジェクトを ls すると main.go・go.mod・go.sum の 3 つしかありません。chi を import してビルドできているのに、chi のソースはプロジェクトの中に無いのです。どこにあるのかを go コマンドに聞きます。
ホームディレクトリは ~ に置き換えています
$ go env GOMODCACHE
~/go/pkg/mod
$ go list -m -f '{{.Dir}}' github.com/go-chi/chi/v5
~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2
実体は、ホームディレクトリの下の モジュールキャッシュ(GOMODCACHE、既定は ~/go/pkg/mod)にありました。このキャッシュはマシンに 1 つで、どのプロジェクトからも同じ場所が読まれます。
Go
プロジェクトA ─┐
├─→ ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2
プロジェクトB ─┘ マシンに 1 つ
npm
プロジェクトA/node_modules/express ← プロジェクトごとに別のコピー
プロジェクトB/node_modules/express
共有して、困らないのか?
npm に慣れていると、ここで不安になります。1 つの実体を全プロジェクトで共有して、本当に大丈夫なのでしょうか。私が感じた疑問は 4 つです。
なぜ同じディレクトリに置いているのか?
別のプロジェクトが別のバージョン(たとえば v5.2.0)を使いたくなったら?
別のプロジェクトがキャッシュの中身を書き換えたら?
モジュールの作者が v5.3.2 のタグを別のコミットに付け替えたら?
この 4 つの答えは、どれも go get の処理の中にあります。順番に追っていく前に、用語を 2 つだけ揃えておきます。
Go の「モジュール」は npm の「パッケージ」にあたる
同じ「パッケージ」という言葉でも、Go と npm では指すものが逆です。
Go
npm
バージョンを付けて配布・取得される単位
module(go.mod があるもの)
package(package.json があるもの)
コードを読み込む単位
package(同じディレクトリにあり、まとめてコンパイルされる .go ファイル群)
module(require / import で読み込めるファイルやディレクトリ)
この記事で npm の package と比べるのは、Go の モジュール のほうです。
package.json は範囲、go.mod は最低バージョン
バージョンの書き方も違います。
package.json
{
"dependencies": {
"express": "^5.2.1"
}
}
go.mod
module github.com/shirokuma/conference-report/GoBash/ex-Go
go 1.26.8
require github.com/go-chi/chi/v5 v5.3.2
^5.2.1 は「5.2.1 以上 6.0.0 未満」という 範囲 です。範囲のうちどのバージョンが入るかはインストールした時点の最新版で変わるので、npm は選んだ結果を package-lock.json に記録します。範囲の宣言と結果の記録、2 つのファイルで 1 組です。
go.mod の require には範囲を書きません。公式リファレンスでは、require は依存モジュールの 最低バージョン を宣言するものだと定義されています。
A require directive declares a minimum required version of a given module dependency.
ここで次の疑問が湧きます。範囲を書かないなら、選んだ結果を記録する必要もなさそうです。では、go.sum は何を記録しているのでしょうか。 冒頭の「go.sum は lock ファイルか」という問いも、ここにつながります。
go get の出力に残る 3 つの謎
モジュールキャッシュが空の状態で go get を 1 回打つと、出力はこれだけです。
$ go get github.com/go-chi/chi/v5
go: downloading github.com/go-chi/chi/v5 v5.3.2
go: downloading github.com/go-chi/chi v1.5.5 # ① 頼んだのは 1 つなのに 2 つ取得している
go: added github.com/go-chi/chi/v5 v5.3.2 # ② v5.3.2 はどう決まった?
$ cat go.sum
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
③ なぜ 2 行? h1: とは何?
短い出力ですが、1 行ずつ読むと分からないところが 3 つあります。特に ① は、/v5 を頼んだのに v1 系の chi v1.5.5 まで落としてきているので不思議です。
go get は内部で、だいたい次の 6 ステップを踏んでいます(Go Modules Reference)。
必要なモジュールとバージョンを決める
モジュールプロキシ(または Git などの VCS)からモジュールを取得する
モジュールキャッシュに保存する
暗号学的ハッシュを計算する
go.sum または checksum database(sum.golang.org)と照合する
go.sum に記録する
このステップを 1 つずつ追うと、①〜③ の謎と、さきほどの 4 つの疑問が順に解けていきます。
!
go: downloading の行はキャッシュが空のときしか出ません。手元のキャッシュを汚さずに再現するには、使い捨てのディレクトリを GOMODCACHE に指定します。キャッシュは読み取り専用で作られるため、後で消せるように -modcacherw も付けておきます。
GOMODCACHE=$(mktemp -d) GOFLAGS=-modcacherw go get -x github.com/go-chi/chi/v5
STEP1 必要なモジュールとバージョンを決める
import path のどこまでがモジュールなのか
import "github.com/go-chi/chi/v5" と書いても、go コマンドには「どこまでがモジュールパスで、どこからがモジュール内のディレクトリか」が分かりません。
github.com/go-chi/chi/v5 ← この名前のモジュール?
github.com/go-chi/chi ← このモジュールの中の v5/ ディレクトリ?
そこで go コマンドは 2 段階で探します(Resolving a package to a module)。
まず手元の ビルドリスト(ビルドに使うモジュールとバージョンの一覧。go list -m all で見られます)から、モジュールパスが import path の接頭辞になっているものを探します。go.mod に require があればここで決まり、ネットワークは使いません。
$ GOPROXY=off go list -f '{{.ImportPath}} → {{.Module.Path}} {{.Module.Version}}' \
github.com/go-chi/chi/v5 github.com/go-chi/chi/v5/middleware
github.com/go-chi/chi/v5 → github.com/go-chi/chi/v5 v5.3.2
github.com/go-chi/chi/v5/middleware → github.com/go-chi/chi/v5 v5.3.2
初回の go get ではビルドリストに chi が無いので、次の段階に進みます。import path の末尾から 1 要素ずつ削ったパスをすべてモジュールパスの候補にして、モジュールプロキシに「最新バージョンは?」と並列に問い合わせるのです。-x を付けると、その様子が見えます。
$ go get -x github.com/go-chi/chi/v5 2>&1 | grep -E '/@v/list: |go: downloading'
get proxy.golang.org: 200 OK
get proxy.golang.org: 200 OK
get proxy.golang.org: 404 Not Found
get proxy.golang.org: 404 Not Found
go: downloading github.com/go-chi/chi/v5 v5.3.2
go: downloading github.com/go-chi/chi v1.5.5
200 が返ったのは chi/v5 と chi の 2 つです。公式リファレンスによると、go コマンドは応答があったモジュールの最新バージョンを取得し、要求されたパッケージを含むかどうかを確かめます。含むモジュールが複数あれば、パスが最も長いものを使います。
① の謎はこれで解けます。 chi v1.5.5 は、「chi モジュールの中に v5/ ディレクトリがあるのでは?」という候補を確かめるために取ってきたものでした。
MVS でバージョンを決める
モジュールが決まったら、次はバージョンです。Go は MVS(Minimal Version Selection) という方法で決めます(Minimal version selection (MVS))。
MVS はメインモジュールから各モジュールの go.mod の require をたどり、モジュールごとに「要求された中で最も高いバージョン」を記録します。たどり終えた時点の記録がビルドリストです。
メインモジュール ─require→ 依存 A ── chi v5.0.0 以上が必要
└require→ 依存 B ── chi v5.2.0 以上が必要
↓ MVS
ビルドリスト: chi v5.2.0
「最も高い」なのに「Minimal」と呼ぶのは、これが すべての要求を満たす最小のバージョン だからです。上の例で v5.3.2 が公開されていても、誰も要求していなければ選びません。
この決め方の材料は、各モジュールの go.mod だけです。新しいバージョンが公開されても結果は変わりません。公式リファレンスはこう書いています。
MVS is deterministic, and the build list doesn't change when new versions of dependencies are released.
(中略)
Unlike other dependency management systems, the build list is not saved in a "lock" file.
npm は範囲の中から「いま一番新しいもの」を選ぶため、実行する時期によって結果が変わります。lock ファイルが必要なのはこのためです。Go は go.mod に書いた最低バージョンとほかのモジュールの go.mod から、毎回同じ結果を計算できます。だから結果を別ファイルに残す必要がありません。
STEP2 モジュールプロキシから取得する
取得先は GOPROXY で決まります。既定値はこうです。
$ go env GOPROXY
proxy.golang.org,direct
proxy.golang.org が モジュールプロキシ です。Go チームが運営する、公開モジュールのキャッシュサーバーです。, はフォールバックの区切りで、プロキシが 404 / 410 を返したら次の direct(プロキシを使わず Git などから直接取る)に進みます。社内リポジトリのように GOPRIVATE に一致するモジュールも direct で取得されます。
モジュールプロキシは、npm の registry とは配布物の集め方が違います。npm では作者が npm publish で registry にアップロードします。Go のモジュールプロキシは、初めて要求されたバージョンを自分で元リポジトリから取ってきて zip にします。いったんキャッシュされると、作者が元リポジトリでソースを消しても取得できます(proxy.golang.org の FAQ)。
空のキャッシュで go get -x した出力から、chi/v5 の取得に関わる行だけを抜き出すとこうなります(URL 以外の情報は省略しています)。
get proxy.golang.org ← バージョン一覧
get proxy.golang.org ← メタデータ
get proxy.golang.org ← go.mod 単体
get sum.golang.org ← 照合用のハッシュ(STEP5)
go: downloading github.com/go-chi/chi/v5 v5.3.2
get proxy.golang.org ← モジュール zip ファイル
go: added github.com/go-chi/chi/v5 v5.3.2
バージョンを指定しない go get は、@v/list で取った一覧からプレリリースを除き、SemVer で最も大きいバージョンを選びます。
$ curl -s proxy.golang.org | sort -V | tail -3
v5.3.0
v5.3.1
v5.3.2
② の謎はここで解けます。 v5.3.2 は「その時点の最新リリース」として選ばれ、go.mod の require に書かれました。added は「go.mod に書いた」という意味です。いったん書かれたあとは、STEP1 で見た MVS がこの最低バージョンを材料にビルドリストを決めます。
.mod と .zip が別々に取得されている点も覚えておいてください。MVS は依存モジュールの go.mod しか読みません。そのため go コマンドは、ソース全体を落とさずに go.mod だけを先に取得できます。これが ③ の伏線になります。
STEP3 モジュールキャッシュに保存する
取ってきたものは GOMODCACHE の下に、役割の違う 2 つの層に分けて置かれます。
~/go/pkg/mod/
├── cache/download/github.com/go-chi/chi/v5/@v/ ← 層1: 取得したものをそのまま置く
│ ├── list
│ ├── v5.3.2.info ← バージョンと取得元(git のコミット)
│ ├── v5.3.2.mod ← go.mod 単体
│ ├── v5.3.2.zip ← モジュール zip ファイル
│ ├── v5.3.2.ziphash ← zip のハッシュ(STEP5 で使う)
│ └── v5.3.2.lock
│
└── github.com/go-chi/chi/v5@v5.3.2/ ← 層2: zip を展開したソース(ビルドが読む)
├── chi.go
├── mux.go
└── ...
go build や go test が読むのは層2です。層2のディレクトリ名は <モジュールパス>@<バージョン> の形です。展開した時点で、中のディレクトリとファイルは 読み取り専用 になります。
$ ls -ld ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2
dr-xr-xr-x@ 24 ... github.com/go-chi/chi/v5@v5.3.2
go get の出力にあった downloading の 2 行は、どちらもこのキャッシュに置かれています。chi v1.5.5 も github.com/go-chi/chi@v1.5.5/ として同じ場所に残っています。
.info を開くと、プロキシがどこから作った zip なのかが分かります。
{"Version":"v5.3.2","Time":"2026-08-20T09:37:52Z",
"Origin":{"VCS":"git","URL":"go-chi/chi",
"Hash":"38939062c5df4d3e8814aad1a488983112627ced","Ref":"refs/tags/v5.3.2"}}
「git の refs/tags/v5.3.2、コミット 3893906 から作った」と記録されています。疑問 4(タグの付け替え)を考えるときに、この記録が効いてきます。
STEP4 暗号学的ハッシュを計算する
go.sum に並ぶ h1: は、SHA-256 を使うハッシュ方式「Hash1」の識別子です。対象はモジュール zip ファイルですが、zip のバイト列をそのままハッシュしているわけではありません。手順は次の 4 段です。
zip 内の全ファイルについて SHA-256 を計算する
"<SHA-256 の hex> <ファイル名>\n" という行を作る(スペースは 2 個)
ファイル名でソートして全行を連結する(これが「リスト」)
リスト全体の SHA-256 を取り、その 32 バイトを base64 にして h1: を付ける
本当にそうなっているのか、シェルで再現してみます。
$ cd $(mktemp -d)
$ unzip -q ~/go/pkg/mod/cache/download/github.com/go-chi/chi/v5/@v/v5.3.2.zip
$ find github.com -type f | LC_ALL=C sort | # 3. ファイル名でソート
xargs shasum -a 256 | # 1〜3. 「<hex> <ファイル名>」の行を順に出力
shasum -a 256 | cut -d' ' -f1 | # 4. リスト全体の SHA-256
xxd -r -p | base64 # 4. hex を 32 バイトに戻して base64
5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
先頭に h1: を付けると、go.sum の 1 行目と一致しました。shasum の出力形式が「hex + スペース 2 個 + ファイル名」なので、ソートしたファイル名の順に並べるだけでリストになります。LC_ALL=C を付けているのは、Go と同じバイト順でソートするためです。
ファイル名と中身だけから計算するので、zip の圧縮レベルやタイムスタンプ、エントリの順序には左右されません。誰が zip を組み立てても、中身が同じなら同じ値になります。
2 行目の /go.mod 行も同じ Hash1 です。対象は v5.3.2.mod の 1 ファイルだけで、リストに書くファイル名は go.mod です。
$ D=~/go/pkg/mod/cache/download/github.com/go-chi/chi/v5/@v
$ printf '%s go.mod\n' "$(shasum -a 256 $D/v5.3.2.mod | cut -d' ' -f1)" |
shasum -a 256 | cut -d' ' -f1 | xxd -r -p | base64
R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
Go のコードから計算するなら、golang.org/x/mod/sumdb/dirhash の dirhash.HashZip(path, dirhash.Hash1) で同じ値が得られます。
STEP5 go.sum または checksum database と照合する
計算したハッシュを何と比べるかは、go.sum に行があるかどうかで変わります。
go.sum に行がある場合
go.sum の値が正解です。手元で計算したハッシュと一致すれば、checksum database には問い合わせずに検証を終えます。一致しなければ、取得したものを捨ててエラーで止まります。
SECURITY ERROR
This download does NOT match an earlier download recorded in go.sum.
go.sum に行が無い場合
checksum database(既定は sum.golang.org)に正解のハッシュを問い合わせます。STEP2 の -x 出力にあった lookup がこれです。
$ curl sum.golang.org
60295460 # ← 台帳の何番目の記録か
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
go.sum database tree # ← ここから下は署名付きのツリーヘッド
63096908
8lHrQPRYe4KT4WQ9/Qk8/taKnD6IRHbFgfN7vQE2Y54=
— sum.golang.org Az3gri/oVBN1jAhNZ/iS+W31psFAWRT20/GyrhpU6taFwulQGetBkH5S7wpGXdS935jiIGiUUAXBVHzT/P6fYhqAmAk=
STEP4 で計算した値と同じハッシュが返ってきています。checksum database は追記専用の台帳(Merkle tree を使った透明性ログ)で、一度記録したハッシュを後から書き換えられません。
最終行は sum.golang.org の電子署名(Ed25519)です。この署名を確かめる公開鍵は、go コマンドのソースに定数として書かれています。
src/cmd/go/internal/modfetch/key.go
var knownGOSUMDB = map[string]string{
"sum.golang.org": "sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8",
}
定数なので、ビルドすると go の実行ファイルにそのまま入ります。
$ strings $(which go) | grep -o 'sum.golang.org+033de0ae+.{44}'
sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8
途中のプロキシは秘密鍵を持っていないので、応答を書き換えると署名が合わなくなります。検証用の鍵は手元の go コマンドの中にあるので、鍵ごと差し替えることもできません。
2 回目以降
キャッシュ済みのモジュールを使うたびに、go コマンドはメインモジュールの go.sum と照合しています。毎回 zip を再計算すると重いので、STEP3 で保存した .ziphash(計算済みのハッシュ)と go.sum を比べます。
To avoid the need to trust other modules, the go command verifies hashes using the main module's go.sum whenever it accesses a file in the module cache. Zip file hashes are expensive to compute, so the go command checks pre-computed hashes stored alongside zip files instead of re-hashing the files.
— Authenticating modules
"To avoid the need to trust other modules" という一節を覚えておいてください。最後に「一番の違い」を考えるときに使います。
STEP6 go.sum に記録する
検証を通ったハッシュが go.sum に追記されます。行数にはルールがあります(go.sum files)。
ビルドにソースが必要なモジュールは、zip の h1: と /go.mod の h1: の 2 行
モジュールグラフをたどるためだけに使うモジュールは、/go.mod の 1 行
③ の謎の答えです。 2 行あるのは、STEP2 で見たとおり zip と go.mod を別々に取得し、別々に検証しているからです。MVS は go.mod しか読まないので、ソースが要らないモジュールについては go.mod の行だけで足ります。
ここで改めて go.sum を見てください。
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
STEP1 で取得した github.com/go-chi/chi v1.5.5 は、zip の取得・展開も checksum database での検証も済んでいます。それなのに go.sum には 1 行もありません。import path を解決するための「探り」で取っただけで、最終的なモジュールグラフには入っていないからです。
キャッシュには「見に行ったもの全部」が残り、go.sum には「ビルドに関わるものだけ」が載ります。この非対称が、最後の答えにつながります。
go.sum は lock ファイルなのか
6 ステップを見終えたので、途中で保留した問いに戻ります。go.sum を消したらどうなるかを試しました。
$ mv go.sum /tmp/go.sum.bak
$ go build ./...
main.go:8:2: missing go.sum entry for module providing package github.com/go-chi/chi/v5 (imported by github.com/shirokuma/conference-report/GoBash/ex-Go); to add:
go get github.com/shirokuma/conference-report/GoBash/ex-Go
ビルドは止まりました。Go 1.16 以降は -mod=readonly が既定なので、go build が go.sum を勝手に作り直すことはありません。go mod tidy で作り直して、元のファイルと比べます。
$ go mod tidy
$ diff /tmp/go.sum.bak go.sum
$ # 差分なし
1 バイトも変わりませんでした。go.sum が無いとビルドは止まりますが、作り直しても中身は同じです。つまり go.sum はバージョンの決定に関わっていません。バージョンを決めているのは go.mod と MVS です。
ファイル
役割
go.mod
どのモジュールのどのバージョン以上が要るかの 宣言
go.sum
取ってきた中身が本物であることを確かめる 証明 の台帳
go.sum は lock ファイルではなく、検証のための台帳でした。
4 つの疑問の答え合わせ
最初に挙げた 4 つの疑問に、ここまでのステップで答えます。
疑問
答え
根拠
1. なぜ同じディレクトリに置いているのか?
同じ <モジュール>@<バージョン> なら、中身の h1: ハッシュは 1 つに決まる。1 つ置けばどのプロジェクトも同じものを使える
STEP4・5
2. 別のプロジェクトが別のバージョンを使いたくなったら?
ディレクトリ名にバージョンが入るので、別ディレクトリとして共存する。どのバージョンを使うかは各プロジェクトの go.mod が決める
STEP1・3
3. 別のプロジェクトが中身を書き換えたら?
展開済みのソースは読み取り専用なので、そもそも書き込めない
STEP3
4. 作者がタグを別のコミットに付け替えたら?
プロキシにキャッシュ済みなら、.info に記録された元のコミットから作った zip がそのまま配られる。別経路で付け替え後の中身を取っても、go.sum や checksum database に残る最初のハッシュと合わずエラーになる
STEP2・3・5
疑問 3 は実際に試せます。
$ M=~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2
$ echo "// hack" >> $M/mux.go
zsh: permission denied: .../chi/v5@v5.3.2/mux.go
chmod で権限を外して無理やり書き換えた場合は、go mod verify で見つけられます。go mod verify はキャッシュの zip と展開済みディレクトリのハッシュを計算し直し、ダウンロード時に記録した .ziphash と比べるコマンドです。合わなければ dir has been modified などのエラーを出します。
!
ビルドのたびに展開済みディレクトリの全ファイルを計算し直しているわけではありません。STEP5 で見たとおり、普段の照合は計算済みの .ziphash と go.sum の比較です。手でキャッシュを触ってしまったときは go mod verify を実行してください。go mod verify の照合相手は、Go 1.27.0 の cmd/go/internal/modcmd/verify.go で確認しました。
おまけの疑問にも 1 つ答えておきます。途中で checksum database の応答まで偽のハッシュに書き換えられた場合は、STEP5 で見た署名の検証で止まります。
最初の問いに戻る:npm と Go の一番の違い
冒頭の問いに戻ります。npm と Go の一番の違いは何でしょうか。
表にすると、違いはいくつも挙がります。
観点
Go
npm
依存の置き場所
GOMODCACHE(マシンに 1 つ)
プロジェクトごとの node_modules
同一バージョンの重複
無い
プロジェクトの数だけコピーされる
バージョンの宣言
go.mod に最低バージョンを 1 つ
package.json に範囲、lock に結果
依存の書き込み
読み取り専用
書き換えられる
git 管理するもの
go.mod と go.sum
package.json と package-lock.json
登壇の準備を始めたとき、私は一番の違いを「依存の置き場所」だと考えていました。node_modules が無いのは、Go を触ると最初に気付く違いだからです。
ただ、6 ステップを追った今は少し違う答えを出せます。npm も ~/.npm/_cacache に共有キャッシュを持っているので、実体を共有すること自体は Go だけの特徴ではありません。違いは、共有しても安全だと言える仕組みが仕様で決まっている点にあります。
バージョンの中身は変わらない(同じバージョンなら h1: は 1 つに決まり、checksum database がそれを記録し続ける)
使うバージョンは各プロジェクトの go.mod だけで決まる(MVS)
キャッシュを読むたびに、各プロジェクトの go.sum と照合する
STEP5 で引用した一節 "To avoid the need to trust other modules" が言っているのはこのことです。キャッシュには、ほかのプロジェクトが取ってきたものも探りで取っただけのものも入っています。それでも各プロジェクトは自分の go.sum で照合するので、ほかのプロジェクトを信用する必要がありません。
Go が共有しているのは依存の「実体」だけで、「信頼」は共有していません。信頼の単位は、リポジトリごとに git で管理している go.sum です。
これが、私の考える「一番の違い」です。
裏を返すと、go.sum 自体を書き換えられたら、この仕組みでは防げません。go.sum に行があれば checksum database には問い合わせないので、偽のハッシュを書いた go.sum がリポジトリに入れば、go コマンドはそれを正解として扱います。go.sum の差分は、自動生成のファイルだからと読み飛ばさず、コードと同じようにレビューしてください。
調べきれなかったこと
登壇までに調べきれず、宿題として残っているものです。
最後は Google(sum.golang.org の運営者)を信用するしかないのか
Go 1.27.0 で依存管理がどう変わったのか
dep(Gopkg.lock)の時代の依存管理と、なぜ lock ファイルをやめたのか
標準パッケージが go.mod と go.sum に記録されない理由(どうやってインストールし、どうやって検証しているのか)
1 つ目については手がかりだけ見つけています。透明性ログには、運営者が特定の人にだけ違うハッシュを返すと、台帳の枝分かれとして検出できる仕組みがあります。ただ、それで運営者への信用をどこまで減らせているのかは、まだ整理できていません。
分かったら続きの記事を書く予定です。詳しい方がいたら、ぜひコメントで教えてください。
まとめ
go get は「決める → 取得する → キャッシュに置く → ハッシュを計算する → 照合する → 記録する」の 6 ステップで動いている
go.mod は最低バージョンの宣言で、MVS により同じ結果が毎回計算できるので lock ファイルが要らない
go.sum は lock ファイルではなく、取ってきた中身が本物かを確かめる台帳。依存の実体は全プロジェクトで共有し、信頼は各リポジトリの go.sum に置いている
次のコマンドを一度打ってみると、ここで追った 6 ステップがそのまま流れていくのが見えます。
GOMODCACHE=$(mktemp -d) GOFLAGS=-modcacherw go get -x <モジュール>
普段使っているモジュールで試してみてください。
参考
Go Modules Reference
Resolving a package to a module
Minimal version selection (MVS)
Module cache
Authenticating modules
go.sum files
Managing dependencies
proxy.golang.org
About packages and modules | npm Docs
12 likes
Building MarginCMS, part 2: A Go server generated from the spec
In part 1 I wrote the OpenAPI contract for MarginCMS and planned the backend. In this part the Go service comes to life: a server generated from the spec, config from environment variables, stub handlers that fail properly, and a Docker Compose setup that runs the API, the docs and a mock server with one command.
It also includes a CORS bug where my server logged every request and the browser still showed nothing. That one gets its own section.
Why generate the server at all?
The contract is the only thing my wife's frontend and my backend share. If the Go handlers are written by hand, they'll drift from the spec sooner or later: a renamed field, a missing status code, a query parameter parsed as the wrong type. With a generated server, the handlers implement an interface that comes straight from the spec. When the spec changes, I regenerate, and the compiler tells me exactly what's out of date.
Step 1: Choose the server and generate it from the spec
Commit: api: generate Go server and types from the OpenAPI spec
chi
I picked chi as the router:
- I've used it before, so it's one less new thing.
- It's a thin layer on top of net/http. Handlers are plain http.Handlers, and anything from the standard library works with it.
- It has a good community and plenty of middleware and packages, including the CORS package that shows up later in this post.
oapi-codegen
oapi-codegen turns the bundled spec into Go code. I generate four files, each from its own small config:
| Config | Generates |
| --- | --- |
| models.yaml | Request and response types |
| server.yaml | A chi server and a strict server interface |
| client.yaml | A typed Go client, useful for tests later |
| server_urls.yaml | Constants for the server URLs in the spec |
The strict server is the important part. Instead of func(w http.ResponseWriter, r *http.Request), each operation becomes a method with typed input and typed output:
func (s server) GetHealth(ctx context.Context, request api.GetHealthRequestObject) (api.GetHealthResponseObject, error)
The generated code parses the path, query, headers and body, calls my method, and writes the response. My code never touches http.Request. The return type is an interface that the generated per-status response types implement, so returning a response the spec doesn't define takes deliberate effort instead of happening by accident.
Empty string or no field at all?
A few years ago I worked with code generated from proto3 messages with plain string fields. Without the optional keyword (generally available only since protobuf 3.15) or wrapper types like StringValue, proto3 can't tell the difference between a client sending "title": "" and not sending title at all. Both arrived as an empty string. For a PATCH endpoint that's a real problem: "clear the excerpt" and "don't touch the excerpt" look the same.
oapi-codegen handles this the Go way. Optional fields become pointers:
type PostUpdate struct {
Body *string `json:"body,omitempty"`
Excerpt *string `json:"excerpt,omitempty"`
Slug *string `json:"slug,omitempty"`
Status *PostStatus `json:"status,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Title *string `json:"title,omitempty"`
}
nil means "not sent", and a pointer to "" means "set it to empty" (for excerpt and body; title has minLength: 1). That's exactly what the PATCH semantics need.
One caveat: an explicit "excerpt": null also decodes to nil, so "not sent" and "sent as null" look the same. That's fine here because none of these fields are nullable. If I ever need the difference, oapi-codegen's nullable-type output option generates nullable.Nullable[T] for it.
Tools pinned with go tool
My first version installed the generator with go install ...@latest. That works on my machine today, but it means anyone cloning the repo next year gets a different generator than the one that produced the committed code.
Since Go 1.24, go.mod can track tools the same way it tracks dependencies:
tool (
github.com/air-verse/air
github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
)
Now go tool oapi-codegen always runs the exact version in go.mod, and the root Makefile uses it:
openapi-generate-go:
cd api && go tool oapi-codegen --config=../openapi/oapi-codegen/server.yaml ../openapi/dist/openapi.bundled.yaml
The trade-off is that the tools' dependencies show up in go.mod as indirect requirements. They don't end up in the API binary, but the file gets longer. I'm fine with that in exchange for one source of truth for versions.
Step 2: Config, stubs and a health check
Commit: api: add server skeleton with config, stub handlers and health check
Config from the environment
Config comes from environment variables, mapped onto structs with go-envconfig. Each package owns its own config struct, and the top-level config just composes them:
// internal/server/config.go
type Config struct {
Addr string `env:"SERVER_ADDR, default=:8080"`
ShutdownTimeout time.Duration `env:"SERVER_SHUTDOWN_TIMEOUT, default=30s"`
ReadHeaderTimeout time.Duration `env:"SERVER_READ_HEADER_TIMEOUT, default=5s"`
// ...
}
// internal/config/config.go
type Config struct {
Server *server.Config
}
When the database and auth packages arrive, they'll add their own Config next to their code instead of one giant struct growing in a single file.
There's also a test that loads the config with no SERVER_* variables set and compares it with the expected defaults. It sounds trivial, but it's already caught me twice: once when I added the CORS and timeout fields, and once when I lowered a timeout default. Defaults are part of the behaviour, so they deserve a test. It also makes me look at .env.example every time a default changes, so the code and the docs don't drift apart.
main.go
main.go stays small: load the config, build the generated handler, wrap it, and run an http.Server with timeouts and graceful shutdown:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cfg := config.New(ctx)
serverInterface := api.NewStrictHandlerWithOptions(server.New(), server.NewServerMiddlewares(), server.NewServerOptions())
handler := api.HandlerWithOptions(serverInterface, api.ChiServerOptions{BaseURL: "/api/v1"})
On SIGINT or SIGTERM the server stops accepting new connections and gives in-flight requests up to SERVER_SHUTDOWN_TIMEOUT to finish.
501 for everything that isn't built yet
The generated interface has 17 methods, and I've implemented one. The stubs for the rest started out as panic("implement me"). Go's HTTP server recovers from the panic, but the client gets a dropped connection and my log gets a stack trace. Not great when the docs page has a "Try it" button.
501 Not Implemented is the honest answer. The catch is that the strict server only lets a method return the responses the spec defines, and the spec has no 501. It doesn't need one, though: any error a method returns goes to a configurable ResponseErrorHandlerFunc. So every stub returns a sentinel error:
var ErrNotImplemented = errors.New("not implemented")
func (s server) ListPosts(ctx context.Context, request api.ListPostsRequestObject) (api.ListPostsResponseObject, error) {
return nil, ErrNotImplemented
}
and the error handler maps it to a status code and writes a problem+json body using the Problem type generated from the spec:
func responseErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
status := http.StatusInternalServerError
if errors.Is(err, ErrNotImplemented) {
status = http.StatusNotImplemented
} else {
log.Printf("%s %s %s", r.Method, r.URL.Path, err)
}
writeProblem(w, status)
}
A nice side effect: real errors returned from handlers now also come back as a clean 500 in the same format, and the internal error message is logged instead of being sent to the client. Request-decoding errors still get oapi-codegen's default plain-text 400 for now.
$ curl http://localhost:8080/api/v1/posts
{"status":501,"title":"Not Implemented","type":"about:blank"}
Health check
GET /api/v1/healthz returns {"status":"OK"}. That's a stub for now. Once there's a database, it'll check that the app's dependencies are reachable and healthy, so Docker and any future deployment can tell a running process from a working one.
Step 3: One command for the whole dev environment
Commit: tooling: add Docker Compose dev environment
docker compose up starts three services:
| Service | Port | What it is |
| --- | --- | --- |
| api | 8080 | The Go API, rebuilt on every save by Air |
| apidocs | 8081 | redocly preview: the API docs with a "Try it" console |
| apimock | 8082 | Prism, serving mock responses from the spec |
The api container mounts the source code and runs go tool air, so Air is pinned in go.mod like the generator. Two named volumes keep the Go module cache and the build cache (which holds the compiled Air binary) between container restarts. Without them, every recreated container downloaded all modules and compiled Air from scratch before serving a single request.
Do I still need Prism?
While setting this up, I noticed that the Redocly preview also has a built-in mock server behind its "Try it" console. So is Prism redundant?
Not for us. The Redocly mock is great for trying endpoints from the docs page. Prism is a standalone server on a fixed port that the React app can use as its backend while my endpoints are still returning 501. It also validates requests against the spec and supports the Prefer header, so the frontend can ask for a specific response, like Prefer: code=412 for the edit-conflict case. That's what the frontend needs, so Prism stays.
Step 4: The request that arrived and never came back
Commit: api: add CORS middleware
With everything running, I opened the docs on localhost:8081, pointed the "Try it" console at my real API on localhost:8080, and called the health check. The API logged the request. The docs page showed no response at all.
Part one: CORS
The server did its job. The browser threw the response away.
A different port is a different origin, so a page on :8081 calling :8080 makes a cross-origin request. A plain GET without custom headers is a "simple" request, so the browser sends it straight away without a preflight. My handler runs and returns 200. But the response has no Access-Control-Allow-Origin header, so the browser refuses to let the page read it. The devtools console says so, if you think to look there.
The fix is the go-chi/cors middleware. One detail matters: it has to run before chi's routing, so I wrap the whole handler at the net/http level instead of adding it to the strict server's middleware list. A preflight OPTIONS request doesn't match any generated route (chi answers 405 or 404 itself), so per-operation middleware never sees it.
handler = cors.Handler(cors.Options{
AllowedOrigins: cfg.Server.AllowedOrigins,
AllowedHeaders: cfg.Server.AllowedHeaders,
AllowedMethods: cfg.Server.AllowedMethods,
AllowCredentials: cfg.Server.AllowCredentials,
})(handler)
Part two: * means "all methods" in the spec, but not in go-chi/cors
I added CORS, set everything to * in my local env to get going, and still got nothing.
Calling the API with curl and an Origin header showed the middleware was running, since the response had Vary: Origin, but there was still no Access-Control-Allow-Origin.
That surprised me, because * is a valid value for the Access-Control-Allow-Methods header: for requests without credentials, it means "all methods". The protocol was fine; the library was the problem. Reading the go-chi/cors source explained it. In its options, * is a wildcard for AllowedOrigins and AllowedHeaders, but not for AllowedMethods. There, * is compared literally against the request method, as if it were a method called *.
On top of that, go-chi/cors checks the method on the actual request too, not only on the preflight, which the CORS spec doesn't ask for. My GET was a simple request with no preflight, and GET wasn't in my list, so it got no CORS headers at all. A preflighted request wouldn't have fared better: the preflight checks the requested method against the same list, and on a mismatch it still answers 200 OK, just without any CORS headers.
Even a library that passed * through wouldn't have saved this config, because I also had AllowCredentials: true. For requests with credentials (cookies, TLS client certificates or HTTP authentication), browsers treat * in Access-Control-Allow-Methods and Access-Control-Allow-Headers as a literal name. That setting had a problem of its own, which is part three.
Listing the methods explicitly fixed it:
SERVER_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"
Part three: don't ship the permissive version
While I was there, I noticed a config that only worked by accident. AllowedOrigins: * with AllowCredentials: true makes go-chi/cors send Access-Control-Allow-Origin: * next to Access-Control-Allow-Credentials: true, a combination browsers reject for credentialed requests. MarginCMS uses bearer tokens and no cookies, so it doesn't need credentials at all, and there's no reason to let every origin in either. The defaults are now strict:
AllowedOrigins []string `env:"SERVER_ALLOWED_ORIGINS, default=http://localhost:8081"`
AllowedMethods []string `env:"SERVER_ALLOWED_METHODS, default=GET,POST,PUT,PATCH,DELETE,OPTIONS"`
AllowedHeaders []string `env:"SERVER_ALLOWED_HEADERS, default=Authorization,Content-Type"`
AllowCredentials bool `env:"SERVER_ALLOW_CREDENTIALS, default=false"`
If it works locally with *, that's a sign to tighten it, not a sign that you're done.
What I'd tell myself before starting
- When the server says 200 and the client sees nothing, the browser is the one dropping the response. Start debugging there.
- Read the source of small middleware packages. go-chi/cors is a few hundred lines, and five minutes of reading beat an hour of guessing.
- Pin your code generators. Generated code is only reproducible if the generator version is.
- Test your defaults. It's the cheapest test you'll write and it keeps config and docs honest.
Next steps
1. Add a linter. golangci-lint with a config that's strict from day one, while there's still almost no code to fix.
2. Start on auth with mock data. Login, token refresh, logout and GET /me, backed by an in-memory user for now, so the auth flow and its tests exist before the database does.
In part 3 the linter came first and turned into a dependency hygiene problem: every Go tool moved into its own module, then came a strict golangci-lint config and request validation against the spec. Auth moved to part 4.
---
Originally published on my blog, where the comments live. The code for this post is on GitHub.
Building MarginCMS, part 1: Contract first, code later
I'm building a small CMS in Go. It's called MarginCMS, it has a React admin on top, and it won't replace anything I already use. This series is the build log: what I decided, what I wrote, and what tripped me up along the way.
This first part covers why I'm doing it, and the work that happened before writing any Go code: the API contract, the tooling around it, and the plan for the backend.
The code is public at davidporos92/margin-cms.
Why build a CMS in 2026?
Nobody needs another CMS, including me. I'm building one anyway, for four reasons.
It's fun. Writing a CMS has been on my someday list for years, and a pet project with no deadline and no users is the best place to finally do it.
Deliberate practice. I use AI tools every day, and they're good. But I noticed I reach for them before I've thought a problem through. I want one project where I make every decision and write the code myself, with AI as a reviewer and a rubber duck rather than the author.
It starts easy and gets hard. A CMS starts as CRUD over a posts table, which is a nice way to ease back in. Then the real problems show up: authentication and token refresh, revision history, two people editing the same post at the same time, search, pagination. Each of these is small enough to finish and deep enough to learn something from.
There's a lot of room to grow. Once the core works, there's room for tools, plugins and packages around it: an export pipeline to my existing blog, a CLI, maybe a client library. None of that is planned yet, but the option is there.
My wife is building the React frontend, so this is a two-person project with a clean split: I own the Go API, she owns the web app and the design system. The only thing we share is the API contract (and a small little doggo), and that shapes almost every decision below.
The scope, briefly
To keep it finishable, v1 is deliberately boring:
- one admin user, one content type (posts), no media uploads, no plugin system
- posts are markdown with a title, slug, excerpt, tags and a status (draft, published, archived)
- every content change creates a revision, and an activity log records who did what
- one Go binary with small internal packages (auth, posts, revisions, activity, stats)
"Modular" here means compile-time packages with small interfaces, not a dynamic content-type builder.
Step 1: Write the OpenAPI contract first
Commit: Add OpenAPI contract and spec tooling
Before writing a single handler, I wrote the API as an OpenAPI 3 spec. That had two goals.
Don't block the frontend. If the frontend has to wait for my endpoints, it'll wait a long time. With a spec in place, the frontend can generate its TypeScript types and develop against a mock server from day one, and switch the base URL once the real endpoints land.
Get a clear picture of what I'm building. Writing the contract forced decisions I would otherwise have made halfway through a handler:
- Auth: a short-lived access token in Authorization: Bearer, plus a single-use refresh token. No cookies, so classic CSRF doesn't apply; XSS is the thing to guard against instead.
- Errors: every error is application/problem+json (RFC 9457), with field-level details for form validation.
- Optimistic concurrency: posts carry an ETag, and updates, deletes and revision restores require If-Match. A stale write gets 412 Precondition Failed with the current version of the post in the response, so the UI can show a proper conflict banner instead of silently overwriting someone's work.
- Pagination: cursor-based, not offset-based.
The result is 17 operations across auth, posts (including bulk actions and markdown export), revisions, tags, activity, stats and a health check.
I didn't want one 2,000-line YAML file, so the spec is split into small files: one per path and one per component (schemas, parameters, request bodies, responses), all referenced from a root openapi.yaml. The convention is simple: file name equals component name. components/schemas/Post.yaml defines Post. A bundler merges everything into a single file for the tools that need one.
Step 2: Set up tooling around the spec
A spec is only useful if the tooling around it is easy to use, so the same commit adds npm scripts and make targets for:
| Need | Tool |
| --- | --- |
| Lint the spec | Redocly CLI (redocly lint) |
| Bundle the split files into one | Redocly CLI (redocly bundle) |
| Browse the docs | Redocly |
| Mock server | Prism |
| TypeScript types | openapi-typescript |
| Go types and server | oapi-codegen (wired up in part 2) |
Prism deserves a special mention. Besides returning example responses, it supports the Prefer header. The frontend can send Prefer: code=412 and get a 412 back, which makes it possible to build the conflict handling long before the backend can produce a real conflict.
Step 3: Plan the Go backend
No commit for this one. It's the thinking that happened before the code.
I picked tools in three groups.
The HTTP server. I went with chi. I've used it before, it stays close to net/http, and oapi-codegen can generate a chi server directly from the spec. More on that in part 2.
Data access. The classic Go debate is ORM versus plain SQL. Since part of the point is to learn new things while I ease back into Go, I chose tools I haven't used in anger yet:
- PostgreSQL with pgx
- Ent for the schema and data access, with the schema written as Go code
- Atlas to generate versioned SQL migrations from the Ent schema. The API never changes the database schema on its own.
- testcontainers-go for integration tests against a real Postgres, using the same migration files that ship
Code generation from OpenAPI. Handlers implement an interface generated from the spec, so the code can't drift from the contract without the compiler noticing.
Step 4: Plan the local dev setup
Also mostly planning at this stage. The goal was a dev loop where everything runs with one command:
- Go hot reload with Air, so saving a file rebuilds and restarts the API
- Prism as the mock backend for the web app, for the Prefer header support mentioned above
- Docker Compose so we both get the same environment, including Postgres later
Next steps
In part 2 I set up the Go service and the local environment: generating the server from the spec, config handling, stub handlers, the Docker Compose setup, and a CORS bug that turned out to be a library quirk, not the protocol.
---
Originally published on my blog, where the comments live. The code for this post is on GitHub.
1 reaction · 2 comments
Mentions
My fix checker found two missing tests. Then it wrote them. --- title: My fix checker found two missing tests. Then it wrote them. tags: testing, ai, opensource, python published: true --- In my last post, Bite checked some well-known projects and found a few small gaps: changes that no test would notice if they broke. Two of them: - In Rich, Markdown <kbd>Ctrl</kbd> is drawn in bold yellow, and no test checks it. - In chi, a Go web router, a change made XML responses get compressed by default. The commit did add a test, but that test counts the list against itself, so it still passes with the change taken out. Finding a gap is the easy half. Someone still has to write the test. So this week Bite got a new option, --write-tests, that tries to write it. How it works For each change no test noticed, a fresh AI worker writes one small test on a copy of the project. Then Bite doesn't take its word for it. It runs the test itself: 1. With the change in, the test must pass, twice. 2. With the change taken out, the test must fail, and fail for a real reason (not just a crash on loading). If either part goes wrong, Bite throws the test away and says so. It uses your own Anthropic API key, and --budget caps what a run spends. What happened | Gap | Bite's test | With the change | Without it | Cost | |---|---|---|---|---| | Rich <kbd> | prints <kbd>Ctrl</kbd>, checks Ctrl is bold yellow | passes, twice | fails: Ctrl not bold yellow | $0.14 | | chi XML | sends XML through chi's compressor, checks it comes back gzipped | passes, twice | fails: XML not gzipped | $0.10 | Total: $0.34. That's more than the two rows add up to, because of the next part. The first chi try got thrown away, and that was Bite's fault. Go runs tests by folder, not by file, and Bite asked Go to run one file alongside the whole project. Go refused before running anything. Bite's rule caught it: a test that fails before it even runs doesn't count. So it threw the test out instead of showing a fake result. The fix is in PR #67, and the second try worked. Did it write the right test? Before Bite could do this, Claude had already written a test for each gap the slow way, to confirm the gaps were real. So there was an answer key. - Rich: Bite's test checks the same thing as the answer key: Ctrl comes out bold bright yellow. - chi: the answer key (chi #1246, still open) adds XML to chi's existing list test. Bite's test goes further: it sends a real XML response through the compressor and checks it gets zipped. Different route, same claim. Here's the Rich one, exactly as Bite wrote it: # Guards that <kbd>…</kbd> in Markdown renders its text in the markdown.kbd style. from rich.console import Console from rich.markdown import Markdown def test_kbd_inline_html_is_styled(): console = Console( file=__import__("io").StringIO(), force_terminal=True, color_system="truecolor", width=40, ) console.print(Markdown("Press <kbd>Ctrl</kbd> now")) output = console.file.getvalue() assert "\x1b[1;93mCtrl\x1b[0m" in output Then we tried the whole loop On my own copy of Rich, Bite ran as a GitHub Action on a pull request. It found the <kbd> gap, wrote the test, and opened a small pull request with the test in it. I merged that pull request, and Bite's check on the original pull request turned green. Gap found, test written, test merged, without anyone typing a test. What I took from it - Don't trust a test you didn't watch fail. The pass-and-fail check is the whole point. An AI can write a test that looks right and checks nothing, just like the chi commit's own test did. - Throwing things away is a feature. The chi test that never ran would have looked like a catch. Bite saying "no" there is what makes its "yes" worth something. - Two gaps is a small trial. It worked twice, on two languages. That's a start, not proof it always works. It's in v0.7.0 (pipx upgrade bite-check). On your own computer: bite --write-tests. In GitHub Actions, the setup is in Bite's README. Since this trial, Bite also asks a second fresh check whether each test is fair before showing it. --- Disclosure: I'm not a coder. Claude ran these trials, wrote Bite's code and helped me draft this post. The numbers come from the run logs.
@aptabeenhere Use plain net/http or chi😋 go-chi/chi
🚨 New MEDIUM CVE detected in AWS Lambda 🚨 CVE-2026-114437 impacts github.com/go-chi/chi/v5 in 27 Lambda base images. Details: aws-lambda-base-images#767 More: lambdawatchdog.com #AWS #Lambda #CVE #CloudSecurity #Serverless
npm と比べて分かった Go の依存管理の「一番の違い」── go get の中身を 6 ステップで追う 2026 年 9 月 30 日の Go Bash vol.3 で「npm との違いを手がかりに Go の依存管理を理解したい」というタイトルで登壇しました。この記事はその内容を、登壇では時間の都合で省いた実測ログも足して書き直したものです。 この記事で扱うこと 普段 Go を書いていると、go get を打てば依存が入り、go.sum も勝手に増えていきます。それで困ったこともありません。ただ、次の質問に即答できるでしょうか。 go.sum は package-lock.json と同じものですか? go get したモジュールの実体は、どこに置かれていますか? そのディレクトリを、別のプロジェクトが書き換えたらどうなりますか? 私は答えられませんでした。この記事では、go get が内部でやっていることを 6 つのステップに分けて追いかけ、上の問いに答えていきます。 確認した環境は次のとおりです。 対象 バージョン Go 1.26.8 依存に使うモジュール github.com/go-chi/chi/v5 v5.3.2 比較対象の Node.js / npm Node.js v26 系 / express 5.2.1 最初に、登壇の冒頭で会場に投げかけた問いを紹介します。 npm と Go の一番の違いは何だと思いますか? 答えは記事の最後で出します。少し考えてから読み進めてみてください。 同じサーバーを Go と npm で書いて比べる 「Hello」を返すだけの HTTP サーバーを、Go(chi)と Node.js(express)で書きました。やっていることは同じです。 main.go package main import ( "fmt" "log" "net/http" "github.com/go-chi/chi/v5" ) func main() { r := chi.NewRouter() r.Get("/", func(w http.ResponseWriter, req *http.Request) { fmt.Fprintln(w, "Hello, Go server!") }) log.Fatal(http.ListenAndServe(":8080", r)) } index.js const express = require("express"); const app = express(); app.get("/", (req, res) => { res.send("Hello, Node server!\n"); }); app.listen(8080); 依存の実体がどこにあるかを調べます。npm の場合は、プロジェクト直下の node_modules にあります。 $ npm ls express --parseable /path/to/ex-npm/node_modules/express $ ls -1 node_modules | wc -l 65 $ find node_modules -type f | wc -l 601 express を 1 つ指定しただけで、node_modules には 65 ディレクトリ・601 ファイル(3.8MB)が展開されていました。 一方、Go のプロジェクトを ls すると main.go・go.mod・go.sum の 3 つしかありません。chi を import してビルドできているのに、chi のソースはプロジェクトの中に無いのです。どこにあるのかを go コマンドに聞きます。 ホームディレクトリは ~ に置き換えています $ go env GOMODCACHE ~/go/pkg/mod $ go list -m -f '{{.Dir}}' github.com/go-chi/chi/v5 ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 実体は、ホームディレクトリの下の モジュールキャッシュ(GOMODCACHE、既定は ~/go/pkg/mod)にありました。このキャッシュはマシンに 1 つで、どのプロジェクトからも同じ場所が読まれます。 Go プロジェクトA ─┐ ├─→ ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 プロジェクトB ─┘ マシンに 1 つ npm プロジェクトA/node_modules/express ← プロジェクトごとに別のコピー プロジェクトB/node_modules/express 共有して、困らないのか? npm に慣れていると、ここで不安になります。1 つの実体を全プロジェクトで共有して、本当に大丈夫なのでしょうか。私が感じた疑問は 4 つです。 なぜ同じディレクトリに置いているのか? 別のプロジェクトが別のバージョン(たとえば v5.2.0)を使いたくなったら? 別のプロジェクトがキャッシュの中身を書き換えたら? モジュールの作者が v5.3.2 のタグを別のコミットに付け替えたら? この 4 つの答えは、どれも go get の処理の中にあります。順番に追っていく前に、用語を 2 つだけ揃えておきます。 Go の「モジュール」は npm の「パッケージ」にあたる 同じ「パッケージ」という言葉でも、Go と npm では指すものが逆です。 Go npm バージョンを付けて配布・取得される単位 module(go.mod があるもの) package(package.json があるもの) コードを読み込む単位 package(同じディレクトリにあり、まとめてコンパイルされる .go ファイル群) module(require / import で読み込めるファイルやディレクトリ) この記事で npm の package と比べるのは、Go の モジュール のほうです。 package.json は範囲、go.mod は最低バージョン バージョンの書き方も違います。 package.json { "dependencies": { "express": "^5.2.1" } } go.mod module github.com/shirokuma/conference-report/GoBash/ex-Go go 1.26.8 require github.com/go-chi/chi/v5 v5.3.2 ^5.2.1 は「5.2.1 以上 6.0.0 未満」という 範囲 です。範囲のうちどのバージョンが入るかはインストールした時点の最新版で変わるので、npm は選んだ結果を package-lock.json に記録します。範囲の宣言と結果の記録、2 つのファイルで 1 組です。 go.mod の require には範囲を書きません。公式リファレンスでは、require は依存モジュールの 最低バージョン を宣言するものだと定義されています。 A require directive declares a minimum required version of a given module dependency. ここで次の疑問が湧きます。範囲を書かないなら、選んだ結果を記録する必要もなさそうです。では、go.sum は何を記録しているのでしょうか。 冒頭の「go.sum は lock ファイルか」という問いも、ここにつながります。 go get の出力に残る 3 つの謎 モジュールキャッシュが空の状態で go get を 1 回打つと、出力はこれだけです。 $ go get github.com/go-chi/chi/v5 go: downloading github.com/go-chi/chi/v5 v5.3.2 go: downloading github.com/go-chi/chi v1.5.5 # ① 頼んだのは 1 つなのに 2 つ取得している go: added github.com/go-chi/chi/v5 v5.3.2 # ② v5.3.2 はどう決まった? $ cat go.sum github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= ③ なぜ 2 行? h1: とは何? 短い出力ですが、1 行ずつ読むと分からないところが 3 つあります。特に ① は、/v5 を頼んだのに v1 系の chi v1.5.5 まで落としてきているので不思議です。 go get は内部で、だいたい次の 6 ステップを踏んでいます(Go Modules Reference)。 必要なモジュールとバージョンを決める モジュールプロキシ(または Git などの VCS)からモジュールを取得する モジュールキャッシュに保存する 暗号学的ハッシュを計算する go.sum または checksum database(sum.golang.org)と照合する go.sum に記録する このステップを 1 つずつ追うと、①〜③ の謎と、さきほどの 4 つの疑問が順に解けていきます。 ! go: downloading の行はキャッシュが空のときしか出ません。手元のキャッシュを汚さずに再現するには、使い捨てのディレクトリを GOMODCACHE に指定します。キャッシュは読み取り専用で作られるため、後で消せるように -modcacherw も付けておきます。 GOMODCACHE=$(mktemp -d) GOFLAGS=-modcacherw go get -x github.com/go-chi/chi/v5 STEP1 必要なモジュールとバージョンを決める import path のどこまでがモジュールなのか import "github.com/go-chi/chi/v5" と書いても、go コマンドには「どこまでがモジュールパスで、どこからがモジュール内のディレクトリか」が分かりません。 github.com/go-chi/chi/v5 ← この名前のモジュール? github.com/go-chi/chi ← このモジュールの中の v5/ ディレクトリ? そこで go コマンドは 2 段階で探します(Resolving a package to a module)。 まず手元の ビルドリスト(ビルドに使うモジュールとバージョンの一覧。go list -m all で見られます)から、モジュールパスが import path の接頭辞になっているものを探します。go.mod に require があればここで決まり、ネットワークは使いません。 $ GOPROXY=off go list -f '{{.ImportPath}} → {{.Module.Path}} {{.Module.Version}}' \ github.com/go-chi/chi/v5 github.com/go-chi/chi/v5/middleware github.com/go-chi/chi/v5 → github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/chi/v5/middleware → github.com/go-chi/chi/v5 v5.3.2 初回の go get ではビルドリストに chi が無いので、次の段階に進みます。import path の末尾から 1 要素ずつ削ったパスをすべてモジュールパスの候補にして、モジュールプロキシに「最新バージョンは?」と並列に問い合わせるのです。-x を付けると、その様子が見えます。 $ go get -x github.com/go-chi/chi/v5 2>&1 | grep -E '/@v/list: |go: downloading' get proxy.golang.org: 200 OK get proxy.golang.org: 200 OK get proxy.golang.org: 404 Not Found get proxy.golang.org: 404 Not Found go: downloading github.com/go-chi/chi/v5 v5.3.2 go: downloading github.com/go-chi/chi v1.5.5 200 が返ったのは chi/v5 と chi の 2 つです。公式リファレンスによると、go コマンドは応答があったモジュールの最新バージョンを取得し、要求されたパッケージを含むかどうかを確かめます。含むモジュールが複数あれば、パスが最も長いものを使います。 ① の謎はこれで解けます。 chi v1.5.5 は、「chi モジュールの中に v5/ ディレクトリがあるのでは?」という候補を確かめるために取ってきたものでした。 MVS でバージョンを決める モジュールが決まったら、次はバージョンです。Go は MVS(Minimal Version Selection) という方法で決めます(Minimal version selection (MVS))。 MVS はメインモジュールから各モジュールの go.mod の require をたどり、モジュールごとに「要求された中で最も高いバージョン」を記録します。たどり終えた時点の記録がビルドリストです。 メインモジュール ─require→ 依存 A ── chi v5.0.0 以上が必要 └require→ 依存 B ── chi v5.2.0 以上が必要 ↓ MVS ビルドリスト: chi v5.2.0 「最も高い」なのに「Minimal」と呼ぶのは、これが すべての要求を満たす最小のバージョン だからです。上の例で v5.3.2 が公開されていても、誰も要求していなければ選びません。 この決め方の材料は、各モジュールの go.mod だけです。新しいバージョンが公開されても結果は変わりません。公式リファレンスはこう書いています。 MVS is deterministic, and the build list doesn't change when new versions of dependencies are released. (中略) Unlike other dependency management systems, the build list is not saved in a "lock" file. npm は範囲の中から「いま一番新しいもの」を選ぶため、実行する時期によって結果が変わります。lock ファイルが必要なのはこのためです。Go は go.mod に書いた最低バージョンとほかのモジュールの go.mod から、毎回同じ結果を計算できます。だから結果を別ファイルに残す必要がありません。 STEP2 モジュールプロキシから取得する 取得先は GOPROXY で決まります。既定値はこうです。 $ go env GOPROXY proxy.golang.org,direct proxy.golang.org が モジュールプロキシ です。Go チームが運営する、公開モジュールのキャッシュサーバーです。, はフォールバックの区切りで、プロキシが 404 / 410 を返したら次の direct(プロキシを使わず Git などから直接取る)に進みます。社内リポジトリのように GOPRIVATE に一致するモジュールも direct で取得されます。 モジュールプロキシは、npm の registry とは配布物の集め方が違います。npm では作者が npm publish で registry にアップロードします。Go のモジュールプロキシは、初めて要求されたバージョンを自分で元リポジトリから取ってきて zip にします。いったんキャッシュされると、作者が元リポジトリでソースを消しても取得できます(proxy.golang.org の FAQ)。 空のキャッシュで go get -x した出力から、chi/v5 の取得に関わる行だけを抜き出すとこうなります(URL 以外の情報は省略しています)。 get proxy.golang.org ← バージョン一覧 get proxy.golang.org ← メタデータ get proxy.golang.org ← go.mod 単体 get sum.golang.org ← 照合用のハッシュ(STEP5) go: downloading github.com/go-chi/chi/v5 v5.3.2 get proxy.golang.org ← モジュール zip ファイル go: added github.com/go-chi/chi/v5 v5.3.2 バージョンを指定しない go get は、@v/list で取った一覧からプレリリースを除き、SemVer で最も大きいバージョンを選びます。 $ curl -s proxy.golang.org | sort -V | tail -3 v5.3.0 v5.3.1 v5.3.2 ② の謎はここで解けます。 v5.3.2 は「その時点の最新リリース」として選ばれ、go.mod の require に書かれました。added は「go.mod に書いた」という意味です。いったん書かれたあとは、STEP1 で見た MVS がこの最低バージョンを材料にビルドリストを決めます。 .mod と .zip が別々に取得されている点も覚えておいてください。MVS は依存モジュールの go.mod しか読みません。そのため go コマンドは、ソース全体を落とさずに go.mod だけを先に取得できます。これが ③ の伏線になります。 STEP3 モジュールキャッシュに保存する 取ってきたものは GOMODCACHE の下に、役割の違う 2 つの層に分けて置かれます。 ~/go/pkg/mod/ ├── cache/download/github.com/go-chi/chi/v5/@v/ ← 層1: 取得したものをそのまま置く │ ├── list │ ├── v5.3.2.info ← バージョンと取得元(git のコミット) │ ├── v5.3.2.mod ← go.mod 単体 │ ├── v5.3.2.zip ← モジュール zip ファイル │ ├── v5.3.2.ziphash ← zip のハッシュ(STEP5 で使う) │ └── v5.3.2.lock │ └── github.com/go-chi/chi/v5@v5.3.2/ ← 層2: zip を展開したソース(ビルドが読む) ├── chi.go ├── mux.go └── ... go build や go test が読むのは層2です。層2のディレクトリ名は <モジュールパス>@<バージョン> の形です。展開した時点で、中のディレクトリとファイルは 読み取り専用 になります。 $ ls -ld ~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 dr-xr-xr-x@ 24 ... github.com/go-chi/chi/v5@v5.3.2 go get の出力にあった downloading の 2 行は、どちらもこのキャッシュに置かれています。chi v1.5.5 も github.com/go-chi/chi@v1.5.5/ として同じ場所に残っています。 .info を開くと、プロキシがどこから作った zip なのかが分かります。 {"Version":"v5.3.2","Time":"2026-08-20T09:37:52Z", "Origin":{"VCS":"git","URL":"go-chi/chi", "Hash":"38939062c5df4d3e8814aad1a488983112627ced","Ref":"refs/tags/v5.3.2"}} 「git の refs/tags/v5.3.2、コミット 3893906 から作った」と記録されています。疑問 4(タグの付け替え)を考えるときに、この記録が効いてきます。 STEP4 暗号学的ハッシュを計算する go.sum に並ぶ h1: は、SHA-256 を使うハッシュ方式「Hash1」の識別子です。対象はモジュール zip ファイルですが、zip のバイト列をそのままハッシュしているわけではありません。手順は次の 4 段です。 zip 内の全ファイルについて SHA-256 を計算する "<SHA-256 の hex> <ファイル名>\n" という行を作る(スペースは 2 個) ファイル名でソートして全行を連結する(これが「リスト」) リスト全体の SHA-256 を取り、その 32 バイトを base64 にして h1: を付ける 本当にそうなっているのか、シェルで再現してみます。 $ cd $(mktemp -d) $ unzip -q ~/go/pkg/mod/cache/download/github.com/go-chi/chi/v5/@v/v5.3.2.zip $ find github.com -type f | LC_ALL=C sort | # 3. ファイル名でソート xargs shasum -a 256 | # 1〜3. 「<hex> <ファイル名>」の行を順に出力 shasum -a 256 | cut -d' ' -f1 | # 4. リスト全体の SHA-256 xxd -r -p | base64 # 4. hex を 32 バイトに戻して base64 5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= 先頭に h1: を付けると、go.sum の 1 行目と一致しました。shasum の出力形式が「hex + スペース 2 個 + ファイル名」なので、ソートしたファイル名の順に並べるだけでリストになります。LC_ALL=C を付けているのは、Go と同じバイト順でソートするためです。 ファイル名と中身だけから計算するので、zip の圧縮レベルやタイムスタンプ、エントリの順序には左右されません。誰が zip を組み立てても、中身が同じなら同じ値になります。 2 行目の /go.mod 行も同じ Hash1 です。対象は v5.3.2.mod の 1 ファイルだけで、リストに書くファイル名は go.mod です。 $ D=~/go/pkg/mod/cache/download/github.com/go-chi/chi/v5/@v $ printf '%s go.mod\n' "$(shasum -a 256 $D/v5.3.2.mod | cut -d' ' -f1)" | shasum -a 256 | cut -d' ' -f1 | xxd -r -p | base64 R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= Go のコードから計算するなら、golang.org/x/mod/sumdb/dirhash の dirhash.HashZip(path, dirhash.Hash1) で同じ値が得られます。 STEP5 go.sum または checksum database と照合する 計算したハッシュを何と比べるかは、go.sum に行があるかどうかで変わります。 go.sum に行がある場合 go.sum の値が正解です。手元で計算したハッシュと一致すれば、checksum database には問い合わせずに検証を終えます。一致しなければ、取得したものを捨ててエラーで止まります。 SECURITY ERROR This download does NOT match an earlier download recorded in go.sum. go.sum に行が無い場合 checksum database(既定は sum.golang.org)に正解のハッシュを問い合わせます。STEP2 の -x 出力にあった lookup がこれです。 $ curl sum.golang.org 60295460 # ← 台帳の何番目の記録か github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= go.sum database tree # ← ここから下は署名付きのツリーヘッド 63096908 8lHrQPRYe4KT4WQ9/Qk8/taKnD6IRHbFgfN7vQE2Y54= — sum.golang.org Az3gri/oVBN1jAhNZ/iS+W31psFAWRT20/GyrhpU6taFwulQGetBkH5S7wpGXdS935jiIGiUUAXBVHzT/P6fYhqAmAk= STEP4 で計算した値と同じハッシュが返ってきています。checksum database は追記専用の台帳(Merkle tree を使った透明性ログ)で、一度記録したハッシュを後から書き換えられません。 最終行は sum.golang.org の電子署名(Ed25519)です。この署名を確かめる公開鍵は、go コマンドのソースに定数として書かれています。 src/cmd/go/internal/modfetch/key.go var knownGOSUMDB = map[string]string{ "sum.golang.org": "sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8", } 定数なので、ビルドすると go の実行ファイルにそのまま入ります。 $ strings $(which go) | grep -o 'sum.golang.org+033de0ae+.{44}' sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8 途中のプロキシは秘密鍵を持っていないので、応答を書き換えると署名が合わなくなります。検証用の鍵は手元の go コマンドの中にあるので、鍵ごと差し替えることもできません。 2 回目以降 キャッシュ済みのモジュールを使うたびに、go コマンドはメインモジュールの go.sum と照合しています。毎回 zip を再計算すると重いので、STEP3 で保存した .ziphash(計算済みのハッシュ)と go.sum を比べます。 To avoid the need to trust other modules, the go command verifies hashes using the main module's go.sum whenever it accesses a file in the module cache. Zip file hashes are expensive to compute, so the go command checks pre-computed hashes stored alongside zip files instead of re-hashing the files. — Authenticating modules "To avoid the need to trust other modules" という一節を覚えておいてください。最後に「一番の違い」を考えるときに使います。 STEP6 go.sum に記録する 検証を通ったハッシュが go.sum に追記されます。行数にはルールがあります(go.sum files)。 ビルドにソースが必要なモジュールは、zip の h1: と /go.mod の h1: の 2 行 モジュールグラフをたどるためだけに使うモジュールは、/go.mod の 1 行 ③ の謎の答えです。 2 行あるのは、STEP2 で見たとおり zip と go.mod を別々に取得し、別々に検証しているからです。MVS は go.mod しか読まないので、ソースが要らないモジュールについては go.mod の行だけで足ります。 ここで改めて go.sum を見てください。 github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= STEP1 で取得した github.com/go-chi/chi v1.5.5 は、zip の取得・展開も checksum database での検証も済んでいます。それなのに go.sum には 1 行もありません。import path を解決するための「探り」で取っただけで、最終的なモジュールグラフには入っていないからです。 キャッシュには「見に行ったもの全部」が残り、go.sum には「ビルドに関わるものだけ」が載ります。この非対称が、最後の答えにつながります。 go.sum は lock ファイルなのか 6 ステップを見終えたので、途中で保留した問いに戻ります。go.sum を消したらどうなるかを試しました。 $ mv go.sum /tmp/go.sum.bak $ go build ./... main.go:8:2: missing go.sum entry for module providing package github.com/go-chi/chi/v5 (imported by github.com/shirokuma/conference-report/GoBash/ex-Go); to add: go get github.com/shirokuma/conference-report/GoBash/ex-Go ビルドは止まりました。Go 1.16 以降は -mod=readonly が既定なので、go build が go.sum を勝手に作り直すことはありません。go mod tidy で作り直して、元のファイルと比べます。 $ go mod tidy $ diff /tmp/go.sum.bak go.sum $ # 差分なし 1 バイトも変わりませんでした。go.sum が無いとビルドは止まりますが、作り直しても中身は同じです。つまり go.sum はバージョンの決定に関わっていません。バージョンを決めているのは go.mod と MVS です。 ファイル 役割 go.mod どのモジュールのどのバージョン以上が要るかの 宣言 go.sum 取ってきた中身が本物であることを確かめる 証明 の台帳 go.sum は lock ファイルではなく、検証のための台帳でした。 4 つの疑問の答え合わせ 最初に挙げた 4 つの疑問に、ここまでのステップで答えます。 疑問 答え 根拠 1. なぜ同じディレクトリに置いているのか? 同じ <モジュール>@<バージョン> なら、中身の h1: ハッシュは 1 つに決まる。1 つ置けばどのプロジェクトも同じものを使える STEP4・5 2. 別のプロジェクトが別のバージョンを使いたくなったら? ディレクトリ名にバージョンが入るので、別ディレクトリとして共存する。どのバージョンを使うかは各プロジェクトの go.mod が決める STEP1・3 3. 別のプロジェクトが中身を書き換えたら? 展開済みのソースは読み取り専用なので、そもそも書き込めない STEP3 4. 作者がタグを別のコミットに付け替えたら? プロキシにキャッシュ済みなら、.info に記録された元のコミットから作った zip がそのまま配られる。別経路で付け替え後の中身を取っても、go.sum や checksum database に残る最初のハッシュと合わずエラーになる STEP2・3・5 疑問 3 は実際に試せます。 $ M=~/go/pkg/mod/github.com/go-chi/chi/v5@v5.3.2 $ echo "// hack" >> $M/mux.go zsh: permission denied: .../chi/v5@v5.3.2/mux.go chmod で権限を外して無理やり書き換えた場合は、go mod verify で見つけられます。go mod verify はキャッシュの zip と展開済みディレクトリのハッシュを計算し直し、ダウンロード時に記録した .ziphash と比べるコマンドです。合わなければ dir has been modified などのエラーを出します。 ! ビルドのたびに展開済みディレクトリの全ファイルを計算し直しているわけではありません。STEP5 で見たとおり、普段の照合は計算済みの .ziphash と go.sum の比較です。手でキャッシュを触ってしまったときは go mod verify を実行してください。go mod verify の照合相手は、Go 1.27.0 の cmd/go/internal/modcmd/verify.go で確認しました。 おまけの疑問にも 1 つ答えておきます。途中で checksum database の応答まで偽のハッシュに書き換えられた場合は、STEP5 で見た署名の検証で止まります。 最初の問いに戻る:npm と Go の一番の違い 冒頭の問いに戻ります。npm と Go の一番の違いは何でしょうか。 表にすると、違いはいくつも挙がります。 観点 Go npm 依存の置き場所 GOMODCACHE(マシンに 1 つ) プロジェクトごとの node_modules 同一バージョンの重複 無い プロジェクトの数だけコピーされる バージョンの宣言 go.mod に最低バージョンを 1 つ package.json に範囲、lock に結果 依存の書き込み 読み取り専用 書き換えられる git 管理するもの go.mod と go.sum package.json と package-lock.json 登壇の準備を始めたとき、私は一番の違いを「依存の置き場所」だと考えていました。node_modules が無いのは、Go を触ると最初に気付く違いだからです。 ただ、6 ステップを追った今は少し違う答えを出せます。npm も ~/.npm/_cacache に共有キャッシュを持っているので、実体を共有すること自体は Go だけの特徴ではありません。違いは、共有しても安全だと言える仕組みが仕様で決まっている点にあります。 バージョンの中身は変わらない(同じバージョンなら h1: は 1 つに決まり、checksum database がそれを記録し続ける) 使うバージョンは各プロジェクトの go.mod だけで決まる(MVS) キャッシュを読むたびに、各プロジェクトの go.sum と照合する STEP5 で引用した一節 "To avoid the need to trust other modules" が言っているのはこのことです。キャッシュには、ほかのプロジェクトが取ってきたものも探りで取っただけのものも入っています。それでも各プロジェクトは自分の go.sum で照合するので、ほかのプロジェクトを信用する必要がありません。 Go が共有しているのは依存の「実体」だけで、「信頼」は共有していません。信頼の単位は、リポジトリごとに git で管理している go.sum です。 これが、私の考える「一番の違い」です。 裏を返すと、go.sum 自体を書き換えられたら、この仕組みでは防げません。go.sum に行があれば checksum database には問い合わせないので、偽のハッシュを書いた go.sum がリポジトリに入れば、go コマンドはそれを正解として扱います。go.sum の差分は、自動生成のファイルだからと読み飛ばさず、コードと同じようにレビューしてください。 調べきれなかったこと 登壇までに調べきれず、宿題として残っているものです。 最後は Google(sum.golang.org の運営者)を信用するしかないのか Go 1.27.0 で依存管理がどう変わったのか dep(Gopkg.lock)の時代の依存管理と、なぜ lock ファイルをやめたのか 標準パッケージが go.mod と go.sum に記録されない理由(どうやってインストールし、どうやって検証しているのか) 1 つ目については手がかりだけ見つけています。透明性ログには、運営者が特定の人にだけ違うハッシュを返すと、台帳の枝分かれとして検出できる仕組みがあります。ただ、それで運営者への信用をどこまで減らせているのかは、まだ整理できていません。 分かったら続きの記事を書く予定です。詳しい方がいたら、ぜひコメントで教えてください。 まとめ go get は「決める → 取得する → キャッシュに置く → ハッシュを計算する → 照合する → 記録する」の 6 ステップで動いている go.mod は最低バージョンの宣言で、MVS により同じ結果が毎回計算できるので lock ファイルが要らない go.sum は lock ファイルではなく、取ってきた中身が本物かを確かめる台帳。依存の実体は全プロジェクトで共有し、信頼は各リポジトリの go.sum に置いている 次のコマンドを一度打ってみると、ここで追った 6 ステップがそのまま流れていくのが見えます。 GOMODCACHE=$(mktemp -d) GOFLAGS=-modcacherw go get -x <モジュール> 普段使っているモジュールで試してみてください。 参考 Go Modules Reference Resolving a package to a module Minimal version selection (MVS) Module cache Authenticating modules go.sum files Managing dependencies proxy.golang.org About packages and modules | npm Docs
Building MarginCMS, part 2: A Go server generated from the spec In part 1 I wrote the OpenAPI contract for MarginCMS and planned the backend. In this part the Go service comes to life: a server generated from the spec, config from environment variables, stub handlers that fail properly, and a Docker Compose setup that runs the API, the docs and a mock server with one command. It also includes a CORS bug where my server logged every request and the browser still showed nothing. That one gets its own section. Why generate the server at all? The contract is the only thing my wife's frontend and my backend share. If the Go handlers are written by hand, they'll drift from the spec sooner or later: a renamed field, a missing status code, a query parameter parsed as the wrong type. With a generated server, the handlers implement an interface that comes straight from the spec. When the spec changes, I regenerate, and the compiler tells me exactly what's out of date. Step 1: Choose the server and generate it from the spec Commit: api: generate Go server and types from the OpenAPI spec chi I picked chi as the router: - I've used it before, so it's one less new thing. - It's a thin layer on top of net/http. Handlers are plain http.Handlers, and anything from the standard library works with it. - It has a good community and plenty of middleware and packages, including the CORS package that shows up later in this post. oapi-codegen oapi-codegen turns the bundled spec into Go code. I generate four files, each from its own small config: | Config | Generates | | --- | --- | | models.yaml | Request and response types | | server.yaml | A chi server and a strict server interface | | client.yaml | A typed Go client, useful for tests later | | server_urls.yaml | Constants for the server URLs in the spec | The strict server is the important part. Instead of func(w http.ResponseWriter, r *http.Request), each operation becomes a method with typed input and typed output: func (s server) GetHealth(ctx context.Context, request api.GetHealthRequestObject) (api.GetHealthResponseObject, error) The generated code parses the path, query, headers and body, calls my method, and writes the response. My code never touches http.Request. The return type is an interface that the generated per-status response types implement, so returning a response the spec doesn't define takes deliberate effort instead of happening by accident. Empty string or no field at all? A few years ago I worked with code generated from proto3 messages with plain string fields. Without the optional keyword (generally available only since protobuf 3.15) or wrapper types like StringValue, proto3 can't tell the difference between a client sending "title": "" and not sending title at all. Both arrived as an empty string. For a PATCH endpoint that's a real problem: "clear the excerpt" and "don't touch the excerpt" look the same. oapi-codegen handles this the Go way. Optional fields become pointers: type PostUpdate struct { Body *string `json:"body,omitempty"` Excerpt *string `json:"excerpt,omitempty"` Slug *string `json:"slug,omitempty"` Status *PostStatus `json:"status,omitempty"` Tags *[]string `json:"tags,omitempty"` Title *string `json:"title,omitempty"` } nil means "not sent", and a pointer to "" means "set it to empty" (for excerpt and body; title has minLength: 1). That's exactly what the PATCH semantics need. One caveat: an explicit "excerpt": null also decodes to nil, so "not sent" and "sent as null" look the same. That's fine here because none of these fields are nullable. If I ever need the difference, oapi-codegen's nullable-type output option generates nullable.Nullable[T] for it. Tools pinned with go tool My first version installed the generator with go install ...@latest. That works on my machine today, but it means anyone cloning the repo next year gets a different generator than the one that produced the committed code. Since Go 1.24, go.mod can track tools the same way it tracks dependencies: tool ( github.com/air-verse/air github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen ) Now go tool oapi-codegen always runs the exact version in go.mod, and the root Makefile uses it: openapi-generate-go: cd api && go tool oapi-codegen --config=../openapi/oapi-codegen/server.yaml ../openapi/dist/openapi.bundled.yaml The trade-off is that the tools' dependencies show up in go.mod as indirect requirements. They don't end up in the API binary, but the file gets longer. I'm fine with that in exchange for one source of truth for versions. Step 2: Config, stubs and a health check Commit: api: add server skeleton with config, stub handlers and health check Config from the environment Config comes from environment variables, mapped onto structs with go-envconfig. Each package owns its own config struct, and the top-level config just composes them: // internal/server/config.go type Config struct { Addr string `env:"SERVER_ADDR, default=:8080"` ShutdownTimeout time.Duration `env:"SERVER_SHUTDOWN_TIMEOUT, default=30s"` ReadHeaderTimeout time.Duration `env:"SERVER_READ_HEADER_TIMEOUT, default=5s"` // ... } // internal/config/config.go type Config struct { Server *server.Config } When the database and auth packages arrive, they'll add their own Config next to their code instead of one giant struct growing in a single file. There's also a test that loads the config with no SERVER_* variables set and compares it with the expected defaults. It sounds trivial, but it's already caught me twice: once when I added the CORS and timeout fields, and once when I lowered a timeout default. Defaults are part of the behaviour, so they deserve a test. It also makes me look at .env.example every time a default changes, so the code and the docs don't drift apart. main.go main.go stays small: load the config, build the generated handler, wrap it, and run an http.Server with timeouts and graceful shutdown: ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() cfg := config.New(ctx) serverInterface := api.NewStrictHandlerWithOptions(server.New(), server.NewServerMiddlewares(), server.NewServerOptions()) handler := api.HandlerWithOptions(serverInterface, api.ChiServerOptions{BaseURL: "/api/v1"}) On SIGINT or SIGTERM the server stops accepting new connections and gives in-flight requests up to SERVER_SHUTDOWN_TIMEOUT to finish. 501 for everything that isn't built yet The generated interface has 17 methods, and I've implemented one. The stubs for the rest started out as panic("implement me"). Go's HTTP server recovers from the panic, but the client gets a dropped connection and my log gets a stack trace. Not great when the docs page has a "Try it" button. 501 Not Implemented is the honest answer. The catch is that the strict server only lets a method return the responses the spec defines, and the spec has no 501. It doesn't need one, though: any error a method returns goes to a configurable ResponseErrorHandlerFunc. So every stub returns a sentinel error: var ErrNotImplemented = errors.New("not implemented") func (s server) ListPosts(ctx context.Context, request api.ListPostsRequestObject) (api.ListPostsResponseObject, error) { return nil, ErrNotImplemented } and the error handler maps it to a status code and writes a problem+json body using the Problem type generated from the spec: func responseErrorHandler(w http.ResponseWriter, r *http.Request, err error) { status := http.StatusInternalServerError if errors.Is(err, ErrNotImplemented) { status = http.StatusNotImplemented } else { log.Printf("%s %s %s", r.Method, r.URL.Path, err) } writeProblem(w, status) } A nice side effect: real errors returned from handlers now also come back as a clean 500 in the same format, and the internal error message is logged instead of being sent to the client. Request-decoding errors still get oapi-codegen's default plain-text 400 for now. $ curl http://localhost:8080/api/v1/posts {"status":501,"title":"Not Implemented","type":"about:blank"} Health check GET /api/v1/healthz returns {"status":"OK"}. That's a stub for now. Once there's a database, it'll check that the app's dependencies are reachable and healthy, so Docker and any future deployment can tell a running process from a working one. Step 3: One command for the whole dev environment Commit: tooling: add Docker Compose dev environment docker compose up starts three services: | Service | Port | What it is | | --- | --- | --- | | api | 8080 | The Go API, rebuilt on every save by Air | | apidocs | 8081 | redocly preview: the API docs with a "Try it" console | | apimock | 8082 | Prism, serving mock responses from the spec | The api container mounts the source code and runs go tool air, so Air is pinned in go.mod like the generator. Two named volumes keep the Go module cache and the build cache (which holds the compiled Air binary) between container restarts. Without them, every recreated container downloaded all modules and compiled Air from scratch before serving a single request. Do I still need Prism? While setting this up, I noticed that the Redocly preview also has a built-in mock server behind its "Try it" console. So is Prism redundant? Not for us. The Redocly mock is great for trying endpoints from the docs page. Prism is a standalone server on a fixed port that the React app can use as its backend while my endpoints are still returning 501. It also validates requests against the spec and supports the Prefer header, so the frontend can ask for a specific response, like Prefer: code=412 for the edit-conflict case. That's what the frontend needs, so Prism stays. Step 4: The request that arrived and never came back Commit: api: add CORS middleware With everything running, I opened the docs on localhost:8081, pointed the "Try it" console at my real API on localhost:8080, and called the health check. The API logged the request. The docs page showed no response at all. Part one: CORS The server did its job. The browser threw the response away. A different port is a different origin, so a page on :8081 calling :8080 makes a cross-origin request. A plain GET without custom headers is a "simple" request, so the browser sends it straight away without a preflight. My handler runs and returns 200. But the response has no Access-Control-Allow-Origin header, so the browser refuses to let the page read it. The devtools console says so, if you think to look there. The fix is the go-chi/cors middleware. One detail matters: it has to run before chi's routing, so I wrap the whole handler at the net/http level instead of adding it to the strict server's middleware list. A preflight OPTIONS request doesn't match any generated route (chi answers 405 or 404 itself), so per-operation middleware never sees it. handler = cors.Handler(cors.Options{ AllowedOrigins: cfg.Server.AllowedOrigins, AllowedHeaders: cfg.Server.AllowedHeaders, AllowedMethods: cfg.Server.AllowedMethods, AllowCredentials: cfg.Server.AllowCredentials, })(handler) Part two: * means "all methods" in the spec, but not in go-chi/cors I added CORS, set everything to * in my local env to get going, and still got nothing. Calling the API with curl and an Origin header showed the middleware was running, since the response had Vary: Origin, but there was still no Access-Control-Allow-Origin. That surprised me, because * is a valid value for the Access-Control-Allow-Methods header: for requests without credentials, it means "all methods". The protocol was fine; the library was the problem. Reading the go-chi/cors source explained it. In its options, * is a wildcard for AllowedOrigins and AllowedHeaders, but not for AllowedMethods. There, * is compared literally against the request method, as if it were a method called *. On top of that, go-chi/cors checks the method on the actual request too, not only on the preflight, which the CORS spec doesn't ask for. My GET was a simple request with no preflight, and GET wasn't in my list, so it got no CORS headers at all. A preflighted request wouldn't have fared better: the preflight checks the requested method against the same list, and on a mismatch it still answers 200 OK, just without any CORS headers. Even a library that passed * through wouldn't have saved this config, because I also had AllowCredentials: true. For requests with credentials (cookies, TLS client certificates or HTTP authentication), browsers treat * in Access-Control-Allow-Methods and Access-Control-Allow-Headers as a literal name. That setting had a problem of its own, which is part three. Listing the methods explicitly fixed it: SERVER_ALLOWED_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS" Part three: don't ship the permissive version While I was there, I noticed a config that only worked by accident. AllowedOrigins: * with AllowCredentials: true makes go-chi/cors send Access-Control-Allow-Origin: * next to Access-Control-Allow-Credentials: true, a combination browsers reject for credentialed requests. MarginCMS uses bearer tokens and no cookies, so it doesn't need credentials at all, and there's no reason to let every origin in either. The defaults are now strict: AllowedOrigins []string `env:"SERVER_ALLOWED_ORIGINS, default=http://localhost:8081"` AllowedMethods []string `env:"SERVER_ALLOWED_METHODS, default=GET,POST,PUT,PATCH,DELETE,OPTIONS"` AllowedHeaders []string `env:"SERVER_ALLOWED_HEADERS, default=Authorization,Content-Type"` AllowCredentials bool `env:"SERVER_ALLOW_CREDENTIALS, default=false"` If it works locally with *, that's a sign to tighten it, not a sign that you're done. What I'd tell myself before starting - When the server says 200 and the client sees nothing, the browser is the one dropping the response. Start debugging there. - Read the source of small middleware packages. go-chi/cors is a few hundred lines, and five minutes of reading beat an hour of guessing. - Pin your code generators. Generated code is only reproducible if the generator version is. - Test your defaults. It's the cheapest test you'll write and it keeps config and docs honest. Next steps 1. Add a linter. golangci-lint with a config that's strict from day one, while there's still almost no code to fix. 2. Start on auth with mock data. Login, token refresh, logout and GET /me, backed by an in-memory user for now, so the auth flow and its tests exist before the database does. In part 3 the linter came first and turned into a dependency hygiene problem: every Go tool moved into its own module, then came a strict golangci-lint config and request validation against the spec. Auth moved to part 4. --- Originally published on my blog, where the comments live. The code for this post is on GitHub.
Building MarginCMS, part 1: Contract first, code later I'm building a small CMS in Go. It's called MarginCMS, it has a React admin on top, and it won't replace anything I already use. This series is the build log: what I decided, what I wrote, and what tripped me up along the way. This first part covers why I'm doing it, and the work that happened before writing any Go code: the API contract, the tooling around it, and the plan for the backend. The code is public at davidporos92/margin-cms. Why build a CMS in 2026? Nobody needs another CMS, including me. I'm building one anyway, for four reasons. It's fun. Writing a CMS has been on my someday list for years, and a pet project with no deadline and no users is the best place to finally do it. Deliberate practice. I use AI tools every day, and they're good. But I noticed I reach for them before I've thought a problem through. I want one project where I make every decision and write the code myself, with AI as a reviewer and a rubber duck rather than the author. It starts easy and gets hard. A CMS starts as CRUD over a posts table, which is a nice way to ease back in. Then the real problems show up: authentication and token refresh, revision history, two people editing the same post at the same time, search, pagination. Each of these is small enough to finish and deep enough to learn something from. There's a lot of room to grow. Once the core works, there's room for tools, plugins and packages around it: an export pipeline to my existing blog, a CLI, maybe a client library. None of that is planned yet, but the option is there. My wife is building the React frontend, so this is a two-person project with a clean split: I own the Go API, she owns the web app and the design system. The only thing we share is the API contract (and a small little doggo), and that shapes almost every decision below. The scope, briefly To keep it finishable, v1 is deliberately boring: - one admin user, one content type (posts), no media uploads, no plugin system - posts are markdown with a title, slug, excerpt, tags and a status (draft, published, archived) - every content change creates a revision, and an activity log records who did what - one Go binary with small internal packages (auth, posts, revisions, activity, stats) "Modular" here means compile-time packages with small interfaces, not a dynamic content-type builder. Step 1: Write the OpenAPI contract first Commit: Add OpenAPI contract and spec tooling Before writing a single handler, I wrote the API as an OpenAPI 3 spec. That had two goals. Don't block the frontend. If the frontend has to wait for my endpoints, it'll wait a long time. With a spec in place, the frontend can generate its TypeScript types and develop against a mock server from day one, and switch the base URL once the real endpoints land. Get a clear picture of what I'm building. Writing the contract forced decisions I would otherwise have made halfway through a handler: - Auth: a short-lived access token in Authorization: Bearer, plus a single-use refresh token. No cookies, so classic CSRF doesn't apply; XSS is the thing to guard against instead. - Errors: every error is application/problem+json (RFC 9457), with field-level details for form validation. - Optimistic concurrency: posts carry an ETag, and updates, deletes and revision restores require If-Match. A stale write gets 412 Precondition Failed with the current version of the post in the response, so the UI can show a proper conflict banner instead of silently overwriting someone's work. - Pagination: cursor-based, not offset-based. The result is 17 operations across auth, posts (including bulk actions and markdown export), revisions, tags, activity, stats and a health check. I didn't want one 2,000-line YAML file, so the spec is split into small files: one per path and one per component (schemas, parameters, request bodies, responses), all referenced from a root openapi.yaml. The convention is simple: file name equals component name. components/schemas/Post.yaml defines Post. A bundler merges everything into a single file for the tools that need one. Step 2: Set up tooling around the spec A spec is only useful if the tooling around it is easy to use, so the same commit adds npm scripts and make targets for: | Need | Tool | | --- | --- | | Lint the spec | Redocly CLI (redocly lint) | | Bundle the split files into one | Redocly CLI (redocly bundle) | | Browse the docs | Redocly | | Mock server | Prism | | TypeScript types | openapi-typescript | | Go types and server | oapi-codegen (wired up in part 2) | Prism deserves a special mention. Besides returning example responses, it supports the Prefer header. The frontend can send Prefer: code=412 and get a 412 back, which makes it possible to build the conflict handling long before the backend can produce a real conflict. Step 3: Plan the Go backend No commit for this one. It's the thinking that happened before the code. I picked tools in three groups. The HTTP server. I went with chi. I've used it before, it stays close to net/http, and oapi-codegen can generate a chi server directly from the spec. More on that in part 2. Data access. The classic Go debate is ORM versus plain SQL. Since part of the point is to learn new things while I ease back into Go, I chose tools I haven't used in anger yet: - PostgreSQL with pgx - Ent for the schema and data access, with the schema written as Go code - Atlas to generate versioned SQL migrations from the Ent schema. The API never changes the database schema on its own. - testcontainers-go for integration tests against a real Postgres, using the same migration files that ship Code generation from OpenAPI. Handlers implement an interface generated from the spec, so the code can't drift from the contract without the compiler noticing. Step 4: Plan the local dev setup Also mostly planning at this stage. The goal was a dev loop where everything runs with one command: - Go hot reload with Air, so saving a file rebuilds and restarts the API - Prism as the mock backend for the web app, for the Prefer header support mentioned above - Docker Compose so we both get the same environment, including Postgres later Next steps In part 2 I set up the Go service and the local environment: generating the server from the spec, config handling, stub handlers, the Docker Compose setup, and a CORS bug that turned out to be a library quirk, not the protocol. --- Originally published on my blog, where the comments live. The code for this post is on GitHub.