Documentation Index
Fetch the complete documentation index at: https://www.aidonow.com/llms.txt
Use this file to discover all available pages before exploring further.
Executive Summary
A multi-service Rust provisioning system creates DynamoDB tables, seeds initial data, and configures Global Secondary Indexes for each product service at capsule creation time. The naive implementation — importing each product crate directly into the provisioner for its schema definitions — produces a circular dependency: product crates depend on the provisioner for shared provisioning utilities, and the provisioner depends on the product crates for their schemas. Rust’s crate graph must be a directed acyclic graph (DAG); the compiler rejects cycles before compilation begins. The schema registry pattern resolves this by introducing a shared crate that defines only aSchemaProvider trait and supporting data structures, with no dependencies on any product crate or the provisioner. Each product crate implements the trait and registers itself using the inventory crate, which collects implementations at link time via ELF section injection. The provisioner collects all registered providers at startup without importing any product crate directly. The result is a dependency graph with no cycles, schema definitions testable in isolation, and provisioning that is safe to re-run by design.
Key Findings
- Circular crate dependencies in Rust are a compile-time error, not a link-time warning: the Cargo resolver rejects dependency graph cycles before any compilation unit is produced, making the issue architectural rather than a build configuration problem.
- The registry pattern breaks the cycle by introducing a shared crate as the dependency inversion point: product crates and the provisioner both depend on the registry, but neither depends on the other, eliminating the cycle entirely.
- The
inventorycrate resolves provider collection at link time via ELF section injection: registered providers become a static property of the binary, eliminating runtime HashMap registration and the initialization ordering problems it introduces. - Treating
ResourceInUseExceptiononCreateTableas idempotent success makes provisioning safe to re-run: this is required for operator-based reconciliation loops where the same provisioning function may be called multiple times for the same capsule. - Enforcing idempotency conditions on seed records at the test level — rather than relying on developer discipline — produces a seed registry that is safe to run against an already-seeded capsule without overwriting existing data.
- Each product’s
SchemaProviderimplementation is testable in isolation without a running provisioner: CI can validate schema correctness — table names, key schemas, seed idempotency conditions — before any infrastructure is exercised.
1. Direct Product-to-Provisioner Imports Create Cycles That the Cargo Resolver Rejects Before Compilation
Provisioning in a multi-service platform is a well-bounded problem: when a new tenant capsule is created, the provisioner creates the backing infrastructure each product service requires. For DynamoDB-backed services, this means creating tables, applying TTL configuration, defining GSIs, and inserting seed records that the service expects to exist before it receives its first request. The straightforward implementation places this logic in a dedicated provisioner crate. The provisioner needs to know what tables each product requires. The product crates own that knowledge. The first instinct is to import each product crate into the provisioner and call a function that returns the schema definition. This approach fails immediately in a codebase where product crates also import the provisioner for shared utilities — provisioning result types, error handling infrastructure, or tenant identifier types that the provisioner defines. The dependency graph looks like this:.rs file is read:
2. Three Naive Workarounds Fail Because They Address the Symptom Without Inverting the Dependency
Three approaches appear reasonable before the architectural solution becomes clear. Each fails for a specific reason. Approach 1: Move schema definitions into the provisioner. This eliminates the import from provisioner to product crate. However, it creates a new problem: product crates now have no schema definitions of their own. Any product-side code that validates against or references table names must either re-import them from the provisioner (restoring the cycle) or duplicate them (creating a maintenance hazard where product code and provisioner code diverge). Approach 2: Use#[cfg(feature = "provisioner")] to gate the circular import. Feature flags gate compilation of code but not dependency resolution. Cargo resolves the full dependency graph before evaluating feature flags. A cyclic dependency declared in Cargo.toml under [dependencies] fails at dependency resolution time regardless of whether the dependent code is behind a feature gate.
Approach 3: Move shared utilities out of the provisioner and into a separate utilities crate. This is a step in the right direction but does not resolve the problem unless it also breaks the product-to-provisioner dependency. If product crates import from a separate utilities crate rather than the provisioner, but the provisioner still imports product crates for their schemas, the cycle remains between provisioner and product crates.
The correct solution does not patch the dependency graph — it restructures it.
| Approach | Circular Dep Risk | Resolves the Cycle |
|---|---|---|
| Move schemas to provisioner | High | No — products re-import provisioner |
| Feature-flag the import | High | No — Cargo resolves deps before features |
| Extract shared utilities crate | Medium | Partial — requires also breaking product→provisioner |
| Schema registry pattern | None | Yes — cycle is architecturally eliminated |
3. A Shared Schema Registry Crate with Zero Upstream Dependencies Is the Correct Inversion Point
The registry pattern introduces a crate —eva-schema-registry — that sits below both the product crates and the provisioner in the dependency graph. It defines only traits and data structures. It has no dependencies on any product crate, on the provisioner, or on any infrastructure client. Its entire purpose is to provide a shared vocabulary that both sides can speak without knowing about each other.
The dependency graph after the pattern is applied:
SeedRecord.condition field is typed as Option<String> rather than String to maintain API flexibility, but the test suite (described in Section 7) enforces that every seed produced by a registered provider carries a non-None condition. This combination — optional at the type level, required at the test level — avoids over-constraining the type while still catching omissions in CI before deployment.
4. Each Product Crate Implements SchemaProvider Independently Without Any Knowledge of the Provisioner
Each product crate creates aschema.rs module that implements SchemaProvider. The implementation depends only on eva-schema-registry — no provisioner import is needed or permitted.
TableDefinition.name is the bare service-scoped name (crm_accounts). The provisioner prepends the capsule code at runtime ({capsule_code}-crm_accounts), ensuring that multiple capsules share the same schema definition without collision. The SchemaProvider implementation does not need to know the capsule code.
5. The inventory Crate Resolves Provider Collection at Link Time, Not Runtime — Zero Initialization Overhead
Each product crate registers itsSchemaProvider implementation using the inventory crate by dtolnay. The inventory::submit! macro places a static reference to the provider into a named linker section. When the final binary is linked, the linker collects all references from all object files into a contiguous array. inventory::iter iterates over that array at runtime — with zero dynamic allocation and zero initialization ordering concerns.
Registration in the product crate:
Cargo.toml lists each product crate as a direct dependency. This is now safe — the product crates do not import the provisioner. The dependency exists only so the linker sees the product crates’ object files when building the provisioner binary, enabling the inventory collection mechanism to find all registered providers.
The
inventory crate offers compile-time collection (via inventory::submit! and inventory::iter) and runtime registration (via inventory::Registry). These are distinct mechanisms with different trade-offs. Compile-time collection — the approach documented here — is a static property of the binary: every provider that was compiled into the binary is always present, and the set cannot change after the binary is linked. Runtime registration supports dynamic plugin loading (e.g., dlopen-based architectures) at the cost of initialization ordering constraints and the need to ensure registration happens before collection. For provisioning use cases where the set of services is fixed at compile time, static collection is the correct choice.6. Treating ResourceInUseException as Success Makes the Provisioner Safe to Re-Run in Operator Reconciliation Loops
A provisioner that runs exactly once per capsule is fragile. Operator-based reconciliation loops call provisioning logic repeatedly — on pod restart, on configuration change, on manual re-trigger. Infrastructure automation tools apply provisioning functions idempotently by design. A provisioner that fails when run against an already-provisioned capsule is incompatible with these environments. Idempotency in DynamoDB provisioning requires handling two distinct cases: table creation and seed data insertion.Table Creation Idempotency
DynamoDB returnsResourceInUseException when CreateTable is called for a table that already exists. Treating this response as a success — rather than an error — makes table creation idempotent:
Seed Data Idempotency
Seed record idempotency is enforced by thecondition field on SeedRecord. Each seed carries a DynamoDB condition expression passed to PutItem. The standard condition for a seed that must not overwrite existing data is attribute_not_exists(pk):
attribute_not_exists(pk) is the condition and the item already exists, DynamoDB returns ConditionalCheckFailedException. This is not a failure — it is confirmation that the expected data is already present.
The Full Provisioning Loop
service_id ensures that re-running the provisioner on the same capsule applies operations in the same order as the original run. Non-deterministic ordering is not a correctness problem for idempotent operations, but it makes log correlation and debugging substantially harder.
The wait between table creation and seeding is required because DynamoDB table creation is asynchronous: CreateTable returns immediately with a CREATING status, and PutItem against a table in CREATING state returns ResourceNotFoundException. The provisioner must poll until the table status transitions to ACTIVE before inserting seeds.
In an early implementation, the saga’s
persist_state() function was a no-op — saga checkpoints were never written to DynamoDB. An operator restart after partial provisioning would re-execute completed steps rather than resuming from the checkpoint. This was caught in architecture review before schema wiring began. If you’re implementing a provisioning saga, write and test persist_state() before wiring product schemas — otherwise your provisioner can silently re-execute work it already completed.7. SchemaProvider Implementations Are Unit-Testable in Isolation Without a Running DynamoDB or Provisioner
TheSchemaProvider trait is a pure Rust interface with no I/O. Every product crate can test its schema definition without a DynamoDB connection, without a provisioner process, and without any infrastructure dependency:
tables() output, but the corresponding seed record is not updated. Without this test, the provisioner would call PutItem against a table name that no longer exists, producing a ResourceNotFoundException at runtime on the first provisioning run after the change.
The idempotency condition test enforces a property that is difficult to catch in code review: a seed record missing its condition expression will overwrite existing data every time the provisioner runs. This can silently reset user-modified seed records on operator-triggered re-provisioning events.
8. Five Steps Migrate an Existing Provisioner to the Schema Registry Pattern Without a Big Bang Rewrite
Teams migrating from an existing provisioning implementation — whether inline table definitions in the provisioner, direct product crate imports, or a mix of both — can adopt the schema registry pattern incrementally without requiring a full rewrite. Step 1: Create theeva-schema-registry crate.
eva-schema-registry/src/lib.rs. Add aws-sdk-dynamodb to the registry crate’s [dependencies] for the AttributeValue type used in SeedRecord. Add inventory as a dependency:
schema.rs file.
For each product, create src/schema.rs, implement SchemaProvider, and move existing table definitions into the implementation. Add the inventory::submit! call at the bottom of the file. Add eva-schema-registry and inventory to the product crate’s dependencies:
collect_all_providers().
Replace any direct calls to product-crate schema functions with collect_all_providers(). Add each product crate to the provisioner’s [dependencies]. Remove any shared provisioning utilities from the provisioner crate that were previously imported by product crates — either delete them if unused or move them to a separate utilities crate that neither the provisioner nor the product crates circularly depend on.
Step 4: Delete the circular imports.
Remove any use eva_provisioner::* or equivalent imports from product crates. Verify that cargo build --workspace succeeds without error. Run the test suite for each product crate to confirm that SchemaProvider implementations pass the isolation tests described in Section 7.
Step 5: Pin versions.
After confirming the build is clean, pin the eva-schema-registry version in each product crate’s Cargo.toml to the current version. This prevents the schema registry trait from being silently changed in a way that breaks a product crate’s implementation without a compilation error.
The comparison between the original approach and the registry pattern across the dimensions that matter most for long-term maintainability:
| Dimension | Direct Import (Original) | Schema Registry Pattern |
|---|---|---|
| Circular dependency risk | Critical — Rust rejects at resolution | None — graph is a DAG |
| Product schema testability | Requires provisioner in test scope | Isolated — no provisioner needed |
| Adding a new product service | Requires provisioner to import new crate (may reintroduce cycle) | Add schema.rs + inventory::submit! in product crate only |
| Provider collection mechanism | Manual — provisioner lists providers explicitly | Automatic — linker collects at link time |
| Binary size impact | Equivalent | Equivalent — no additional runtime overhead |
| Migration cost | N/A | Medium — one schema.rs per product crate |
9. Recommendations for Multi-Service Rust Provisioning
-
Audit your crate dependency graph for cycles before they block a build. Run
cargo tree --duplicatesand visually inspect the output for any crate that appears both above and below another crate in the tree. Cargo does not provide a dedicated cycle-detection command, butcargo metadata --format-version 1 | jq '[.packages[].dependencies[].name]'piped through a graph analysis script can identify cycles programmatically in large workspaces. - Create a shared schema registry crate as the first step of any multi-service provisioning system you build. Do not defer this to when the cycle first manifests. The registry crate is cheap to create and eliminates an entire class of architectural debt.
-
Add
inventoryas a dependency in both your registry crate and each product crate. TheSchemaProvidertrait should re-export theinventorycrate or at minimum document thatinventory::submit!is the expected registration mechanism, so product crate authors do not need to rediscover the pattern. -
Enforce idempotency conditions on all seed records in your CI pipeline. Add a test to each product crate’s test suite that calls
provider.seeds()and asserts that every returnedSeedRecordhas a non-Nonecondition. This test costs nothing to run and prevents data loss on re-provisioning. -
Sort providers by
service_id()in your provisioner to ensure deterministic provisioning order. Theinventorycrate makes no guarantees about iteration order. Non-deterministic ordering does not break correctness for idempotent operations, but it produces non-deterministic logs that are difficult to compare across provisioning runs. -
Verify cross-compilation builds in your CI pipeline after adopting
inventory. The linker section mechanism is platform-specific. If the provisioner binary is built for a Linux target but the CI host runs macOS or Windows, confirm that the cross-compiler and linker correctly produce the ELF.init_arraysections thatinventory::iterexpects. -
Treat
ResourceInUseExceptiononCreateTableas success, not as an error to suppress. Make this explicit in code comments. A future reader may attempt to “fix” this by propagating the error, not understanding that idempotency is intentional. Document the design decision, not just the implementation.
Conclusion
The schema registry pattern resolves a class of architectural problem that arises naturally in multi-service Rust systems: the provisioner needs product knowledge, and product crates need provisioner infrastructure, and the result is a dependency graph that Rust correctly refuses to compile. The solution is not a workaround — it is a proper dependency inversion. By introducing a shared crate that defines only the contract between products and provisioner, both sides can be implemented, tested, and maintained without knowledge of each other. Theinventory crate makes this pattern operationally lightweight. Registration requires one line per product crate. Collection requires one function in the provisioner. The resulting binary has no runtime registration overhead and no initialization ordering constraints. As Rust adoption in multi-service platform architectures grows, patterns like this — compile-time composition over runtime wiring — will become standard practice rather than advanced technique. Teams building provisioning infrastructure today are well-positioned to establish these patterns before the codebase grows to a scale where ad-hoc dependency management becomes unworkable.
All content represents personal learning from personal projects. Code examples are sanitized and generalized. No proprietary information is shared. Opinions are my own and do not reflect my employer’s views.