When creating a new Gleam project or improving an existing project,wisp + mist + gleam_otp + just use it when implementing with the structure.
Default Workflow
- First,
gleam newとgleam addis used. Let the solver decide dependencies rather than writing them by hand. - Proceed with TDD.
探索 -> Red -> Green -> Refactor。 - Separate pure logic from state management. Separate domain modules from actor/server modules.
- Keep the public API small.
pub opaque typePrioritize, and do not expose internal constructors or protocol types unnecessarily. @externalis a last resort. If used, confine it to a thin adapter module.- Keep the web layer thin.
wispFocus handlers on decode, call service, encode response. - Consolidate task execution in
justfile, and CI should also calljust ci.
Project Setup
Use this as the starting point for new Erlang target projects.
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
Add as needed.
- 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
External CLI:
- HTTP load test:
k6
wisp と mist Use compatible combinations. Rather than pinning versions individually,gleam add wisp mist it is safer to resolve them at the same timing.
Template:
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
At minimum, fill this in.
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",
]
Principles:
- Do not leave metadata empty
- Hide package-internal modules with
internal_moduleshide with - Broad semver ranges are acceptable, but always run a fixed lockfile in CI
Recommended Layout
Start with this separation at first.
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
Separation criteria:
domain.gleam: pure functions only*_server.gleam: actor / supervision / timeout / mailboxweb.gleam: routing と HTTP mappinghttp_json.gleam: JSON schema と codecapp.gleam: wiring only
Do not mix router, JSON codec, business logic, and actor state in one file.
When it grows large, separate it like this.
web_*.gleam: handler per featureweb_json_*.gleam: encoder / decoder per domainagent_*.gleam: protocol, runner, tool, transportworkspace_*.gleam: overlay, patch, git, session, runtime*_test_support.gleam: separate test helpers by responsibility
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 layer principles:
wisp.require_jsonRead the payload with- Map decoder failures explicitly to
400 - domain error は
4xx/5xxmap explicitly to - Split handlers by feature when they grow large
OTP Pattern
Basically, the two layers of pure state と server .
Important: gleam_otp は Erlang gen_server is a library that wrapsAPI is incompatible with gen_server。:gen_server.call / :gen_server.cast Do not call directly.Subject と actor.Message Keep everything within the world of
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 (concrete example)
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))
}
3 types of supervision trees
gleam_otp splits supervisors into 3 modules by purpose.
| Type | Module | When to use |
|---|---|---|
| static | gleam/otp/static_supervisor |
Child processes are fixed at startup (DB pool, configured actors). Handles only restarts |
| factory | gleam/otp/supervisor_factory |
Dynamically increase the same spec with IDs (session per user, etc.).start_child(name, arg) |
| dynamic | gleam/otp/supervisor_dynamic |
Dynamically add/remove arbitrary specs (job worker pool, etc.).start_child(name, spec) / terminate_child |
Actual API names vary by version.gleam_otp Check the official hex docs each time. Below is a v0.16+ template:
// 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)
}
How to choose a restart strategy:
Permanent: Always restart if it crashes (infrastructure such as DB pool, auth server)Transient: normal exit is OK, restart only on abnormal exit (job worker)Temporary: do not restart (one-off processing)
restart_intensity(default 3) and period(default 5 seconds) are exceeded, the supervisor itself crashes and propagates to the upper level.
Guidelines
Servershould be opaque (do not allow the Subject to be manipulated directly from outside)- Do not touch state directly from HTTP (go through the actor)
- supervision tree は
app.gleamgather into - For restart strategy, first
OneForOne, at the top levelstatic_supervisor - Use named actors to make testing easier
- Use the different types of supervisors properly:
static(fixed),factory(dynamic increase of the same spec),dynamic(arbitrary spec dynamic)
JSON Codec (gleam/dynamic/decode + gleam/json)
Gleam's JSON API changes easily between versions (gleam_json v2+). Current idiomatic way of writing:
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 is required,decode.optional_field(key, default, decoder) is optional. Nested objects are 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)
List: json.array(items, of: encode_todo)。
LSP Code Action: gleam-language-server v1.2+ has a "Generate JSON encoder/decoder" feature.Todo Place the cursor on the type and invoke Code Action to encode_todo / todo_decoder auto-generate. More accurate than writing by hand.
justfile Template
If you want to reuse it, first assets/justfile copy to the project root, then fine-tune it.
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}}
Policy:
- shell は
bash - The CI entry point is
just ci lintDo not create a dedicated command,format-check + build --warnings-as-errorsclose with
Add this too if necessary.
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
If you use GitHub Actions, first assets/github-actions/ci.yml を .github/workflows/ci.yml copy to, then add only project-specific steps.
Principles:
- CI も local も
just cimake it the sole entry point - Fix the Erlang / Gleam version on the workflow side
@external, add additional toolchains only if there are NIF, Wasm, or Elixir dependencies- Do not add too many shell scripts in the workflow; put the processing
justfiletoward the side
Testing
First fix the pure functions, then the actors, and finally 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"
}
Testing policy:
- Test pure modules' state transitions directly
- Test only the public API of actor modules
- HTTP test は
wisp/simulateprioritize - Inject fake services for external APIs
- Use poll/retry helpers instead of flaky sleep
- For outputs where snapshots are effective,
birdie - For pure logic where property-based tests are effective,
qcheck - When you want local iteration speed,
glacier
birdie basic workflow:
gleam test- Check failing / new snapshots
gleam run -m birdie- Review and commit the snapshot
qcheck basic workflow:
- Decide on one property of the pure function
qcheck.given(...)run the generator with- If a counterexample appears, reduce it to a unit test based on the shrink result
- If you need long tests or parallel execution, use
qcheck_gleeunit_utilsuse
qcheck_gleeunit_utils notes:
- Erlang target only
- If you want to parallelize all tests together,
run.run_gleeunit - If you want to wrap just one long test,
test_spec.make - If you want to add a custom timeout,
test_spec.make_with_timeout - If you want to make the test group explicit,
test_spec.run_in_parallel/run_in_order
Test Structure
As features grow, split the test file by responsibility.
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
Also split out support modules.
*_app_test_support.gleam: app startup, handler creation, runtime helper*_workspace_test_support.gleam: fixture directory、workspace helper
Don't cram everything into one giant test file. As refactoring progresses, split the tests along with it.
Lint and Quality
For Gleam, using the formatter and compiler warnings strictly suits it better than a first-party standalone lint tool.
Minimum:
gleam format --check .gleam checkgleam build --warnings-as-errorsgleam test
Auxiliary:
- Following deprecated APIs:
gleam fix - Checking docs:
gleam docs build - Detecting unused exports:
gleam run -m cleam
Performance Measurement
Do measurement in 3 stages.
- pure function の micro benchmark
- actor / workspace の integration benchmark
- HTTP endpoint の load test
Principles:
- Separate pure functions and I/O as benchmark targets
- Separate warmup and measurement
- Turn off debug logs
- Before benchmarking,
gleam cleanand dependency download should be completed - Don't compare micro benchmark results and HTTP load test results on the same footing
- For FFI / Wasm / Port, measure cold start and hot path separately
Means:
- micro benchmark:
bench/Create a module andgleam run -m bench/main - If you're adding a library,
glycheeuse - HTTP load test:
k6かwrk - BEAM hotspot:
erl/gleam shellfrom:eprof,:fprof,:erlang.statistics(:reductions)use
Minimal HTTP measurement example:
just run
k6 run bench/http.js
At first, assets/bench/http.js を bench/http.js copy and use it.
FFI and Native Integration
@external When using , follow these rules.
- Create a single adapter module
pub opaque type HandleExpose only- Don't leak Erlang/Elixir types or pids outward too much
- Keep the details of paths, ETS, NIF, Port, and Wasm runtime confined to internal modules
- Write both live tests and failure tests
@external implementations are not verified by the compiler, so minimize the surface area on the Gleam side.
Additional Tools
Think of the positioning of commonly used external tools as follows.
gleeunit: The basic unit test runner. Start with this.birdie: Snapshot testing. Suited for fixing HTTP responses and generated text.qcheck+qcheck_gleeunit_utils: Property-based testing. Suited for pure domain logic.glacier: interactive / incremental test loop。gleeunitUse as a drop-in replacement for .cleam: Detection of unused exports. Suited for cleaning up public APIs.glychee: Micro benchmarking. Suited for comparing pure functions and small integration.k6: HTTP / websocket load testing. Not a Gleam package but an external CLI.logging: Erlang logger configuration. Fine to include if the project is operations-oriented.olive: Dev proxy with live reload. Use only when you want to improve the Wisp/Mist development experience.
How to use them differently:
- default は
gleam format/check/build/test - The first candidate for additional inclusion is
birdie,qcheck,cleam,glychee glacierとoliveis for when you want to improve local DXloggingis for long-running services and production apps
Documentation
At minimum, write this in the README.
- What is implemented and what is still out of scope
- Main module prefixes and responsibilities
- How to run,
just ci, main endpoints - How to read the test files
For medium-sized or larger projects, docs/ is placed, and the reading order is guided.
docs/reference-ja.md: Implementation guide for Japanesedocs/reference-en.md: Implementation guide for English
State clearly at the beginning of the README whether this project is for "beginners" or is a "practical reference implementation."
Reference Project Positioning
If you present the Gleam project as a reference implementation, do not make its positioning ambiguous.
- beginner sample: just a small API and minimal OTP
- medium reference:
wisp + mist + gleam_otp + just + CI + docs - advanced integration: FFI, Wasm, external LLM, workspace/session orchestration
If you include advanced topics, explain separately whether it is "a general way of writing Gleam" or "complexity specific to this project."
Closure Checklist
This is about the point where you wrap things up for now.
- The public / internal boundary is organized
just ciが green- Test files are split by feature
- Support modules are separated by responsibility
- The implementation status and test structure are written in the README
- If necessary,
docs/there is a reference guide in
Review Checklist
- Are public constructors really necessary?
pub typeをpub opaque typeCan it not be madeinternal_modulesAre there any modules that should be hidden in- Is the web layer doing anything beyond decode/call/encode?
- Are pure logic and actor state mixed together?
just ciIsgleam build --warnings-as-errorsIs it passing through- Are absolute paths or machine-local config embedded?
- Is the external/FFI boundary too wide?
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/