Module and API design
Where a decision lives decides what it costs later. A concern solved in the module that owns it is one edit; the same concern threaded through four modules that had no business knowing about it is four edits, forever, made by people who will not understand why they are making them. So the shape is settled before the first line: decompose the concerns, weigh the abstractions against the alternatives, name them, then implement. A brute-force solution that works is not yet engineering.
Craft bar
Section titled “Craft bar”- Keep every module cohesive, with one responsibility. The names carry the design; see Naming.
- Avoid premature abstraction and speculative generality. A closure and a variable beat clever bookkeeping that exists for a caller nobody has written.
- Prefer the platform idiom where the platform owns the concern. Explicit resource management with
usingandDisposableStackreplaces any hand-rolled dispose helper, and it replaces the bug that helper will eventually have. - Write a module so it cannot be told apart from its committed neighbors. A module that reads as if a second author wrote it is wrong even when it is correct, and the same goes for its tests, its comments and its messages.
Before hand-over, read the neighbors of the module and check that the new file matches their shape. That reading takes time, and the time is part of writing the module.
Boundaries and seams
Section titled “Boundaries and seams”Concerns never leak coupling into a neighbor. The owning module absorbs the whole detail instead of passing a variable through modules that then have to be understood before anyone can touch them.
- Solve a problem at the boundary that owns it, once you have found out which boundary that is. A transport limitation is the transport’s problem, so the fix belongs in the transport or in its upstream, never in a layer of conversions wrapped around it here. See Dependencies.
- Centralize a cross-cutting format concern at its port. The dispatch envelope is encoded and decoded in one place, and handlers speak plain values.
- Name an extension seam after the fact the producer emits, never after a subscriber’s purpose. The sandbox exposes
onExecutionSettled, notonTelemetry. Seams carrying a consumer’s name commit the layer to that consumer silently, and the commitment surfaces only when a second consumer shows up. - Keep hooks synchronous and fire-and-forget. A hook can never fail the work it observes, so a throwing hook is caught at the call site and the execution carries on.
- Keep a consumer-specific protocol in the single layer that owns it. Observability enters at the pool and server layers and nowhere below them.
- Give an inherited duty its own module, designed from the requirement itself and written in this repository’s style. Read the upstream source by all means. But its code may not travel back here, and neither may a test pinned to its internals. Decision 0005 records why.
Wire contracts
Section titled “Wire contracts”A type that crosses a process, an RPC boundary or a package boundary is a contract, and a contract is written out in full.
- Give every wire contract its own module and state its fields explicitly.
- Never derive a wire contract from an internal type with
OmitorPick. A derived contract widens the day the internal type gains a field, and it widens silently, so the change ships without anyone reviewing what now travels over the wire. - Extract the pure part when a neighbor needs only a type. The import-graph boundary that follows from it is in Code style.
- Expose an extension point that tests need as a real option on the public options type. An environment variable is not a seam; the one environment-variable seam in the repository is described in Testing.
Weak, because every internal field addition escapes to the wire:
export type ExecuteRequestWire = Omit<InternalExecuteRequest, 'dispatch'>Strong, because the wire shape changes only when someone edits the wire shape:
export type ExecuteRequestWire = { code: string timeout: number memoryLimit: number}Errors and invariants
Section titled “Errors and invariants”The executor never throws for user-code failures. Generated code that throws, times out, exhausts memory or fails to resolve an import produces a result envelope carrying result, error and logs. But an infrastructure fault raises a typed error, because a caller does two different things with the two answers: one tells them about the program they submitted, the other tells them about the machine they submitted it to.
| Fault | Shape |
|---|---|
| User code throws, times out, or exhausts memory | Envelope with the error field set |
| Infrastructure fault such as a broken connection | ExecutorError with a code |
| Shutdown while work is in flight | Recorded fact reported under its own code |
| Call that breaches a stated precondition | Throws loudly at the call site |
- Carry the type of an error on a
codeproperty and rehydrate the error from that code on the receiving side. capnweb flattensErrorsubclasses to plain errors, soinstanceofdoes not survive a process or RPC boundary and a taxonomy built on it collapses at exactly the boundary it was needed for. - Express a complex invariant as an
assertXXXfunction whose name states the condition, such asassertNoNodeSnapshot. It fails loud at the boundary where the assumption enters, and it reads as documentation of what must hold. Trivial checks stay inline. - Record a failure once and let the first failure win. Later failure calls on the same unit of work are idempotent and change nothing.
- Do the bookkeeping before the announcement. A listener that throws then cannot corrupt anything, and the ordering achieves that without a
tryaround the emit that would hide the throw as well. - Treat a shutdown as a recorded fact, not a crash. It gets its own error code, so a caller can tell a planned stop from a dead worker.
- Clamp every timer arithmetic at the platform maximum before it reaches
setTimeout. Node sets the delay to 1 millisecond when it is larger than 2147483647, which turns an oversized timeout into no timeout at all.