Declaration-first composition doctrine
For @arki/[email protected] · source docs/composition-doctrine.md
How to structure an app built on @arki/dot and its features (@arki/dot/feature). These rules
are distilled from what drizzle and oRPC get right, proven on a full
production backend. When your code and this doc disagree, fix one of them —
don't fork silently.
The one-sentence version
Declare everything as inert const values; compose by typed folds from one
membership list; let every runtime artifact be a projection of the
declarations; point dependencies only downward; observe the world only in
boot() and request factories.
1. Declarations are inert (the drizzle-table rule)
Importing any module in the declaration layer must be observationally free: no env observation, no I/O, no connections, no throws that depend on the world, no global mutation. Pure computation at import is fine (deriving actions, building a handler from a static router); observation is not.
-
Mistakes are caught on the earliest step that can see them (the ladder):
- Types, when TypeScript can express the check: a duplicate key in a
compose fold (§4), a builder method called too early, a mount path
without a leading
/.tscreports every one at once, before any code runs. - A coded throw at import, when the check depends only on the source
but types cannot express it: a cron string that does not parse, a key
column that is nullable. It fails at the line of the mistake, in every
environment, every time. The error carries a
codeand aremediation;dot explainshows both when the import fails. - Boot, for anything that depends on the world: env, files, the network, the clock. Never at import.
- Types, when TypeScript can express the check: a duplicate key in a
compose fold (§4), a builder method called too early, a mount path
without a leading
-
Never move a check down the ladder. Many consumers never boot through Dot: a router from
composeRoutergoes straight to oRPC. A check deferred to boot would never run for them, and a loud mistake would become a silent one. -
Every type check keeps a runtime throw behind it. Plain JavaScript and casts skip the types; the coded throw is the backstop.
-
Env is observed at use, never at import —
@arki/env'sdefineEnvis lazy by design (theprocess.envsnapshot and validation fire on first property access). -
Adapter config that comes from env is passed as a thunk — prefer thunking the whole options object:
kv(() => ({ url: env.REDIS_URL })). Where an adapter's options carry declaration-time wiring that cannot defer (http'sbundles/featurestokens shape the plugin's needs), only the runtime fields thunk:http({ port: () => env.PORT, ... }). The vocabulary for this isLazy<T>/resolveLazyfrom@arki/ts. -
Client-facing declarations (contracts, enums, branded-id schemas) live in modules that never import server code, published as universal exports; server-only modules get
{ "types", "node" }-conditioned exports so a browser bundler cannot resolve them. Bewareexport { type X } from 'm'— underverbatimModuleSyntaxit keeps a RUNTIME import ofm; use the fully-erased statement formexport type { X } from 'm'in client-safe modules. Enforce with a browser-purity test: esbuild-bundle every universal export withplatform: 'browser'and fail on node builtins or server-module fragments. -
No module-scope singletons that touch the world: no
export const db = createDb(...), no top-levelawait app.boot(). If a library seam truly needs a shared instance, wrap it —lazyObject(() => createDb(...))for object handles (consumers keep plain value semantics),once(() => app.boot())for anything else, both from@arki/ts.lazyObjectisonceplus proxy transparency: reads bind methods to the real instance, writes/deletes/instanceofdelegate to it, and construction happens on first property access.
Executable form: the app graph must render from a bare checkout. Give
every app an import-purity test built on expectImportPure from
@arki/ts/testing: it spawns a subprocess with a scrubbed environment
(keeps PATH/HOME; sets NODE_ENV=development so env validation is NOT
skipped), imports the given modules, and throws unless the exit is clean.
Any module-scope side effect anywhere in the import chain fails it
immediately. dot explain working with zero environment is the same
property, interactively.
2. Types derive from values (the $inferSelect rule)
Never hand-write a type that restates a value: type AppRouter = typeof appRouter, repo types via ReturnType<typeof createOrdersRepository>,
ComposedRepos<typeof features>. A hand-written mirror interface is drift
waiting to happen.
The one sanctioned exception: cycle-cutting pins. Where deriving a type directly would close a circular definition (e.g. a request-context type that contains the repos while routers are typed with that context), hand-list the type and pin it with a type test one module away:
import { expectTypeOf } from 'vitest';
// Stand-in for the app's fold: one repository per feature.
type ComposedRepos<TFeatures extends Record<string, { readonly repo: object }>> = {
[K in keyof TFeatures]: TFeatures[K]['repo'];
};
const features = { orders: { repo: { find: (id: string) => ({ id }) } } };
// The hand-listed type that cuts the cycle.
type AppRepo = { orders: { find: (id: string) => { id: string } } };
expectTypeOf<ComposedRepos<typeof features>>().toEqualTypeOf<AppRepo>();The pin makes the hand list compiler-enforced instead of trusted.
3. One declaration, many projections
A feature is declared once (feature()) and everything else is a
projection of it: the plugin .features() mounts, the slice token
(tokenOf), manifest actions (dot explain attribution), the composed
router (client types), the repo record. If adding a capability requires editing a second
place, the design is wrong — extend the declaration and derive.
Boot-time capabilities ride slice contracts — structural
{ key, actions, resolve } wrappers exported by the adapter that consumes
them (jobs() from @arki/queue/dot, schedules() from
@arki/schedule/dot, endpoints() from @arki/http). Dependencies follow
use; the slice set stays open; @arki/dot/feature imports no adapter.
4. One membership list, typed folds
src/features.ts is THE list. Everything derives from the tuple:
.features(features), composeRouter(features),
composeRepos(features, db), composeSchema(...). Driving adapters
(http, schedule, queueWorker) are bound to the mounted list by
.serve() — they never take it as an argument. Folds are typed
identities: runtime merge + exact intersection type + duplicate-key
rejection, as a compile error first and a coded throw at module load behind
it. There is never a second list to
forget.
The app spine: .infra() → .features() → .entrypoints()
The same three steps under their final names (RFC
docs/rfcs/2026-10-15-dot-feature-builder.md). .use() folds into
.infra() and .serve() becomes .entrypoints(); both take a list in boot
order, checked like .useAll():
import { defineApp, plugin } from '@arki/dot';
import { defineFeature, driver, sliceOf } from '@arki/dot/feature';
import { rpc } from '@arki/dot/orpc';
import { isFailure } from '@arki/failure';
// Stand-ins: in a real app these come from @arki/log, @arki/db and @arki/http.
const logger = plugin({ name: 'log', provides: { log: () => ({ info: (_message: string) => {} }) } });
const database = plugin({ name: 'database', provides: { db: () => ({ query: async (_sql: string) => [] }) } });
const orders = defineFeature('orders').entrypoints(rpc({}, {}, {}));
const features = [orders] as const;
const gateway = driver(mounted =>
plugin({
name: 'gateway',
boot: ctx => {
for (const mountedFeature of mounted) {
if (sliceOf(ctx, mountedFeature) === undefined) throw new Error('gateway sees no slice');
}
},
}),
);
const shop = await defineApp('shop')
.infra(logger, database) // what the app runs on; log first
.features(features) // defineFeature() values (feature() during the migration)
.entrypoints(gateway) // what calls into the features
.boot();
if (isFailure(shop)) throw new Error(shop.code);
const order = shop.manifest.plugins.map(p => p.name).join(' → ');
if (order !== 'log → database → orders → gateway') throw new Error(`unexpected boot order ${order}`);
await shop.dispose();A feature is a chain whose steps are its folders:
import type { DepsOf } from '@arki/dot/feature';
import { defineApp, plugin } from '@arki/dot';
import { defineFeature } from '@arki/dot/feature';
import { rpc } from '@arki/dot/orpc';
import { isFailure } from '@arki/failure';
// Stand-ins for the units the feature uses.
const billing = plugin({
name: 'billing',
provides: { entitlements: () => ({ capFor: (userId: string) => (userId === 'u1' ? 2 : 1) }) },
});
const store = plugin({
name: 'store',
provides: { store: () => ({ itemsFor: async (userId: string) => [`${userId}:welcome`, `${userId}:tips`] }) },
});
// features/daily-digest/feature.ts — one file; usecase and procedure files take `Deps` as a type
const dailyDigestDeps = defineFeature('daily-digest')
.needs({ plans: billing.provides.entitlements }) // other units, by handle
.infra(store); // infra/ — private plugins
export type Deps = DepsOf<typeof dailyDigestDeps>; // what a usecase may ask for
// usecases/ — one file per usecase
const preview =
({ store, plans }: Pick<Deps, 'store' | 'plans'>) =>
async (userId: string) =>
(await store.itemsFor(userId)).slice(0, plans.capFor(userId));
const dailyDigestLogic = dailyDigestDeps.usecases({ preview });
export const dailyDigest = dailyDigestLogic
.provides({ digests: ({ usecases }) => ({ previewFor: usecases.preview }) })
.entrypoints(rpc({}, {}, {})); // entrypoints/ — the contract is empty here
const shop = await defineApp('shop').infra(billing).features([dailyDigest]).boot();
if (isFailure(shop)) throw new Error(shop.code);
const items = await shop.services.digests.previewFor('u1');
if (isFailure(items)) throw new Error(items.code);
if (items.length !== 2) throw new Error(`the plan allows two items, got ${items.length}`);
await shop.dispose();Rules the types enforce (each is also a coded runtime check):
- Steps run in order; a step out of order does not compile.
.usecases({ name: factory })takes one factory per usecase; each gets{ ...needs, ...infra }(and mayPicka subset); asking for a dep the feature lacks is a compile error..providesfactories and entrypoints get the same plususecases, built once per boot.rpc(contract, implementer, factories)takes one factory per procedure (the whole tree in oneentrypoints/procedures.ts), shaped like the contract; an extra, missing or wrong procedure does not compile.- Feature infra is private:
.infra(store)publishesdaily-digest.storeunder the unitdaily-digest/store; a sibling infra need follows the qualified key. Other features reach it only through.provides. - A need names a handle, and the types read its identity — the
providerandkeyliterals the handle carries — not only its service type. A child's need is remapped onto its parent only when it names the parent's own infra handle; a same-shaped global handle stays a global need (ARKI_DOT_E012at boot otherwise). An infra plugin that binds a need to a handle must bind the feature's handle (ARKI_DOT_E026at.infra()). Residual the types cannot see: two plugins with the same name, publish key and service type are one identity to the compiler; the runtime still tells them apart by object. APlugin<N, P>annotation erases both the shape and the name, and the checks fall back to wire keys for that plugin. session,headers,servicesandusecasesare reserved aliases; an infra key equal to a need alias, and an entrypoint key equal to a provided key, are compile errors (ARKI_DOT_E027at runtime).feature.override({ store: fake })swaps an infra service before compilation: one provider per key, entrypoints kept.- Mount keys are written once, in the app's RPC contract:
routerFor(rpcContract, features)finds each feature by itsrpcentrypoint's contract; an unmatched key or contract is a compile error. dot explaindrawsdaily-digest → daily-digest/store → database.
App .infra() is shared (every feature names it by handle, it owns process
resources); feature .infra() is private (it wraps the app's handles or owns
a client nobody else shares — never a second Postgres pool).
Three ways to have two of something: rename(plugin, …) for a second plugin
instance (keys renamed, same declaration); nesting for a feature that belongs to
another (digest.compile, scoped keys); feature.instance('eu', rebind) for the
same feature twice (reports#eu, qualified keys, needs rebound to another
provider, children re-derived). An instance is not a child: it is public,
top-level, and owns its own infra units.
The app spine is a hexagon (object form — removed when the migration to defineFeature ends)
app.ts wires the units in three steps, and the builder types enforce the
order — a unit in the wrong step does not compile:
import { defineApp, plugin } from '@arki/dot';
import { driver, feature } from '@arki/dot/feature';
import { isFailure } from '@arki/failure';
// Stand-ins for adapters from @arki/db, @arki/queue and @arki/http.
const db = plugin({ name: 'db', provides: { db: () => ({ query: async (_sql: string) => [] }) } });
const queue = plugin({ name: 'queue', provides: { queue: () => ({ enqueue: async (_job: string) => {} }) } });
const http = driver(() => plugin({ name: 'http' }));
const queueWorker = driver(() => plugin({ name: 'queue-worker' }));
const features = [feature('orders', { needs: { db: db.provides.db }, provides: { orders: () => 'orders' } })] as const;
const app = await defineApp('shop')
.use(db) // driven adapters — what features need
.use(queue) // (a queue CLIENT features enqueue with)
.features(features) // the domain core — feature() values, one list
.serve(http) // driving adapters — what calls the features
.serve(queueWorker)
.boot();
if (isFailure(app)) throw new Error(app.code);
const order = app.manifest.plugins.map(p => p.name).join(' → ');
if (order !== 'db → queue → orders → http → queue-worker') throw new Error(`unexpected boot order ${order}`);
await app.dispose();Infrastructure never depends on the domain: nothing in .use() can need a
feature service, and .use() is closed once .features() is called.
Adapter authors pick the side explicitly: driver(features => plugin(...))
is accepted only by .serve(); driven(features => plugin(...)) — a driven
adapter that reads the features' static declarations, like the journal — only
by .use().
5. Dependencies point one way (the drizzle-relations rule)
Layer the app with leaves at the bottom; imports may only point down:
app spine (app.ts, gateway.ts, api.ts, features.ts, schema.ts, relations.ts)
↓
features (feature.ts, router.ts, repository.ts, jobs, relations fragment)
↓
core infra (procedure builders, env, auth factory, logging) + adapters
↓
schema leaves (tables.ts — import nothing above)- No lateral feature→feature imports — with one door. Cross-feature table
references go through the composed relations builder (typed by the composed
schema — no import needed). Cross-feature behavior goes through provided
services, needs-checked by the graph. The door: any module of a consumer
feature (its
feature.ts, or a procedure) may import a provider'sfeature.tsto name a capability by handle —needs: { access: billing.provides.entitlements }. That import carries a handle typed from billing'sprovidesfactory (the type is written once, by the provider) and stamped withprovider: 'billing', and nothing else; a consumer reaching into another feature's services, repository, or internals is still a lateral import. Mount order stays the boot order: the provider must be listed before the consumer — a compile error, andARKI_DOT_E020atconfigure()for code that skips types. The kernel checks every plugin's needs against every plugin's static provides, so the same rule holds for adapters, not only features. - Capabilities never form a ring. Two feature modules naming each
other's handles fail as
TS7022on bothfeature()consts (and an ESM TDZReferenceErrorat runtime); erased graphs failconfigure()withARKI_DOT_E019, which prints the ring (through any plugins). Break it by moving the shared piece into a third feature both need, or by reading throughdb.queryinstead of a handle. A feature's own router never names its own handle (alsoTS7022): own capabilities are called by import. - Procedures read capabilities through the request context's opaque
bag. The context type cannot name the merged services (it loops through
the routers), so it carries a
ServicesBag; the gateway feature spreadsprovidesOf(features)into its needs and passesservicesOf(ctx, features)tocreateContext, and each procedure narrows the bag throughresolveNeedswith the handles it uses, declared inline (needs({ entitlements: billing.provides.entitlements })). Keep a feature'sneedsrecord in itsfeature.ts; move it to aneeds.tsleaf only when that feature's OWN procedures must share the exact record — importing it fromfeature.tswould close feature.ts → router → procedure → feature.ts (a TDZ cycle). One DI system: the bag holds the very instancesapp.servicespublishes. - Relations are a second pass over the composed schema: feature-owned
fragments, one
defineRelationscall, never a god barrel. - Nothing points back up — where "up" means a cycle. The rule is
ACYCLICITY, not rigid layering. The composed request context is the app's
db.queryanalog: routers legitimately read ANY feature's repo through it (cross-feature reads are domain reality — a story lives in a universe), so the context type is a spine artifact that features reference through the procedure builders. That reference is legal because it terminates in repository LEAF modules, never in routers — the module graph stays acyclic. Do NOT fragment the context per feature to "fix" this: narrow per-feature contexts force lateral type imports between features, which is the real violation.
6. Effects live in provides factories, boot() and request factories only
Three lifetimes, three homes:
| Lifetime | Home | Examples |
|---|---|---|
| compile-time | ES modules, composed folds | schema, relations, routers, repo factories, context TYPE |
| boot-time | plugins / feature() provides factories, side-effect boot + slices | db handle, auth instance, event store, route bundles |
| request-time | the gateway's context factory | session lookup, per-request context value |
"If it doesn't need boot, it doesn't need a plugin" — static data rides modules and folds, not the graph. The gateway is the one legitimate cross-feature aggregation point (requests → contexts).
7. oRPC lessons (hard-won, don't relearn)
- Static router merge, always. The app router must be a compile-time fold so client inference is exact. Boot-collected routers don't work: the type still needs a static list, and testing the type would require booting the app.
- Context is contravariant — narrow demands run under wide supplies.
A procedure typed against
{ db, user }runs happily under the full app context; useful when extracting a single router to a worker/edge host. But do NOT fragment the app context per feature as a rule: routers consuming any repo through the composed context is the intended shape (see §5), and per-feature contexts trade one hand-pinned spine type for lateral type imports everywhere. - "Type instantiation is excessively deep" (TS7056): fold middlewares, keep procedure chains shallow, split router barrels. A known trap in large oRPC apps.
- RPC handlers behind mounts: Hono's
.mount()rewrites the request URL (strips the prefix) by default, which silently breaks prefix-matching handlers — rpc mounts must receive the ORIGINAL request (replaceRequest: false;@arki/http's engine does this for rpc bindings). - The oRPC wire envelope is
{ json, meta? }— remember it when probing endpoints by hand.
8. TypeScript mechanics that recur in this architecture
- Erased bounds over heterogeneous declarations use the contravariant
top:
(database: never) => unknownfor repo factories,(services: never) => ...for erased boot fns andprovidesfactories. - Contextually typing a record of factories needs a reverse-mapped
parameter, not a constraint:
provides?: { [K in keyof TProvides]: (s: CtxOf<TNeeds>) => MaybePromise<TProvides[K]> }types each factory's parameter fromneedsand infersTProvidesfrom the returns; a constraintTProvides extends Record<string, (s) => unknown>leaves the parameter untyped (TS7031). - Indexed access does not distribute.
T[number] extends X<infer P>infers ONEPacross the whole union; wrap in a naked-param alias (type Of<T> = T extends X<infer P> ? P : never) to distribute per member. UnionToIntersectiondistributes into a single member's union type (Db | TransactionbecomesDb & Transaction). To intersect across items while keeping each item's union intact, fold through function-parameter inference instead.- Collector token bounds should be
Token<object, string>with needs values = the token's OWN payload — all-optional "well-known key" shapes fail the weak-type check the moment payloads differ per feature. - A
neverarm in a provides-type conditional poisons the whole intersection — map empty/void cases to{}explicitly. - Witnesses resolve by key string, not object identity (
token(),provide()); derivation helpers may re-create them freely.
9. Enforcement (what CI/tests actually check)
| Property | Mechanism |
|---|---|
| Import purity | scrubbed-env subprocess imports the app module |
| Fold ↔ contract exactness | derivation tests: per-key toEqualTypeOf, ComposedRepos pin |
| Duplicate membership | compile error in compose folds; coded throw at module load behind it |
| Graph shape | dot explain from a bare checkout; feature-attributed actions |
| Boot behavior | package suites + a live boot smoke (a real round-trip, a fail-closed guard, clean dispose) |