Code conventions
One set of rules for every package, written so that someone with little object-oriented and little advanced functional-programming experience can read any file. Parallel agents follow the same page; the tools below make most of it impossible to get wrong.
The idea: pure core, thin shell
Most code is pure functions over plain data: given the same input, the same output, no files, no network, no Docker. Effects (files, Docker, Dagger, GitLab, cloud SDKs, OpenTofu) live in one thin layer that calls the pure code. Pure code is trivial to test and to reason about; the shell is small enough to read in one sitting.
The Dagger SDK fits this: we embed it as a library (connection() + dag), and its builders are immutable (container.withExec() returns a new object). We do not write Dagger Functions (the @object()/@func() decorator classes); those are for publishing modules to Daggerverse.
Real provider first, the emulator is a test adapter
We build for real clouds; MiniStack exists so we can test that code, not so the code can suit MiniStack (contract §0 invariant 6). Every code path is the one that runs against the real provider: the real SDK with SigV4, the real registry login, the real Container.publish. When a test needs something else (an endpoint override, test credentials, a reachability workaround, a different push path), write an adapter behind the same port and put it in the provider package's emulator/ folders, selected by the environment's emulator: key. Domain code never branches on "is this the emulator", and no real-path code is shaped around an emulator limitation; a limitation is recorded, not accommodated.
Package layout
Every package is created with pnpm new:package <name> (add --ui for a presentation layer), so every package starts identical. A cloud provider is an adapter package: pnpm new:package aws --provider creates providers/aws/ as @dotkit/provider-aws with only domain and adapters; it owns that cloud's SDK dependencies, and no other package imports them (contract §4).
packages/<name>/
package.json exports -> src/public.ts ; imports -> #domain, #adapters, #commands (#ui)
tsconfig.json extends ../../tsconfig.base.json
vitest.config.ts e2e tests only with DOTKIT_E2E=1
src/
public.ts the package's API: re-exports only
domain/ types.ts, constants.ts, <concept>.ts, <concept>.test.ts, index.ts
adapters/ <effect>.ts, index.ts
commands/ <use-case>.ts, index.ts
ui/ (cli only) <view>.ts, index.ts
test/ *.e2e.test.ts (Docker, emulator)Layers
| layer | holds | may import | frontend analogy |
|---|---|---|---|
domain | types, constants, pure logic, the ports (types describing the effects it needs) | nothing but other domain files | store / model |
adapters | every effect: fs, Docker, Dagger, glab, provider SDK calls; implement the ports | #domain | services |
commands | use cases: the package's entry points; call domain, then adapters | #domain, #adapters, #ui | pages / components |
ui | CLI presentation (prompts, spinners, colours) | #domain (types) | view |
domain never imports node:fs, node:child_process, networking, @dagger.io/dagger or an SDK. Adapters turn library exceptions into values; commands are the only place that throws or exits.
providers/<p>/*.ts are adapter files by nature (they exist to call a cloud); examples/ and spikes/ follow the general lint rules but not the layering.
Files
types.ts: every exported type of the folder.constants.ts: every exported constant of the folder (UPPER_CASE,as const).index.ts: the folder's barrel, named re-exports only (export { a } from './a.ts'), neverexport *, never logic. A barrel exports only what something outside the folder imports.public.ts: the package's published API, named re-exports from the layer barrels only.- File names are kebab-case; one concept per file, named after it. No
utils.ts/helpers.ts.
Imports
- Inside a package: the nearest barrel through its alias,
import { parseNodes } from '#domain'; a sibling in the same folder:./graph.ts. Never../, never#domain/graph. - Across packages:
import { run } from '@dotkit/runner'(resolves to itspublic.ts), never a path inside another package. - Aliases are Node subpath imports (
"imports"inpackage.json), not Vite aliases: Node, TypeScript and Vitest all understand them, sonode src/x.tsworks without a bundler. The Vue app inexamples/hellois the one place a Vite@/alias is fine. - Type-only imports use
import type. Order (automatic, oxfmt):node:built-ins, npm packages,@dotkit/*,#layer, siblings.
Types and data
type, notinterface; mark datareadonly. Noenum(use anas constobject and a union type). Noclass,thisor inheritance: data are plain objects, created by functions.- A class is allowed only where a library forces one (an
Errorsubclass forinstanceof, a Dagger Function), and it carries an exception comment saying so. - Errors at a boundary are values:
{ ok: true, value } | { ok: false, error }, handled with an exhaustiveswitch.
Functions
- A named function is a function declaration:
function parseNodes(text) { ... }. It reads top to bottom, is hoisted, and shows its name in stack traces. - An arrow function only where it genuinely fits: an inline callback (
nodes.map((node) => node.id)), a function stored as data in an object (a port implementation{ read: (path) => ... }), or a variable typed by a function type (const handler: RequestHandler = (request) => ...), which a declaration cannot express. - At most two parameters: one positional value, then one options object. More than one value goes in the object; the positional first argument is there for readability (
render(graph, { environment })). Callbacks whose signature a library imposes are the exception. - No monad libraries (fp-ts, Effect), no point-free chains, no currying for its own sake. An array method when it reads better than a loop; a loop when it reads better than a chain.
Naming
- Full words:
attributes, notattr;directory, notdir;error, noterr;options, notopts. Inside compound names too:packageDirectory, notpkgDir. The banned list istools/abbreviations.json. - No one-letter names, anywhere: not in a two-line arrow function, not as a loop index, not as a generic parameter (
Result<Value>, notResult<T>). - Acronyms are words, not abbreviations:
url,api,json,id,cli,ui,otlpare fine. - Names from outside are kept as they are: a library's property (
process.env), a contract name (secret.ref), an HTTP header.
Size (advice, not a gate)
Aim for files under 300 lines, functions under 60, complexity under 10, nesting under 3. The linter warns; the reviewer mentions it; nothing fails on it.
Tests
- Unit tests sit next to the code (
graph.test.ts); e2e tests live intest/*.e2e.test.tsand run only withDOTKIT_E2E=1. - Fakes over mocks: an adapter is described by a port type in
domain/types.ts, so a test passes a plain object implementing it. Module mocking (vi.mock) only when nothing else can work, with an exception comment giving the reason.
When a rule is in the way
Rules exist to make the code clearer. When following one would make a particular piece of code worse, the code wins and the rule records why. What must never happen is code reshaped only to pass a check: unrelated values bundled into an object to get under the parameter limit, a cohesive function split to get under a size warning, a name bent to slip past the abbreviation list, a type moved behind an alias trick, an extra layer invented to satisfy an import rule. That kind of compliance is worse than the violation, and the reviewer treats it as a finding.
Three kinds of rules, three ways to handle friction:
| kind | examples | when it is in the way |
|---|---|---|
| invariant | layer directions, cross-package access only through public.ts, contract names, provider neutrality, secrets never in plain values or logs, tests and configs never loosened | stop; say so in the step report and the handoff. The decider changes the design, the convention or the contract (amendment); nobody writes an exception |
| rule | no classes, types.ts/constants.ts placement, named barrels, function declarations, two parameters, naming, fakes over mocks, any | write the natural code with an exception comment giving the concrete reason; list it in the step report under Exceptions. The MR is never merged automatically: the decider reads each reason |
| advice | file length, function length, complexity, nesting | nothing to justify; the linter warns, the reviewer may suggest a split when it would really help |
A rule that keeps needing exceptions is a rule to change, not to keep working around: the retro counts exceptions per rule, and three justified ones across issues bring a proposal to the decider, who owns the conventions page and the lint configuration.
Exceptions
Every rule has a visible escape hatch with a mandatory reason; the reviewer reads each one:
// oxlint-disable-next-line max-params -- String.replace imposes the (match, ...groups) signature
// convention-exception: no-class - Error subclass, the shell tests it with instanceofEnforcement
| rule | tool |
|---|---|
layer directions, no deep or ../ imports, cross-package via public API | oxlint no-restricted-imports, import/no-relative-parent-imports |
| no cycles, no default exports, imports first, no duplicates | oxlint import/* |
import type, type over interface, no any | oxlint typescript/* |
| function declarations, two parameters max | oxlint func-style: declaration, max-params |
| no one-letter names, no abbreviations | oxlint id-length, id-denylist; compound names in conventions-check (one list, tools/abbreviations.json) |
| size and complexity (warnings) | oxlint max-lines, max-lines-per-function, complexity, max-depth |
| import order | oxfmt sortImports |
no enums/namespaces/parameter properties, import type required | tsc erasableSyntaxOnly, verbatimModuleSyntax |
types in types.ts, constants in constants.ts, no classes, pure named barrels, no unused barrel exports, kebab-case, no mocks | tools/conventions-check.mjs |
| unused files and dependencies | knip |
| exceptions never merge unseen | green-lane.sh: a diff adding one goes to a human |
| everything above, plus the plan's Architecture section, plus no contorted compliance | the reviewer (critic) |
All of it runs in pnpm check, locally and in CI.

