ADR-001: Multi-Language Architecture¶
Status¶
- Proposed by: Ryan on 2026-01-07
- Accepted on: 2026-01-07
Context¶
holoconf needs to support multiple programming languages (Python, JavaScript initially; Go, Java, Rust, C later). We need an architecture that:
- Ensures consistent behavior across all languages
- Scales to 6+ languages without re-architecture
- Supports both x86_64 and aarch64 platforms
Alternatives Considered¶
Alternative 1: Schema-First (JSON Schema + codegen)¶
Generate language-specific code from a schema definition.
- Pros: Strong typing, schema serves as documentation
- Cons: Cannot express dynamic resolver behavior, limited runtime flexibility
- Rejected: Resolvers are inherently runtime/dynamic
Alternative 2: Pure Specification (independent implementations)¶
Define a spec and implement it independently in each language.
- Pros: Language-idiomatic APIs, no FFI complexity
- Cons: N implementations to maintain, drift risk, duplicated effort scales with languages
- Rejected: Does not scale to 6+ languages
Alternative 3: Hybrid Specification (spec + optional shared components)¶
Define a spec with optional shared components that languages can adopt.
- Pros: Flexibility, can start simple
- Cons: Still requires reimplementation when adding languages
- Rejected: Re-architecture cliff when adding language #3
Decision¶
Rust Core with Language Bindings
Implement core configuration logic in Rust with FFI bindings for each target language:
- Rust Core (holoconf-core): YAML/JSON parsing, hierarchical merging, interpolation syntax parsing, resolver registry & dispatch
- Language Bindings: PyO3 (Python), NAPI-RS (JavaScript/Node), cgo (Go), JNI (Java), native (Rust), C headers (C)
Design¶
┌─────────────────────────────────────────────────────┐
│ holoconf-core (Rust) │
│ - YAML/JSON parsing │
│ - Hierarchical merge algorithm │
│ - Interpolation syntax parsing ${...} │
│ - Resolver registry & dispatch │
│ - Built-in resolvers: env, self-reference │
└─────────────────────────────────────────────────────┘
│
FFI Bindings (per language)
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Python │ │ JS │ │ Future │
│ (PyO3) │ │(NAPI-RS)│ │Languages│
└─────────┘ └─────────┘ └─────────┘
Rationale¶
- Single implementation guarantees behavioral consistency - All languages use the same parsing, merging, and resolution logic
- Adding new language = write bindings, not reimplement core - Dramatically reduces effort to add new language support
- Rust compiles to all target platforms - x86_64, aarch64 supported out of the box
- WASM compilation possible - Browser support can be added later
Error Handling¶
Rust Result<T, E> types are mapped to native exceptions/errors in each language:
# Python
try:
config = holoconf.load("config.yaml")
except holoconf.ParseError as e:
print(f"Failed to parse: {e}")
except holoconf.ResolverError as e:
print(f"Resolver failed: {e}")
// JavaScript
try {
const config = await holoconf.load("config.yaml");
} catch (e) {
if (e instanceof holoconf.ParseError) { ... }
}
Memory Management¶
Hybrid approach - Config wrapper with copy-on-access for scalars:
- Python/JS
Configobject holds an opaque reference to Rust-owned config data - Accessing nested paths (e.g.,
config.database) returns another wrapper (no copy) - Accessing scalar values (strings, numbers, booleans) copies the resolved value to native types
- Lazy resolution (ADR-005) happens in Rust; only resolved values cross the FFI boundary
This approach:
- Works naturally with lazy resolution (unresolved values stay in Rust)
- Is memory efficient (large configs aren't duplicated)
- Returns native types where it matters (config.database.host returns a native string)
Crate Structure¶
See ADR-006 for repository and package structure.
Trade-offs Accepted¶
- Rust expertise required for core development in exchange for single source of truth
- FFI boundaries add complexity in exchange for guaranteed consistency
- Language bindings must be maintained per-language in exchange for simpler than full reimplementation
Migration¶
N/A - This is the initial architecture decision.
Consequences¶
- Positive: Consistent behavior across all languages, reduced maintenance burden, scales to many languages
- Negative: Requires Rust expertise, FFI debugging can be challenging
- Neutral: Language-specific idioms may need adaptation to fit the binding model