Skip to content

ADR-010: Rule builder for fluent DSL-style configuration ​

Status: Accepted
Date: 2026-05-12

Context ​

Most rules are configured via defineRule() with an options object. This works well for simple configuration but becomes awkward for rules that need a multi-step, interdependent setup.

The layer rule (layer('target').is(Pure).allows('a', 'b')) is the primary example. It needs:

  1. A target to define the boundary
  2. A role to determine what kind of isolation applies
  3. A set of allowed exceptions

Expressing this as a flat options object loses the natural flow and makes invalid configurations possible (e.g., a layer without a role).

Decision ​

RuleBuilder is a second extension point alongside Rule and RuleFactory:

ts
export interface RuleBuilder {
  build(): Rule;
}

The CLI recognizes RuleBuilder instances in the rules array and calls .build() to produce the final Rule before registration:

ts
if (isRuleBuilder(ruleEntry)) {
  this.kernel.registerRule(ruleEntry.build());
}

The defineRuleBuilder() helper brands the builder so the CLI can distinguish it from plain objects.

Builder interface ​

The builder is free to expose any fluent or chained API. The only requirement is that build() returns a valid Rule. The layer rule uses a two-phase builder:

ts
layer('target').is(Pure).allows('a', 'b')
//     ↑              ↑           ↑
//   target         role       allowed

This prevents invalid configurations — a layer must have a role before it can be built.

Consequences ​

  • RuleBuilder is for rules that benefit from a DSL-like syntax. Most rules should still use defineRule() with options.
  • The builder is resolved at config time, not at runtime. The resulting Rule is what the kernel executes.
  • Third-party plugin authors can use defineRuleBuilder() if their rule has complex configuration needs.
  • The builder pattern is additive — it doesn't replace defineRule() or defineRuleSet().