Composition Root (main.rs)
In Hexagonal Architecture, the Composition Root is the single place that wires the application together. In paiOS this is pai-engine/src/main.rs. It is the only code that knows which adapters exist and how they are selected; the Core (Orchestrator) and all domain crates stay unaware of concrete adapter types and feature flags.
Why a dedicated Composition Root
Section titled “Why a dedicated Composition Root”The why (placement in the binary crate, rejected alternatives) is in ADR-008: Workspace Layout. This page covers the what and how.
Responsibility of main.rs
Section titled “Responsibility of main.rs”The flow in main.rs is intentionally strict:
- Load configuration: e.g.
hardware.toml, CLI args. No domain logic, only bootstrap. - Initialize adapters: The only place that knows about feature flags and concrete types. Use
#[cfg(feature = "...")]so the compiler selects the right adapter per build; avoid runtimeif/elseover adapter choice when possible. - Build the Orchestrator and inject dependencies: Pass the adapter instances (or trait objects) into the Orchestrator. Core receives only the interfaces; it never sees
V4L2CameraAdapterorMockCameraAdapterby name. - Run: Call
orchestrator.run()(or equivalent). From here on, only domain and port code execute; no more adapter construction.
So: main.rs does init and wiring only. It does not contain business logic or orchestration rules; those live in Core and the domain crates.
Where main.rs lives
Section titled “Where main.rs lives”The Composition Root is not a separate domain crate. It is the executable that composes the domain crates:
| Location | Role |
|---|---|
engine/pai-engine/ | The crate that builds the final binary. |
engine/pai-engine/src/main.rs | The Composition Root: entry point, config, adapter init, injection, then run. |
Feature aggregation and which crates are pulled in are defined in Workspace and Build (pai-engine/Cargo.toml, feature flags). The runtime wiring (which adapter instance goes into which port) is defined in main.rs.
Does feature handling “reach” into domain crates?
Section titled “Does feature handling “reach” into domain crates?”Yes, but via Cargo, not via main.rs. When you build pai-engine with a feature (e.g. vision_v4l2), the root Cargo.toml re-exports it so the optional crate and its inner feature are enabled, e.g. vision_v4l2 = ["vision", "vision/vision_v4l2"]. Cargo then builds the vision dependency with that feature enabled, so #[cfg(feature = "vision_v4l2")] inside the domain crate (e.g. in adapters/mod.rs or adapters/v4l2.rs) sees the feature and compiles the adapter code. Main does not need “access” into domain crates; the same feature set is applied when Cargo builds each crate. So: the composition root (build profile + main.rs) decides which adapters exist for a given build; each domain crate gates its adapter modules with #[cfg(feature = "...")] so only the requested adapter code is compiled. Both stay in sync because the dependency is built with the features the root enabled. See Cargo: Dependency features and Workspace and Build for the re-export pattern.
If the composition root grows
Section titled “If the composition root grows”Keeping a single logical composition root (one crate, one place that knows all adapters) does not require all code to live in one file. If main.rs becomes too long:
- Composition module: Have
main.rsonly parse args, load config, and set up logging; then callcomposition::run(config)(orcompose_and_run(config)). Move adapter construction and injection into acompositionmodule in the same crate (e.g.composition/mod.rs, or split by domain:composition/vision.rs,composition/audio.rs). The composition root remains a single place: not literally insidemain.rs. - Factories: For complex sub-graphs (e.g. the full vision or inference stack), the root can construct factory or builder types and inject those; the factory owns building its sub-graph. The root stays small and only wires a few high-level pieces.
A separate composition crate only makes sense when multiple executables need to share the same wiring (e.g. the main engine and a dedicated CLI). Until then, keep a single composition root in pai-engine.
Configuration and adapter roles
Section titled “Configuration and adapter roles”Feature flags (compile-time) determine which adapter code exists in the binary. Runtime configuration (hardware.toml) determines which of the compiled adapters are instantiated and how they are wired. This two-level design handles the “mute button = hardware GPIO, but feedback = desktop notification” scenario:
# hardware.toml (example: hybrid desktop + GPIO board)[peripherals]input = "gpio" # Use GpioInputAdapter for the mute buttonfeedback = "desktop_notify" # Use DesktopNotifyAdapter for user feedback
[vision.camera]device_path = "/dev/video0"format = "MJPEG"The composition root reads this config and instantiates the matching adapters:
// Simplified: main.rs reads config, picks the right adapter per rolelet input_adapter = match config.peripherals.input.as_str() { #[cfg(feature = "periph_gpio")] "gpio" => Box::new(GpioInputAdapter::new(config.gpio_pin)?) as Box<dyn UserInteractionPort>, #[cfg(feature = "periph_desktop")] "desktop" => Box::new(DesktopHotkeyAdapter::new()?) as Box<dyn UserInteractionPort>, other => return Err(anyhow!("unknown input adapter: {other}")),};Key principle: Feature flags control what is compiled; config controls what is used. If both periph_gpio and periph_desktop are compiled (e.g. in a development build), the config selects which one to activate. In production builds, typically only one is compiled (via the profile), so the config applies only to parameters (pin numbers, device paths, thresholds).
Code sketch (pattern)
Section titled “Code sketch (pattern)”Adapter selection is driven by compile-time features where possible, so the compiler only builds the adapters needed for the current target (e.g. desktop vs. embedded):
// engine/pai-engine/src/main.rs (conceptual)fn main() -> Result<()> { let config = ConfigProvider::load("hardware.toml")?;
// 1. Adapters: only main knows about features and concrete types #[cfg(feature = "vision_v4l2")] let vision = pai_vision::adapters::v4l2::V4L2CameraAdapter::new(...)?; #[cfg(feature = "vision_mock")] let vision = pai_vision::adapters::mock::MockCameraAdapter::new(); // ... audio, inference, api, peripherals the same way
// 2. Inject into Core; Orchestrator never sees feature flags or adapter type names let orchestrator = Orchestrator::new(vision, audio, inference, api, peripherals);
// 3. Run: from here only domain logic and ports orchestrator.run()}The Orchestrator (Core) receives only the trait interfaces; it does not know which concrete adapter is behind each one.
Relation to other docs
Section titled “Relation to other docs”- ADR-008: Workspace Layout: Why a single composition root lives in the binary crate and why alternatives were rejected (separate composition crate, service locator, plugin/registry). Single source for the composition root rationale.
- Core: Defines the Orchestrator and the ports (traits) that main.rs injects into.
- Workspace and Build: Where
pai-engineis defined, how features are aggregated, and how the composition root crate fits in the workspace. - ADR-004: Engine Architecture: Hexagonal Architecture and the role of the Composition Root.