Skip to content

Module design

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, and every later change to it is four more. So the shape is settled before the first line: decompose the concerns, weigh the abstractions against the alternatives, name them, then implement.

  • 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 using and DisposableStack replaces any hand-rolled dispose helper.
  • Write a module so a reader cannot tell it from its committed neighbours. Correctness does not excuse a file that reads as if a second author wrote it, and the rule covers its tests, its comments and its messages too.

Before hand-over, read the neighbours of the module and check that the new file matches their shape.

Concerns never leak coupling into a neighbour. 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. A seam carrying a consumer’s name commits the layer to that consumer silently, and the commitment surfaces only when a second consumer arrives.
  • 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. Dependencies states why.

Weak, because the name commits the sandbox to one subscriber:

export type SandboxHooks = {
onTelemetry?(event: SandboxExecutionSettledEvent): void
}

Strong, because the name states what the sandbox emits and leaves out what anyone does with it:

export type SandboxHooks = {
onExecutionSettled?(event: SandboxExecutionSettledEvent): void
}

onExecutionSettled fires once when a run settles, with the duration and whether the run ended in success or error. A logger and a metrics exporter both subscribe to it.

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 Omit or Pick. 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 neighbour 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, and Testing describes the single environment-variable seam this repository keeps.

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
}

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
  • Put the type of an error on a code property and rehydrate the error from that code on the receiving side. capnweb flattens Error subclasses to plain errors, so instanceof does 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 assertXXX function whose name states the condition, such as assertNoNodeSnapshot. 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 does that without a try around the emit, which would swallow the throw along with it.
  • 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, so an oversized timeout becomes no timeout at all.