Skip to content

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 ​

layerholdsmay importfrontend analogy
domaintypes, constants, pure logic, the ports (types describing the effects it needs)nothing but other domain filesstore / model
adaptersevery effect: fs, Docker, Dagger, glab, provider SDK calls; implement the ports#domainservices
commandsuse cases: the package's entry points; call domain, then adapters#domain, #adapters, #uipages / components
uiCLI 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'), never export *, 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 its public.ts), never a path inside another package.
  • Aliases are Node subpath imports ("imports" in package.json), not Vite aliases: Node, TypeScript and Vitest all understand them, so node src/x.ts works without a bundler. The Vue app in examples/hello is 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, not interface; mark data readonly. No enum (use an as const object and a union type). No class, this or inheritance: data are plain objects, created by functions.
  • A class is allowed only where a library forces one (an Error subclass for instanceof, 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 exhaustive switch.

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, not attr; directory, not dir; error, not err; options, not opts. Inside compound names too: packageDirectory, not pkgDir. The banned list is tools/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>, not Result<T>).
  • Acronyms are words, not abbreviations: url, api, json, id, cli, ui, otlp are 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 in test/*.e2e.test.ts and run only with DOTKIT_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:

kindexampleswhen it is in the way
invariantlayer directions, cross-package access only through public.ts, contract names, provider neutrality, secrets never in plain values or logs, tests and configs never loosenedstop; say so in the step report and the handoff. The decider changes the design, the convention or the contract (amendment); nobody writes an exception
ruleno classes, types.ts/constants.ts placement, named barrels, function declarations, two parameters, naming, fakes over mocks, anywrite 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
advicefile length, function length, complexity, nestingnothing 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:

ts
// oxlint-disable-next-line max-params -- String.replace imposes the (match, ...groups) signature
// convention-exception: no-class - Error subclass, the shell tests it with instanceof

Enforcement ​

ruletool
layer directions, no deep or ../ imports, cross-package via public APIoxlint no-restricted-imports, import/no-relative-parent-imports
no cycles, no default exports, imports first, no duplicatesoxlint import/*
import type, type over interface, no anyoxlint typescript/*
function declarations, two parameters maxoxlint func-style: declaration, max-params
no one-letter names, no abbreviationsoxlint 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 orderoxfmt sortImports
no enums/namespaces/parameter properties, import type requiredtsc erasableSyntaxOnly, verbatimModuleSyntax
types in types.ts, constants in constants.ts, no classes, pure named barrels, no unused barrel exports, kebab-case, no mockstools/conventions-check.mjs
unused files and dependenciesknip
exceptions never merge unseengreen-lane.sh: a diff adding one goes to a human
everything above, plus the plan's Architecture section, plus no contorted compliancethe reviewer (critic)

All of it runs in pnpm check, locally and in CI.

Released under the MIT License