Skip to main content

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.

Dependency graph before and after the schema registry pattern: direct circular imports replaced by a shared registry crate with no upstream product or provisioner dependencies

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 a SchemaProvider 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 inventory crate 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 ResourceInUseException on CreateTable as 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 SchemaProvider implementation 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:
eva-provisioner ──→ eva-crm         (provisioner imports CRM for schema)
eva-crm         ──→ eva-provisioner (CRM imports provisioner for shared types)
Cargo resolves this as a cycle. The error appears before any .rs file is read:
error: cyclic package dependency: package `eva-crm v0.1.0` depends on itself.
Cycle:
    package `eva-provisioner v0.1.0`
        ... which satisfies dependency `eva-provisioner = "0.1"` of package `eva-crm v0.1.0`
        ... which satisfies dependency `eva-crm = "0.1"` of package `eva-provisioner v0.1.0`
The Rust compiler cannot emit a single compilation unit for a cycle because each crate in the cycle must be compiled before the crate that depends on it. This constraint is not a Rust-specific limitation — it follows from the definition of a dependency. A system that depends on itself cannot be bootstrapped.

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.
ApproachCircular Dep RiskResolves the Cycle
Move schemas to provisionerHighNo — products re-import provisioner
Feature-flag the importHighNo — Cargo resolves deps before features
Extract shared utilities crateMediumPartial — requires also breaking product→provisioner
Schema registry patternNoneYes — 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:
eva-schema-registry  ← no product deps, no provisioner deps
       ↑                        ↑
eva-crm  eva-itsm  eva-forge   (implement SchemaProvider)
       ↑                        ↑
       eva-provisioner          (collects providers, executes)
Every arrow points downward. No cycles exist. The provisioner imports product crates as dependencies (which is now safe because those crates only expose a trait implementation, not provisioner internals), and all crates import the registry for the shared trait definition. The registry crate’s public API is minimal:
// eva-schema-registry/src/lib.rs

use std::collections::HashMap;
use aws_sdk_dynamodb::types::AttributeValue;

pub trait SchemaProvider: Send + Sync {
    /// Unique identifier for this provider (e.g., "eva-crm", "eva-itsm").
    /// Must be stable across deployments — used for logging and ordering.
    fn service_id(&self) -> &'static str;

    /// DynamoDB table definitions this service requires.
    fn tables(&self) -> Vec<TableDefinition>;

    /// Seed records to insert after table creation.
    /// Every seed must carry a condition expression for idempotency.
    fn seeds(&self) -> Vec<SeedRecord>;

    /// GSI definitions for this service's tables.
    fn gsis(&self) -> Vec<GsiDefinition>;
}

#[derive(Debug, Clone)]
pub struct TableDefinition {
    pub name: String,
    pub pk: KeySchema,
    pub sk: Option<KeySchema>,
    pub billing_mode: BillingMode,
    pub ttl_attribute: Option<String>,
}

#[derive(Debug, Clone)]
pub struct KeySchema {
    pub attribute_name: String,
    pub key_type: KeyType,
    pub attribute_type: AttributeType,
}

#[derive(Debug, Clone, PartialEq)]
pub enum KeyType {
    Hash,
    Range,
}

#[derive(Debug, Clone, PartialEq)]
pub enum AttributeType {
    S,
    N,
    B,
}

#[derive(Debug, Clone, PartialEq)]
pub enum BillingMode {
    PayPerRequest,
    Provisioned { read_capacity: u64, write_capacity: u64 },
}

#[derive(Debug, Clone)]
pub struct SeedRecord {
    pub table_name: String,
    pub item: HashMap<String, AttributeValue>,
    /// DynamoDB condition expression applied during PutItem.
    /// Required — prevents overwriting existing data on re-provisioning.
    pub condition: Option<String>,
}

#[derive(Debug, Clone)]
pub struct GsiDefinition {
    pub table_name: String,
    pub index_name: String,
    pub pk: KeySchema,
    pub sk: Option<KeySchema>,
    pub projection: GsiProjection,
}

#[derive(Debug, Clone)]
pub enum GsiProjection {
    All,
    KeysOnly,
    Include(Vec<String>),
}
The 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 a schema.rs module that implements SchemaProvider. The implementation depends only on eva-schema-registry — no provisioner import is needed or permitted.
// eva-crm/src/schema.rs

use std::collections::HashMap;
use aws_sdk_dynamodb::types::AttributeValue;
use eva_schema_registry::{
    AttributeType, BillingMode, GsiDefinition, GsiProjection,
    KeySchema, KeyType, SchemaProvider, SeedRecord, TableDefinition,
};

pub struct CrmSchemaProvider;

impl SchemaProvider for CrmSchemaProvider {
    fn service_id(&self) -> &'static str {
        "eva-crm"
    }

    fn tables(&self) -> Vec<TableDefinition> {
        vec![
            TableDefinition {
                name: "crm_accounts".to_string(),
                pk: KeySchema {
                    attribute_name: "pk".to_string(),
                    key_type: KeyType::Hash,
                    attribute_type: AttributeType::S,
                },
                sk: Some(KeySchema {
                    attribute_name: "sk".to_string(),
                    key_type: KeyType::Range,
                    attribute_type: AttributeType::S,
                }),
                billing_mode: BillingMode::PayPerRequest,
                ttl_attribute: None,
            },
            TableDefinition {
                name: "crm_contacts".to_string(),
                pk: KeySchema {
                    attribute_name: "pk".to_string(),
                    key_type: KeyType::Hash,
                    attribute_type: AttributeType::S,
                },
                sk: Some(KeySchema {
                    attribute_name: "sk".to_string(),
                    key_type: KeyType::Range,
                    attribute_type: AttributeType::S,
                }),
                billing_mode: BillingMode::PayPerRequest,
                ttl_attribute: Some("expires_at".to_string()),
            },
        ]
    }

    fn seeds(&self) -> Vec<SeedRecord> {
        vec![
            SeedRecord {
                table_name: "crm_accounts".to_string(),
                item: HashMap::from([
                    (
                        "pk".to_string(),
                        AttributeValue::S("ACCOUNT#SYSTEM".to_string()),
                    ),
                    (
                        "sk".to_string(),
                        AttributeValue::S("META#v1".to_string()),
                    ),
                    (
                        "account_type".to_string(),
                        AttributeValue::S("system".to_string()),
                    ),
                    (
                        "created_at".to_string(),
                        AttributeValue::N("0".to_string()),
                    ),
                ]),
                // Idempotent: only inserts if this exact pk/sk does not already exist.
                condition: Some("attribute_not_exists(pk)".to_string()),
            },
        ]
    }

    fn gsis(&self) -> Vec<GsiDefinition> {
        vec![
            GsiDefinition {
                table_name: "crm_accounts".to_string(),
                index_name: "gsi_account_type".to_string(),
                pk: KeySchema {
                    attribute_name: "account_type".to_string(),
                    key_type: KeyType::Hash,
                    attribute_type: AttributeType::S,
                },
                sk: None,
                projection: GsiProjection::KeysOnly,
            },
        ]
    }
}
The table name stored in 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.
Each product crate registers its SchemaProvider 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:
// eva-crm/src/schema.rs (continued from above)

// Submit the CRM provider to the global inventory.
// This line is the only connection between eva-crm and the provisioner's
// collection mechanism — no import of the provisioner is required.
inventory::submit! {
    &CrmSchemaProvider as &dyn eva_schema_registry::SchemaProvider
}
The equivalent registration for each other product crate follows the same pattern:
// eva-itsm/src/schema.rs
inventory::submit! {
    &ItsmSchemaProvider as &dyn eva_schema_registry::SchemaProvider
}

// eva-forge/src/schema.rs
inventory::submit! {
    &ForgeSchemaProvider as &dyn eva_schema_registry::SchemaProvider
}
Collection in the provisioner:
// eva-provisioner/src/registry.rs

use eva_schema_registry::SchemaProvider;

/// Returns all SchemaProvider implementations registered via inventory::submit!
/// across all crates linked into this binary.
///
/// The returned slice is a static property of the binary — its contents are
/// determined at link time, not at runtime.
pub fn collect_all_providers() -> Vec<&'static dyn SchemaProvider> {
    inventory::iter::<&'static dyn SchemaProvider>
        .into_iter()
        .copied()
        .collect()
}
The provisioner’s 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.
# eva-provisioner/Cargo.toml

[dependencies]
eva-schema-registry = { path = "../eva-schema-registry" }
eva-crm             = { path = "../eva-crm" }
eva-itsm            = { path = "../eva-itsm" }
eva-forge           = { path = "../eva-forge" }
eva-whisper         = { path = "../eva-whisper" }
eva-config          = { path = "../eva-config" }
eva-workflow        = { path = "../eva-workflow" }
inventory           = "0.3"
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.
The inventory crate’s linker section mechanism uses platform-specific section names. On Linux (ELF), it uses .init_array. On macOS (Mach-O), it uses __DATA,__mod_init_func. On Windows (PE/COFF), it uses .CRT$XCU. The inventory crate handles these differences internally, but cross-compilation scenarios — for example, compiling for a Linux target on a Windows host — require that the linker in use understands the target platform’s section format. Verify cross-compilation builds in CI; do not assume that a build that succeeds natively will succeed when cross-compiled.

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 returns ResourceInUseException when CreateTable is called for a table that already exists. Treating this response as a success — rather than an error — makes table creation idempotent:
// eva-provisioner/src/provision.rs

use aws_sdk_dynamodb::Client as DynamoDbClient;
use aws_sdk_dynamodb::error::SdkError;
use aws_sdk_dynamodb::operation::create_table::CreateTableError;
use aws_sdk_dynamodb::types::{
    AttributeDefinition, BillingMode as DynamoBillingMode,
    KeySchemaElement, KeyType as DynamoKeyType, ScalarAttributeType,
};
use eva_schema_registry::{AttributeType, KeyType, TableDefinition};

pub async fn create_table_idempotent(
    client: &DynamoDbClient,
    table_name: &str,
    def: &TableDefinition,
) -> Result<(), ProvisionError> {
    // Build attribute definitions from pk and optional sk.
    let mut attr_defs = vec![AttributeDefinition::builder()
        .attribute_name(&def.pk.attribute_name)
        .attribute_type(to_dynamo_attr_type(&def.pk.attribute_type))
        .build()?];

    let mut key_schema = vec![KeySchemaElement::builder()
        .attribute_name(&def.pk.attribute_name)
        .key_type(DynamoKeyType::Hash)
        .build()?];

    if let Some(sk) = &def.sk {
        attr_defs.push(
            AttributeDefinition::builder()
                .attribute_name(&sk.attribute_name)
                .attribute_type(to_dynamo_attr_type(&sk.attribute_type))
                .build()?,
        );
        key_schema.push(
            KeySchemaElement::builder()
                .attribute_name(&sk.attribute_name)
                .key_type(DynamoKeyType::Range)
                .build()?,
        );
    }

    let result = client
        .create_table()
        .table_name(table_name)
        .set_attribute_definitions(Some(attr_defs))
        .set_key_schema(Some(key_schema))
        .billing_mode(DynamoBillingMode::PayPerRequest)
        .send()
        .await;

    match result {
        Ok(_) => Ok(()),
        Err(SdkError::ServiceError(e))
            if matches!(e.err(), CreateTableError::ResourceInUseException(_)) =>
        {
            // Table already exists — idempotent success.
            // Do not return an error; do not attempt to modify the existing table.
            Ok(())
        }
        Err(e) => Err(ProvisionError::DynamoDb(e.into())),
    }
}

fn to_dynamo_attr_type(t: &AttributeType) -> ScalarAttributeType {
    match t {
        AttributeType::S => ScalarAttributeType::S,
        AttributeType::N => ScalarAttributeType::N,
        AttributeType::B => ScalarAttributeType::B,
    }
}

Seed Data Idempotency

Seed record idempotency is enforced by the condition 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):
// eva-provisioner/src/provision.rs (continued)

use aws_sdk_dynamodb::operation::put_item::PutItemError;

pub async fn insert_seed_idempotent(
    client: &DynamoDbClient,
    table_name: &str,
    seed: &SeedRecord,
) -> Result<(), ProvisionError> {
    let mut request = client
        .put_item()
        .table_name(table_name)
        .set_item(Some(seed.item.clone()));

    if let Some(condition) = &seed.condition {
        request = request.condition_expression(condition);
    }

    match request.send().await {
        Ok(_) => Ok(()),
        Err(SdkError::ServiceError(e))
            if matches!(e.err(), PutItemError::ConditionalCheckFailedException(_)) =>
        {
            // Condition failed — the record already exists.
            // This is idempotent success: the data is already seeded.
            Ok(())
        }
        Err(e) => Err(ProvisionError::DynamoDb(e.into())),
    }
}
When 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

// eva-provisioner/src/provision.rs (continued)

use crate::registry::collect_all_providers;

pub async fn provision_capsule(
    client: &DynamoDbClient,
    capsule_code: &str,
) -> Result<(), ProvisionError> {
    let providers = collect_all_providers();

    // Sort providers by service_id for deterministic ordering across runs.
    let mut providers = providers;
    providers.sort_by_key(|p| p.service_id());

    for provider in &providers {
        // Phase 1: Create tables.
        for table_def in provider.tables() {
            let qualified_name = format!("{}-{}", capsule_code, table_def.name);
            create_table_idempotent(client, &qualified_name, &table_def).await?;
        }

        // Phase 2: Wait for table ACTIVE status before seeding.
        for table_def in provider.tables() {
            let qualified_name = format!("{}-{}", capsule_code, table_def.name);
            wait_for_table_active(client, &qualified_name).await?;
        }

        // Phase 3: Insert seed records.
        for seed in provider.seeds() {
            let qualified_name = format!("{}-{}", capsule_code, seed.table_name);
            insert_seed_idempotent(client, &qualified_name, &seed).await?;
        }
    }

    Ok(())
}
Sorting providers by 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

The SchemaProvider 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:
// eva-crm/src/schema.rs (test module)

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn crm_schema_provider_tables_are_non_empty() {
        let provider = CrmSchemaProvider;
        let tables = provider.tables();
        assert!(
            !tables.is_empty(),
            "CRM must define at least one DynamoDB table"
        );
    }

    #[test]
    fn all_tables_have_valid_key_schema() {
        let provider = CrmSchemaProvider;
        for table in provider.tables() {
            assert!(
                !table.name.is_empty(),
                "Table name must not be empty"
            );
            assert!(
                !table.pk.attribute_name.is_empty(),
                "Table '{}' must have a non-empty partition key attribute name",
                table.name
            );
            if let Some(sk) = &table.sk {
                assert!(
                    !sk.attribute_name.is_empty(),
                    "Table '{}' sort key must have a non-empty attribute name",
                    table.name
                );
                assert_ne!(
                    sk.attribute_name, table.pk.attribute_name,
                    "Table '{}' pk and sk must not use the same attribute name",
                    table.name
                );
            }
        }
    }

    #[test]
    fn all_seed_records_have_idempotency_conditions() {
        let provider = CrmSchemaProvider;
        for seed in provider.seeds() {
            assert!(
                seed.condition.is_some(),
                "Seed record for table '{}' must carry a condition expression. \
                 Seeds without conditions overwrite existing data on re-provisioning.",
                seed.table_name
            );
        }
    }

    #[test]
    fn all_seed_records_reference_defined_tables() {
        let provider = CrmSchemaProvider;
        let table_names: std::collections::HashSet<String> = provider
            .tables()
            .into_iter()
            .map(|t| t.name)
            .collect();

        for seed in provider.seeds() {
            assert!(
                table_names.contains(&seed.table_name),
                "Seed record references table '{}' which is not defined by this provider",
                seed.table_name
            );
        }
    }

    #[test]
    fn all_gsi_definitions_reference_defined_tables() {
        let provider = CrmSchemaProvider;
        let table_names: std::collections::HashSet<String> = provider
            .tables()
            .into_iter()
            .map(|t| t.name)
            .collect();

        for gsi in provider.gsis() {
            assert!(
                table_names.contains(&gsi.table_name),
                "GSI '{}' references table '{}' which is not defined by this provider",
                gsi.index_name,
                gsi.table_name
            );
        }
    }
}
The test that verifies seed records reference defined tables catches a common drift pattern: a table is renamed or removed from the provider’s 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 the eva-schema-registry crate.
cargo new --lib eva-schema-registry
Copy the trait definitions from Section 3 into 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:
# eva-schema-registry/Cargo.toml
[dependencies]
aws-sdk-dynamodb = "1"
inventory = "0.3"
Step 2: For each product crate, create a 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:
# eva-crm/Cargo.toml
[dependencies]
eva-schema-registry = { path = "../eva-schema-registry" }
inventory = "0.3"
Step 3: Update the provisioner to use 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:
DimensionDirect Import (Original)Schema Registry Pattern
Circular dependency riskCritical — Rust rejects at resolutionNone — graph is a DAG
Product schema testabilityRequires provisioner in test scopeIsolated — no provisioner needed
Adding a new product serviceRequires provisioner to import new crate (may reintroduce cycle)Add schema.rs + inventory::submit! in product crate only
Provider collection mechanismManual — provisioner lists providers explicitlyAutomatic — linker collects at link time
Binary size impactEquivalentEquivalent — no additional runtime overhead
Migration costN/AMedium — one schema.rs per product crate

9. Recommendations for Multi-Service Rust Provisioning

  1. Audit your crate dependency graph for cycles before they block a build. Run cargo tree --duplicates and 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, but cargo metadata --format-version 1 | jq '[.packages[].dependencies[].name]' piped through a graph analysis script can identify cycles programmatically in large workspaces.
  2. 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.
  3. Add inventory as a dependency in both your registry crate and each product crate. The SchemaProvider trait should re-export the inventory crate or at minimum document that inventory::submit! is the expected registration mechanism, so product crate authors do not need to rediscover the pattern.
  4. 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 returned SeedRecord has a non-None condition. This test costs nothing to run and prevents data loss on re-provisioning.
  5. Sort providers by service_id() in your provisioner to ensure deterministic provisioning order. The inventory crate 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.
  6. 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_array sections that inventory::iter expects.
  7. Treat ResourceInUseException on CreateTable as 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. The inventory 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.