gleam-practice
Gleam Practice
Gleam を新規作成するとき、既存プロジェクトを改善するとき、wisp + mist + gleam_otp + just 構成で実装するときに使う。
Default Workflow
- まず
gleam newとgleam addを使う。依存は手書きより solver に決めさせる。 - TDD で進める。
探索 -> Red -> Green -> Refactor。 - 純粋ロジックと状態管理を分ける。domain module と actor/server module を分離する。
- 公開 API は小さく保つ。
pub opaque typeを優先し、内部 constructor や protocol 型をむやみに公開しない。 @externalは最後の手段。使う場合は薄い adapter module に閉じ込める。- web 層は薄くする。
wisphandler は decode, call service, encode response に寄せる。 - タスク実行は
justfileに集約し、CI もjust ciを叩く。
Project Setup
Erlang target の新規 project はこれを起点にする。
gleam new my_app --template erlang
cd my_app
gleam add wisp mist gleam_otp gleam_erlang
gleam add gleam_json envoy
gleam add --dev gleeunit
必要に応じて追加する。
- HTTP client:
gleam add gleam_http gleam_httpc - file I/O:
gleam add simplifile filepath - snapshot test:
gleam add --dev birdie - property test:
gleam add --dev qcheck qcheck_gleeunit_utils - interactive test loop:
gleam remove gleeunit && gleam add --dev glacier - unused export check:
gleam add --dev cleam - benchmark:
gleam add --dev glychee - logging config:
gleam add logging - live reload DX:
gleam add --dev olive
外部 CLI:
- HTTP load test:
k6
wisp と mist は互換性のある組み合わせを使う。version を個別に固定するより、gleam add wisp mist を同じタイミングで解決させる方が安全。
雛形:
assets/README.mdassets/justfileassets/github-actions/ci.ymlassets/bench/http.jsassets/test/snapshot_test.gleamassets/test/property_test.gleamassets/test/qcheck_parallel_test.gleamassets/test/timeout_test.gleam
gleam.toml Defaults
最低限これを埋める。
name = "my_app"
version = "0.1.0"
target = "erlang"
description = "Short package summary"
licences = ["Apache-2.0"]
repository = { type = "github", user = "owner", repo = "repo" }
# Package 内だけで使う module は隠す
internal_modules = [
"my_app/internal",
"my_app/http/internal",
]
原則:
- metadata を空のまま放置しない
- package 内専用 module は
internal_modulesで隠す - broad な semver range は許容されるが、CI で必ず固定 lockfile を通す
Recommended Layout
最初はこの分け方から始める。
src/
my_app.gleam # entrypoint
my_app/app.gleam # DI と supervision 起動
my_app/web.gleam # router / request handling
my_app/domain.gleam # pure domain logic
my_app/domain_server.gleam # actor wrapper
my_app/http_json.gleam # encoder / decoder
test/
my_app_test.gleam
docs/
reference-ja.md
reference-en.md
bench/
main.gleam
justfile
分離の基準:
domain.gleam: pure function のみ*_server.gleam: actor / supervision / timeout / mailboxweb.gleam: routing と HTTP mappinghttp_json.gleam: JSON schema と codecapp.gleam: wiring のみ
1 file に router, JSON codec, business logic, actor state を混ぜない。
大きくなったらこう分ける。
web_*.gleam: feature ごとの handlerweb_json_*.gleam: domain ごとの encoder / decoderagent_*.gleam: protocol, runner, tool, transportworkspace_*.gleam: overlay, patch, git, session, runtime*_test_support.gleam: test helper を責務ごとに分離
Wisp + Mist Skeleton
src/my_app.gleam
import envoy
import gleam/erlang/process
import gleam/int
import gleam/result
import mist
import my_app/app
import my_app/web
import wisp/wisp_mist
const default_port = 4000
const default_secret_key_base =
"local-dev-secret-key-base-local-dev-secret-key-base-1234567890"
pub fn main() -> Nil {
let port =
envoy.get("PORT")
|> result.map(int.parse)
|> result.flatten
|> result.unwrap(default_port)
let secret_key_base =
envoy.get("SECRET_KEY_BASE")
|> result.unwrap(default_secret_key_base)
let assert Ok(app_state) = app.start()
let assert Ok(_) =
web.app(app_state)
|> wisp_mist.handler(secret_key_base)
|> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(port)
|> mist.start
process.sleep_forever()
}
src/my_app/web.gleam
import gleam/http
import my_app/app.{type App}
import wisp
pub fn app(app: App) -> fn(wisp.Request) -> wisp.Response {
fn(request) {
case request.method, wisp.path_segments(request) {
http.Get, ["healthz"] -> wisp.text_response(200, "ok\n")
_, _ -> wisp.not_found()
}
}
}
web 層の原則:
wisp.require_jsonで payload を読む- decoder 失敗は
400 - domain error は
4xx/5xxへ明示的に map する - handler は大きくなったら feature ごとに分割する
OTP Pattern
基本は pure state と server の 2 層。
重要: gleam_otp は Erlang gen_server を静的型付けで包んだライブラリで、API は gen_server と非互換。:gen_server.call / :gen_server.cast を直接呼ばない。Subject と actor.Message の世界で完結させる。
pure state
pub opaque type State {
State(hits: Dict(String, Int), limit: Int)
}
pub fn new(limit: Int) -> State {
State(hits: dict.new(), limit: limit)
}
pub fn incr(state: State, key: String) -> #(State, Bool) {
let n = dict.get(state.hits, key) |> result.unwrap(0) + 1
#(State(..state, hits: dict.insert(state.hits, key, n)), n <= state.limit)
}
actor wrapper(具体例)
import gleam/erlang/process.{type Subject}
import gleam/otp/actor
import gleam/otp/supervision
pub opaque type Message {
Check(key: String, reply_to: Subject(Bool))
Reset(key: String)
}
pub opaque type Server { Server(subject: Subject(Message)) }
pub fn start(limit: Int) -> Result(Server, actor.StartError) {
actor.new(new(limit))
|> actor.on_message(handle)
|> actor.start
|> result.map(fn(started) { Server(subject: started.data) })
}
pub fn supervised(limit: Int) -> supervision.ChildSpecification(Server) {
supervision.worker(fn() { start(limit) })
}
fn handle(state: State, msg: Message) -> actor.Next(State, Message) {
case msg {
Check(key, reply_to) -> {
let #(next, ok) = incr(state, key)
process.send(reply_to, ok)
actor.continue(next)
}
Reset(key) ->
actor.continue(State(..state, hits: dict.delete(state.hits, key)))
}
}
// sync call pattern: 呼び出し側が reply subject を作って渡す
pub fn check(s: Server, key: String) -> Bool {
let reply = process.new_subject()
process.send(s.subject, Check(key, reply))
process.receive(reply, 1000) |> result.unwrap(False)
}
pub fn reset(s: Server, key: String) -> Nil {
process.send(s.subject, Reset(key))
}
Supervision tree の 3 種
gleam_otp は supervisor を用途別に 3 モジュールに分けている。
| 種類 | モジュール | いつ使う |
|---|---|---|
| static | gleam/otp/static_supervisor |
起動時に子プロセスが確定(DB pool、設定済み actor)。再起動だけを扱う |
| factory | gleam/otp/supervisor_factory |
同一 spec を動的に ID 付きで増やす(session per user 等)。start_child(name, arg) |
| dynamic | gleam/otp/supervisor_dynamic |
任意の spec を動的に追加・削除(job worker pool 等)。start_child(name, spec) / terminate_child |
実際の API 名はバージョンで揺れる。gleam_otp 公式 hex docs で都度確認。以下は v0.16+ 前提の雛形:
// src/my_app/app.gleam
import gleam/otp/static_supervisor as sup
import my_app/db_pool
import my_app/rate_limiter
pub fn start() -> Result(Nil, actor.StartError) {
sup.new(sup.OneForOne)
|> sup.restart_tolerance(intensity: 3, period: 60) // 60s で 3 回までの再起動を許容
|> sup.add(db_pool.supervised()) // static child
|> sup.add(rate_limiter.supervised(limit: 100)) // static child
|> sup.start
|> result.replace(Nil)
}
restart strategy の選び方:
Permanent: 落ちたら必ず再起動(DB pool、認証サーバーなど基盤)Transient: 正常終了は OK、異常終了のみ再起動(job worker)Temporary: 再起動しない(一度だけの処理)
restart_intensity(デフォルト 3)と period(デフォルト 5 秒)を超えた場合、supervisor 自身が落ちて上位に伝播する。
指針
Serverは opaque にする(外部から Subject を直接操作させない)- HTTP から state を直接触らない(actor 経由)
- supervision tree は
app.gleamに集める - restart strategy はまず
OneForOne、最上位はstatic_supervisor - test しやすくするため named actor を使う
- supervisor の種類を使い分ける:
static(固定)、factory(同 spec 動的増)、dynamic(任意 spec 動的)
JSON Codec (gleam/dynamic/decode + gleam/json)
Gleam の JSON API はバージョンで変わりやすい(gleam_json v2+)。現行の idiomatic な書き方:
Decode
import gleam/dynamic/decode
import gleam/json
pub type NewTodo { NewTodo(title: String, priority: Int) }
pub fn new_todo_decoder() -> decode.Decoder(NewTodo) {
use title <- decode.field("title", decode.string)
use priority <- decode.optional_field("priority", 0, decode.int)
decode.success(NewTodo(title:, priority:))
}
// Wisp handler 内で
case decode.run(body, new_todo_decoder()) {
Ok(nt) -> // ... use nt
Error(errors) -> wisp.bad_request("invalid json: " <> string.inspect(errors))
}
decode.field は必須、decode.optional_field(key, default, decoder) はオプション。nested object は decode.field("user", user_decoder())。
Encode
pub fn encode_todo(t: Todo) -> json.Json {
json.object([
#("id", json.int(t.id)),
#("title", json.string(t.title)),
#("done", json.bool(t.done)),
])
}
// Wisp で返す
wisp.json_response(json.to_string_tree(encode_todo(t)), 201)
リスト: json.array(items, of: encode_todo)。
LSP Code Action: gleam-language-server v1.2+ には「Generate JSON encoder/decoder」機能がある。Todo 型にカーソルを置いて Code Action を呼ぶと encode_todo / todo_decoder を自動生成。手書きより正確。
justfile Template
再利用するなら、まず assets/justfile を project root にコピーしてから微調整する。
set shell := ["bash", "-cu"]
default:
@just --list
deps:
gleam deps download
format:
gleam format
format-check:
gleam format --check .
typecheck:
gleam check
build:
gleam build --warnings-as-errors
test:
gleam test
run:
gleam run
docs:
gleam docs build
check:
gleam format --check .
gleam check
gleam build --warnings-as-errors
gleam test
ci: deps check
clean:
gleam clean
bench *args:
gleam run -m bench/main -- {{args}}
方針:
- shell は
bash - CI の入口は
just ci lint専用コマンドは作らず、format-check + build --warnings-as-errorsで閉じる
必要ならこれも足す。
exports: gleam run -m cleamsnapshot-review: gleam run -m birdietest-watch: gleam test -- --glacierbench-http: k6 run bench/http.jsserve-dev: gleam run -m olive
CI Workflow Template
GitHub Actions を使うなら、まず assets/github-actions/ci.yml を .github/workflows/ci.yml にコピーしてから project 固有の step だけ足す。
原則:
- CI も local も
just ciを唯一の入口にする - Erlang / Gleam version は workflow 側で固定する
@external, NIF, Wasm, Elixir 依存がある場合だけ追加 toolchain を足す- workflow で shell script を増やしすぎず、処理は
justfile側へ寄せる
Testing
まず pure function を固定し、そのあと actor、最後に HTTP を固定する。
test/my_app_test.gleam
import gleam/http
import gleeunit
import my_app/app
import my_app/web
import wisp/simulate
pub fn main() -> Nil {
gleeunit.main()
}
pub fn healthz_test() {
let assert Ok(app_state) = app.start()
let response = web.app(app_state)(simulate.request(http.Get, "/healthz"))
assert response.status == 200
assert simulate.read_body(response) == "ok\n"
}
テスト方針:
- pure module は state transition を直接 test
- actor module は public API だけ test
- HTTP test は
wisp/simulateを優先 - 外部 API は fake service を注入する
- flaky な sleep ではなく poll/retry helper を使う
- snapshot が効く出力には
birdie - property-based test が効く pure logic には
qcheck - ローカルの反復速度が欲しいときは
glacier
birdie の基本 workflow:
gleam test- failing / new snapshot を確認
gleam run -m birdie- snapshot を review して commit
qcheck の基本 workflow:
- pure function の性質を 1 つ決める
qcheck.given(...)で generator を流す- 反例が出たら shrink 結果をもとに unit test へ落とし込む
- 長い test や並列実行が必要なら
qcheck_gleeunit_utilsを使う
qcheck_gleeunit_utils の注意:
- Erlang target 専用
- 全 test をまとめて並列化したいなら
run.run_gleeunit - 長い test を 1 本だけ包みたいなら
test_spec.make - custom timeout を付けたいなら
test_spec.make_with_timeout - test group を明示したいなら
test_spec.run_in_parallel/run_in_order
Test Structure
feature が増えたら test file を責務で割る。
domain_test.gleam: pure domain logicruntime_test.gleam/app_test.gleam: supervision と actor integrationweb_app_test.gleam: HTTP contractagent_test.gleam: external API orchestrationworkspace_session_server_test.gleam: server / state helperworkspace_session_http_test.gleam: session API の contractworkspace_bit_runtime_test.gleam: FFI / git / wasm integration
support module も分ける。
*_app_test_support.gleam: app 起動、handler 作成、runtime helper*_workspace_test_support.gleam: fixture directory、workspace helper
1つの巨大な test file に全部詰め込まない。refactor が進んだら test も一緒に分割する。
Lint and Quality
Gleam には first-party の独立 lint tool より、formatter と compiler warning を厳格に使う方が合う。
最低限:
gleam format --check .gleam checkgleam build --warnings-as-errorsgleam test
補助:
- deprecated API の追従:
gleam fix - docs の確認:
gleam docs build - unused export の検出:
gleam run -m cleam
Performance Measurement
計測は 3 段でやる。
- pure function の micro benchmark
- actor / workspace の integration benchmark
- HTTP endpoint の load test
原則:
- benchmark 対象は pure function と I/O を分ける
- warmup と measurement を分ける
- debug log を切る
- benchmark 前に
gleam cleanと依存 download を済ませる - micro benchmark の結果と HTTP 負荷試験の結果を同列に比較しない
- FFI / Wasm / Port は cold start と hot path を分けて測る
手段:
- micro benchmark:
bench/module を作ってgleam run -m bench/main - library を入れるなら
glycheeを使う - HTTP load test:
k6かwrk - BEAM hotspot:
erl/gleam shellから:eprof,:fprof,:erlang.statistics(:reductions)を使う
HTTP の最小測定例:
just run
k6 run bench/http.js
最初は assets/bench/http.js を bench/http.js にコピーして使う。
FFI and Native Integration
@external を使うときは次を守る。
- adapter module を 1 枚作る
pub opaque type Handleだけ公開する- Erlang/Elixir の型や pid を外に漏らしすぎない
- path, ETS, NIF, Port, Wasm runtime の詳細は internal module に閉じ込める
- live test と failure test を両方書く
@external 実装は compiler が検証しないので、Gleam 側の surface area を最小にする。
Additional Tools
よく使う外部ツールの位置づけはこう考える。
gleeunit: 基本の unit test runner。まずこれ。birdie: snapshot test。HTTP response や generated text の固定に向く。qcheck+qcheck_gleeunit_utils: property-based test。pure domain logic に向く。glacier: interactive / incremental test loop。gleeunitの drop-in replacement として使う。cleam: 未使用 export の検出。公開 API の掃除に向く。glychee: micro benchmark。pure function や small integration の比較に向く。k6: HTTP / websocket 負荷試験。Gleam package ではなく外部 CLI。logging: Erlang logger 設定。運用寄り project なら入れてよい。olive: live reload 付き dev proxy。Wisp/Mist 開発体験を上げたいときだけ使う。
使い分け:
- default は
gleam format/check/build/test - 追加で入れる第一候補は
birdie,qcheck,cleam,glychee glacierとoliveは local DX を上げたいときloggingは long-running service や production app 向け
Documentation
README には少なくともこれを書く。
- 何が実装済みで、何がまだ非対象か
- 主要 module prefix と責務
- 起動方法、
just ci、主要 endpoint - test file の見方
中規模以上の project では docs/ を置き、読む順番を案内する。
docs/reference-ja.md: 日本語向けの実装ガイドdocs/reference-en.md: 英語向けの実装ガイド
この project が「入門用」なのか「実践的な参照実装」なのかを README 冒頭で明示する。
Reference Project Positioning
Gleam project を参照実装として見せるなら、位置づけを曖昧にしない。
- beginner sample: 小さい API と最小の OTP だけ
- medium reference:
wisp + mist + gleam_otp + just + CI + docs - advanced integration: FFI, Wasm, external LLM, workspace/session orchestration
高度な題材を入れる場合は、「これは一般的な Gleam の書き方」なのか「この project 固有の複雑さ」なのかを分けて説明する。
Closure Checklist
一旦仕上げる区切りはこのあたり。
- public / internal の境界が整理されている
just ciが green- test file が feature 単位に分かれている
- support module が責務ごとに分かれている
- README に実装状況と test 構成が書かれている
- 必要なら
docs/に参照ガイドがある
Review Checklist
- 公開 constructor は本当に必要か
pub typeをpub opaque typeにできないかinternal_modulesに隠すべき module はないか- web 層が decode/call/encode 以上のことをしていないか
- pure logic と actor state が混ざっていないか
just ciが local と CI の共通入口になっているかgleam build --warnings-as-errorsを通しているか- absolute path や machine-local config を埋めていないか
- external/FFI 境界が広すぎないか
References
- Gleam docs: https://gleam.run/
- Gleam
gleam.toml: https://gleam.run/writing-gleam/gleam-toml/ - Gleam externals: https://gleam.run/documentation/externals/
- Gleam CLI: https://gleam.run/reference/cli/
- Wisp docs: https://hexdocs.pm/wisp/
- Mist docs: https://hexdocs.pm/mist/
- just manual: https://just.systems/man/en/
More from mizchi/chezmoi-dotfiles
empirical-prompt-tuning
agent 向けテキスト指示(skill / slash command / task プロンプト / CLAUDE.md 節 / コード生成プロンプト)を、バイアスを排した実行者に動かしてもらい、両面(実行者の自己申告 + 指示側メトリクス)で評価して反復改善する手法。改善が頭打ちになるまで回す。プロンプトや skill を新規作成・大幅改訂した直後、またはエージェントの挙動が期待通りにならない原因を指示側の曖昧さに求めたいときに使う。
90retrospective-codify
タスク完了時に「最初に失敗した内容」と「最終的に通った解法」を対応付け、最初に知っておくべきだった知見を ast-grep ルール / skill / CLAUDE.md ルールのいずれかに言語化する。試行錯誤の末にたどり着いた解や、同じ落とし穴を将来の自分(または別エージェント)に繰り返させたくないときに使う。ユーザーから「今回の学びをルール化して」「skill にして」「lint に落として」と指示されたとき、またはタスク終了時に学びを棚卸しする場面で起動する。
17conventional-changelog
Conventional Commits 規約と CHANGELOG 自動生成の横断リファレンス。commit 書式、Keep a Changelog 形式、semver タグ運用、release-please / changesets / git-cliff / towncrier 等の生成ツール比較を含む。新規リポジトリで release フローを整備するとき、既存 repo の commit 規約を統一するとき、言語に合った changelog ツールを選ぶとき、release-please 以外の選択肢を検討するときに使う。
14playwright-test
Playwright Test (E2E) のベストプラクティスとリファレンス。テストの書き方、固定 wait 回避、ネットワークトリガー、DnD、GitHub Actions での shard/retry 設定など。Playwright テストを書く・レビュー・CI 設定するときに使用。
13ast-grep-practice
ast-grep をプロジェクト lint ツールとして運用するためのガイド。sgconfig.yml 設定、fix/rewrite ルール、constraints、transform、テスト、CI 統合、既存 linter との使い分けを扱う。汎用 linter で表現できないルールを ast-grep で書くときに使用。
13gh-fix-ci
Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions; use `gh` to inspect checks and logs, summarize failure context, draft a fix plan, and implement only after explicit approval. Treat external providers (for example Buildkite) as out of scope and report only the details URL.
10