ADR-0002: Two composition roots, not one Aspire AppHost¶
Status¶
Accepted
Context¶
Constitution P1: "One Aspire AppHost project declares every resource the system
needs... A developer clones the repository and runs one command." The reference systems
this was extracted from are all-.NET: every resource an AppHost orchestrates (a database,
a cache, a project, a Next.js frontend) either has a first-class Aspire integration or is
a container Aspire can start and health-check generically.
The simulation stack here does not fit that shape:
- It needs privileged/device access and X11 (or Wayland) socket passthrough for GUI rendering, which Aspire's container model does not express.
- Its image takes tens of minutes to build the first time (PX4 compiled from source);
treating it as a normal Aspire-managed container conflates "orchestrate" with "build",
and a
dotnet runon the AppHost is not where a contributor expects a 30-minute wait. - It is one stack shared by every future .NET service (this phase has exactly one:
SwarmApi.Api), not a per-service dependency an AppHost's per-project graph models.
Decision¶
Two composition roots, each for the layer it actually composes:
docker/docker-compose.yml— ROS 2 + PX4 SITL + Gazebo, with aHEADLESSenvironment toggle for GPU-less environments (documented risk in the source analysis). This is what a contributor runs to get the simulation itself up.- Plain
dotnet run --project backend/src/SwarmApi.Api— no Aspire AppHost. A single ASP.NET Core project has no second resource to compose; an AppHost around one project is ceremony with no payoff yet.
This is a deviation from P1's letter (recorded in docs/architecture/DEVIATIONS.md), not
from its intent: the point of P1 is that setup is one command per layer a contributor
actually needs to bring up, and it stays that way here.
Consequences¶
- No
SwarmApi.AppHostproject exists yet.SwarmApi.ServiceDefaults(P2) is still built now, ahead of needing it for orchestration, because it is what makes the next service cheap to add correctly (/health,/alive, CORS) — it does not depend on an AppHost existing. - Recorded trigger to revisit: the day a second .NET service is added (the M5 LLM
mission-translation layer is the planned one), stand up
SwarmApi.AppHostreferencing both projects. At that point the AppHost becomes the thing P1 describes: a real graph withWithReference/WaitForedges, not a wrapper around a single node. - The simulation stack stays outside any future AppHost's graph regardless (same
GUI/build-time reasoning above); the AppHost would compose the .NET services only, with
SwarmApi.Apipointed at the independently-running compose stack via configuration (RosBridge:Url) — the two composition roots talk to each other over a URL, never over a shared orchestrator.
Worked example: docker/docker-compose.yml, backend/src/SwarmApi.ServiceDefaults/.