I went into the module system design with two convictions that turned out to be wrong. Not wrong as in “bad ideas” — wrong as in “solving the problem at the wrong layer.”

The first was withModuleScope() on VariableResolver. Module parameters need highest priority when resolving ${var.*} references inside a module’s content. I wanted that priority to be explicit — a named method on the resolver, not buried in a chain order. The decision review disagreed: module scope IS a VariableSource. Map<String, String>::get already satisfies the @FunctionalInterface. Chain order IS the priority mechanism — that’s the defining semantic of VariableSource.chain(). Calling it “implicit” was like calling middleware order implicit. It’s the design.

The second was validate() that throws. Parameter validation failures are always fatal — a module can’t expand with invalid parameters. So I designed the API around the throwing case: call validate(), get either silence or an exception full of violations. The review pointed out that the composable primitive is the return value, not the exception. Testing is cleaner — asserting on a returned list vs catching an exception to inspect its violations list. The throwing method is a convenience built on top, not the foundation.

Both revisions cost nothing to apply. withModuleScope() became VariableSource.chain(moduleParams::get, existingSource) — one line, no new field on the resolver, no split resolution model. validate() became a List<ParameterViolation> return with validateOrThrow() alongside it for the common case.

What’s interesting isn’t that I was wrong — it’s that both mistakes came from the same instinct: making the important thing loud. Explicit method for priority. Exception for fatality. But “loud” added complexity without adding information. The chain order already documents priority. The list return already communicates violations. Both designs were trying to signal something the existing abstractions already expressed.

The module system itself is a generic extraction from desiredstate’s domain-coupled implementation. YamlModule holds opaque Map<String, Map<String, Object>> sections instead of YamlNode, YamlRule, YamlInvariant. ModuleExpander handles alias prefixing, parameter resolution, and import merging — structural operations on the envelope. The consumer casts sections to domain types at the boundary, one objectMapper.convertValue() call per section.

ParameterValidator collects all violations — not fail-fast. Type-aware constraints: minLength on a LIST counts elements, not characters. Pattern matching on a LIST applies per element. This is a deliberate JSON Schema subset — just enough for module parameters, without importing a validation library into a zero-dep module.

The import structural validation was another review catch — the existing desiredstate code checks for null aliases, dots in aliases, and duplicate aliases, but my spec didn’t mention any of them. Silent corruption from a null alias producing "null.cpu-check" as a key, or a dotted alias breaking variable resolution — the kind of bugs that pass all tests and surface three repos downstream.

Six decisions went in. Two came back revised. Both revisions made the design simpler. That’s the outcome you want from a review — not “you were wrong” but “here’s the same thing with less.”


<
Previous Post
When the orchestrator lies about what it did
>
Next Post
YAML All the Way Down