Skip to main content
This article is part of the “Building with AI” series documenting my journey using multi-agent AI workflows to build production systems.All examples are from personal projects and do not represent employer technologies.

The Challenge

January 2026. My SaaS platform had configuration chaos:
  • JWT expiration hardcoded in 3 places
  • Each crate reading std::env::var() directly
  • No tenant-specific overrides
  • Zero hierarchy (Platform → Tenant → Capsule)
Worse: Every handler manually called ConfigService.get() with different error handling patterns. Inconsistent, error-prone, and impossible to enforce. AI Focus: Could AI design a middleware pattern that makes configuration automatic and hierarchical? System Example: ConfigMiddleware that injects resolved config into every request via web::ReqData<ConfigContext>.

The Multi-Agent Design Session

I gave the Evaluator agent our requirements:
  1. Platform-level defaults (env vars)
  2. Tenant-level overrides (DynamoDB)
  3. Capsule-level overrides (SDLC environments)
  4. Zero manual ConfigService calls in handlers
The Evaluator’s response surprised me: “This is a middleware problem, not a service problem.”
The Evaluator analyzed our existing CapsuleExtractor middleware and proposed chaining:
Request → CapsuleExtractor → ConfigMiddleware → Handler
          (extracts scope)    (loads config)    (uses config)
Builder agent then generated the middleware with:
  • Single cache lookup per request
  • Hierarchical resolution (Platform → Tenant → Capsule)
  • Automatic injection via Actix-web extensions
  • Graceful degradation for public routes

The Architecture

The pattern AI discovered has three layers:

1. Middleware Chain

pub async fn login_handler(
    config_service: web::Data<Arc<ConfigService>>,
    tenant_id: web::Path<String>,
    capsule: web::ReqData<CapsuleContext>,
) -> Result<HttpResponse> {
    // Manual config loading - repeated in EVERY handler
    let auth_config = config_service
        .get_auth_config(&tenant_id, Some(&capsule.capsule_id))
        .await
        .map_err(|e| ErrorInternalServerError(e))?;

    let jwt_expiry = auth_config.jwt_expiration_seconds;
    // ... handler logic
}

2. Hierarchical Resolution

ConfigService resolves configuration with a fallback chain:
impl ConfigService {
    pub async fn get_auth_config(
        &self,
        tenant_id: &str,
        capsule_id: Option<&str>,
    ) -> Result<Arc<AuthConfig>> {
        // Try capsule-level override
        if let Some(capsule_id) = capsule_id {
            if let Some(config) = self.get_capsule_config(tenant_id, capsule_id).await? {
                return Ok(Arc::new(config));
            }
        }

        // Try tenant-level override
        if let Some(config) = self.get_tenant_config(tenant_id).await? {
            return Ok(Arc::new(config));
        }

        // Fall back to platform defaults (env vars)
        Ok(Arc::new(self.get_platform_defaults()))
    }
}
Example hierarchy:
Platform defaults:     jwt_expiration_seconds = 3600 (1 hour)
Tenant override:       jwt_expiration_seconds = 7200 (2 hours, enterprise)
Capsule override:      jwt_expiration_seconds = 300  (5 min, dev env)

Result for dev capsule: 300 seconds
Result for prod capsule: 7200 seconds (tenant override)

3. Caching Layer

Moka cache sits in front of DynamoDB to prevent hammering:
pub struct ConfigService {
    cache: Arc<Cache<ConfigCacheKey, Arc<AuthConfig>>>,
    repository: Arc<dyn ConfigRepository>,
}

impl ConfigService {
    pub fn new(repository: Arc<dyn ConfigRepository>) -> Self {
        let cache = Cache::builder()
            .max_capacity(10_000)  // 10K tenant/capsule combos
            .time_to_live(Duration::from_secs(300))  // 5 min TTL
            .build();

        Self { cache: Arc::new(cache), repository }
    }
}
Cache key:
struct ConfigCacheKey {
    tenant_id: String,
    capsule_id: Option<String>,
    scope: ConfigScope,  // Platform, Tenant, Capsule
}

The Migration

We migrated eva-auth handlers from manual config loading to middleware injection:

Before: 17 handlers × manual loading = 340 lines of boilerplate

// Repeated in login, refresh_token, validate_token, etc.
async fn handler(
    config_service: web::Data<Arc<ConfigService>>,
    tenant_id: web::Path<String>,
    capsule: web::ReqData<CapsuleContext>,
) -> Result<HttpResponse> {
    let auth_config = config_service
        .get_auth_config(&tenant_id, Some(&capsule.capsule_id))
        .await
        .map_err(|e| ErrorInternalServerError(e))?;

    // ... use auth_config
}

After: Zero config lookups in handlers

// All handlers now
async fn handler(
    config: web::ReqData<ConfigContext>,
) -> Result<HttpResponse> {
    // Config already loaded by middleware
    let jwt_expiry = config.auth_config.jwt_expiration_seconds;
}
Deleted code:
  • 340 lines of config loading boilerplate
  • 17 error handling blocks
  • Inconsistent patterns across handlers
Added code:
  • 179 lines of ConfigMiddleware
  • Single registration in main.rs
  • 100% consistent pattern enforced by compiler

What Went Wrong

Mistake: Initially registered ConfigMiddleware BEFORE CapsuleExtractor in the middleware chain.Why it failed: ConfigMiddleware needs CapsuleContext to know which tenant/capsule config to load. Without CapsuleExtractor running first, it had no scope information.How we fixed it: Reversed the middleware order in main.rs:
// WRONG ORDER
App::new()
    .wrap(ConfigMiddleware::new(config_service))  // Runs first
    .wrap(CapsuleExtractor::new())                // Runs second

// CORRECT ORDER
App::new()
    .wrap(CapsuleExtractor::new())                // Runs first
    .wrap(ConfigMiddleware::new(config_service))  // Runs second
Lesson: AI designed both middleware pieces correctly but didn’t understand middleware execution order in Actix-web. Middleware wraps happen in reverse order—humans need to know the framework.

The REST API

We also exposed configuration management via REST API (10 endpoints): Platform Config:
GET    /api/platform/config/auth
PUT    /api/platform/config/auth
POST   /api/platform/config/auth/reset
Tenant Config:
GET    /api/{tenant_id}/config/auth
PUT    /api/{tenant_id}/config/auth
DELETE /api/{tenant_id}/config/auth
Capsule Config:
GET    /api/{tenant_id}/capsules/{capsule_id}/config/auth
PUT    /api/{tenant_id}/capsules/{capsule_id}/config/auth
DELETE /api/{tenant_id}/capsules/{capsule_id}/config/auth
POST   /api/{tenant_id}/capsules/{capsule_id}/config/auth/preview
The preview endpoint is clever—it shows what config would resolve to without saving:
POST /api/tenant-123/capsules/DEVUS/config/auth/preview
{
  "jwt_expiration_seconds": 300
}

Response:
{
  "effective_config": {
    "jwt_expiration_seconds": 300,  // Your override
    "refresh_token_ttl_seconds": 7200,  // From tenant
    "enable_ses": true  // From platform
  },
  "source": {
    "jwt_expiration_seconds": "capsule",
    "refresh_token_ttl_seconds": "tenant",
    "enable_ses": "platform"
  }
}
This lets admins see the full hierarchy before committing changes.

Key Learnings

AI Strength

AI excels at middleware pattern design once you explain the data flow. It generated the entire ConfigMiddleware + caching + API layer in one go.

AI Weakness

AI doesn’t understand framework-specific execution order. It designed both middleware correctly but registered them in the wrong order.

Human Role

Humans define the hierarchy requirements, review middleware ordering, and add performance optimizations (caching, TTL values).

Process Insight

The best AI-generated infrastructure has a single integration point (main.rs middleware registration). Makes it easy to test and migrate incrementally.

Actionable Takeaways

If you’re building hierarchical config systems:
  1. Middleware over manual calls - Inject config via middleware so handlers can’t forget to load it. Compiler enforcement is better than documentation.
  2. Cache at the service layer - Put Moka/Redis in front of your config backend. Our cache hit rate is 99.2% (single 5-min TTL).
  3. Preview before commit - Add a preview endpoint that shows effective config without saving. Prevents production misconfigurations.
Pro tip: Use web::ReqData<T> in Actix-web for type-safe middleware injection. If a handler declares config: web::ReqData<ConfigContext>, it won’t compile unless ConfigMiddleware is registered. Zero runtime errors.

Metrics

  • Boilerplate removed: 340 lines (17 handlers × 20 lines each)
  • Middleware added: 179 lines
  • Net reduction: 161 lines (32% less code)
  • Manual ConfigService.get() calls: 0 (was 17)

The Migration Guide We Wrote

We documented this in CONFIGURATION_MIGRATION.md (450 lines). Here’s the key section:
## Migrating Handlers

### Step 1: Remove ConfigService dependency

Before:
async fn handler(
    config_service: web::Data<Arc<ConfigService>>,
    tenant_id: web::Path<String>,
) -> Result<HttpResponse>

After:
async fn handler(
    config: web::ReqData<ConfigContext>,
) -> Result<HttpResponse>

### Step 2: Replace config loading

Before:
let auth_config = config_service
    .get_auth_config(&tenant_id, None)
    .await?;

After:
// Config already loaded by middleware
let auth_config = &config.auth_config;

### Step 3: Update main.rs middleware chain

App::new()
    .wrap(CapsuleExtractor::new())  // MUST be first
    .wrap(ConfigMiddleware::new(config_service))
    .service(web::scope("/api").configure(routes))
This guide reduced migration time per crate from 2 days to 2 hours.

Resources & Further Reading


Next in This Series

Week 6: How we built event-sourced workflows with saga patterns and automatic compensation logic.

Week 6: Saga Workflow Patterns

State machines that handle multi-step business processes with rollback

Discussion

Share Your Experience

How do you handle configuration in multi-tenant systems? Manual lookups or automatic injection?Connect on LinkedIn or comment on the YouTube Short

Disclaimer: This content represents my personal learning journey using AI for a personal project. It does not represent my employer’s views, technologies, or approaches.All code examples are generic patterns or pseudocode for educational purposes.