A design pattern, and a recommendation for Aether libraries that have a
"start the thing" surface, HTTP servers, daemons, agents, schedulers,
fixtures, test rigs. The short version: don't ship a YAML loader.
Expose your library's start surface as a closure-DSL block, and let the
operator's "config" be a .ae file they run with ae run.
This document is the why. For how, the syntax, the builder
keyword, the trailing-block context, see
closures-and-builder-dsl.md.
Most server-shaped projects walk through these stages, in order, until they get tired of it. Aether lets you skip to the end.
# my-server.yaml
listen:
host: 127.0.0.1
port: 9990
auth:
superuser_token: ${env:SUPER_TOKEN}
repos:
- name: alpha
path: /srv/alpha
- name: beta
path: /srv/betaThe binary parses this at startup. Pros: anyone can edit it; tools can diff it; YAML is "just data."
The catch: every one of these grows a half-baked DSL anyway. First
people want environment-variable interpolation (${env:...}). Then
conditional sections (if env == prod). Then include directives
(!include common.yaml). Then computed defaults. Then templating
(Helm, Kustomize, Jinja). At each step you've reinvented a worse copy
of the host language, and operators are now learning a YAML-flavoured
mini-language that exists only inside your binary.
Apache httpd.conf, Nginx config, Postgres postgresql.conf,
Kubernetes manifests, and Helm charts are all at various stations on
this slide.
# my-server.hcl
listen {
host = "127.0.0.1"
port = 9990
}
repo "alpha" { path = "/srv/alpha" }
repo "beta" { path = "/srv/beta" }Better: typed values, blocks, expressions, sometimes loops and conditionals. Terraform, HCL, Helm-with-templating sit here.
The catch: it's a second language, with a separate parser, a
separate type system, a separate set of tools. You debug it
separately. Its expressivity is bounded by what the DSL author chose
to expose. The first time you need something the DSL doesn't support
a per-tenant lookup against an external API at config time, a
computed cache key, a quick if hostname matches X you reach for
shell or templating around the DSL, and you're back at Level 1's
problems.
# pulumi-style: your config is a real program in a real language
import pulumi
import my_server
server = my_server.Server(
listen=my_server.Listen(host="127.0.0.1", port=9990),
repos=[
my_server.Repo("alpha", "/srv/alpha"),
my_server.Repo("beta", "/srv/beta"),
],
)Now config IS a program. Loops, conditionals, library calls, custom helpers, all available, in the same language as the implementation. AWS CDK, Pulumi, Bazel rule files, and most "infrastructure as code" tooling lives here.
The catch: the declarative readability is gone. The user is constructing objects, calling constructors, threading parameters. It reads like glue code, not like config. Operators bounce off, they wanted a config file, they got a program with import statements and type annotations.
import my_server (serve, host, port, superuser_token, repo)
import std.os (os_getenv)
main() {
serve {
host("127.0.0.1")
port(9990)
if os_getenv("STAGE") == "prod" {
host("0.0.0.0")
}
superuser_token(os_getenv("SUPER_TOKEN"))
repo("alpha", "/srv/alpha")
repo("beta", "/srv/beta")
}
}
The Aether builder keyword (see
closures-and-builder-dsl.md) gives
you the declarative aesthetic of Level 1, bare setter calls, no
explicit object construction, no parameter threading, on top of
full code underneath: control flow, library calls, env lookups,
arbitrary Aether. The library author absorbs the wiring; the operator
writes what looks like a config file but isn't.
This is the one step beyond config-as-code worth naming explicitly. Levels 1 and 2 give you readability without power. Level 3 gives you power without readability. Level 4 gives you both.
When config is just an .ae:
- Loops over inputs that aren't fixed at deploy time. Read a list
of repos from a manifest file, iterate, call
repo(...)for each. No schema work, no JSON-Pointer foreach extension. - Environment-aware branching.
if os_getenv("STAGE") == "prod" { ... }is just anif. No${env:...}mini-language. - Pre-flight setup in the same artifact. Mount checks, key
fetches,
mkdir -p, schema migrations all run in the same process, beforeserve { ... }blocks. - One artifact in git. The deployment IS the config. No drift between code and YAML.
- Same toolchain.
ae run,aetherc, the type checker, the LSP, and the test runner all operate on your config directly. Your YAML linter doesn't.
- The operator and the developer overlap. Self-hosted dev tooling, CI runners, internal services, fixtures, test harnesses.
- The library is "start the thing"-shaped: bind, configure, run.
- The number of config knobs is small enough that exhaustive YAML schemas would be overkill.
- Multi-tenant ops where operators don't write Aether. A managed service with hundreds of customer-facing config files needs a schema'd, language-agnostic surface.
- GitOps pipelines with external tooling. Argo CD, Flux, Kustomize all expect declarative manifests.
- Schemas that other tools consume. Kubernetes resources, OpenAPI specs, JSON Schema validators.
For these, build a thin YAML layer that constructs and calls the same opts struct your closure-DSL populates. The inner workhorse stays shared; only the outer entry point differs.
To expose your library's start surface as a closure-DSL, follow the standard three-layer split:
// 1. Opts construction, explicit API. The base everything else
// funnels into.
export opts_new() -> ptr {
return map.new()
}
// 2. Setters, take `_ctx: ptr` first, write into the map. Each
// setter is one knob.
export host(_ctx: ptr, addr: string) {
_e = map.put(_ctx, "host", addr)
}
export port(_ctx: ptr, n: int) {
_e = map.put(_ctx, "port", string.from_int(n))
}
// (Repeatable setter, collect into a list.)
export repo(_ctx: ptr, name: string, path: string) {
if map.has(_ctx, "repos") == 0 {
_e = map.put(_ctx, "repos", list.new())
}
repos, _ = map.get(_ctx, "repos")
list.add(repos, "${name}\t${path}")
}
// 3. Workhorse, reads opts, does the work. Single source of truth.
export serve_opts(opts: ptr) -> int {
// ... read keys, register, bind, listen, run ...
}
// 4. Closure-DSL entry, `builder` keyword wires the trailing block
// to populate `_builder`, then hands it to the workhorse.
builder serve(_ctx: ptr) -> int {
return serve_opts(_builder)
}
The user's three options stay 1:1 equivalent:
// (a) Closure-DSL block, the recommended surface.
my_lib.serve {
host("127.0.0.1")
port(9990)
}
// (b) Explicit opts API, for callers who want to construct opts
// once and reuse, or build them programmatically from another
// source.
opts = my_lib.opts_new()
my_lib.host(opts, "127.0.0.1")
my_lib.port(opts, 9990)
my_lib.serve_opts(opts)
// (c) CLI front-end (your own main.ae), argv → setters →
// serve_opts. Same workhorse; argv just becomes another
// opts-population strategy alongside the closure-DSL.
Adding a knob is one new setter + one new key read in the workhorse. The CLI, the closure-DSL, and the explicit API all gain the knob in lockstep, no risk of drift between "what the YAML supports" and "what the binary can do."
A few things to watch for when designing setters:
- Avoid libc symbol names. A setter named
bindwill silently shadow libc'sbind(2)at link time, and your listening socket won't attach. Same forlisten,accept,connect,open,read,write,socket,select. Pickhost,addr,subscribe,attach, etc. - Name setters for what they configure, not for the underlying
call.
port(n)reads better thanset_port(n);host(addr)reads better thanbind_to(addr). The closure-DSL aesthetic depends on this. - Repeatable knobs collect into a list, not a map.
repo("a", "/x"); repo("b", "/y")should accumulate, not overwrite. Use a list under a stable key. - Setters write, the workhorse reads. Don't do work inside setters. The order of calls in the block shouldn't matter (operators reorder for clarity); only the final state of the opts map should drive behaviour.
- Default in the workhorse, not the setter. A setter that wasn't
called shouldn't have planted a value. Read with
map.has+ fallback; that way every default lives in one place.
Config-IS-code only lands with operators if they can ask the compiler
"what does my script actually declare?" without reading generated C.
ae inspect <file.ae> prints that summary:
$ ae inspect deploy.ae
inspect: deploy.ae
artifact: executable (entry: main)
capabilities required (gated imports): fs net
(pass --with=<cap,...> to grant under --emit=lib)
imports (3):
std.string [stdlib]
std.fs [stdlib, capability: fs]
std.http [stdlib, capability: net]
functions (2):
greet(name: string) -> string
divmod(a: int, b: int) -> (int, int)
structs (1): Config
constants (1): DEFAULT_PORT
It reports only what this file declares (imports resolved and
classified as stdlib / contrib / local, with the --with= capability any
gated module needs; the artifact shape, executable entry point vs
library export surface; and the top-level functions, structs, constants,
and actors with signatures), declarations merged in from imported
modules are excluded. No code is generated. For a library
(exports (...)) it lists the exported ABI surface instead of an entry
point. Out of scope: full type dumps (that's the FFI aether_describe
metadata) and raw AST printing (that's aetherc --emit=ast).
closures-and-builder-dsl.md, the reference forbuilder, trailing blocks, and context injection.aether-embedded-in-host-applications.mdwhenae runisn't the deployment vehicle and you're embedding the script inside another binary.aether-dsl-as-a-rules-engine.mdthe same closure-DSL machinery applied to business rules rather than start-the-thing config.