Skip to main content

The Problem: Drowning in Repetition

After building 7 CRM domain entities, I noticed a painful pattern. Every single entity required the same boilerplate: For each domain entity (Account, Contact, Lead, etc.):
// 1. Event sourcing boilerplate (~150 lines)
impl Account {
    pub fn apply(&mut self, event: &AccountEvent) {
        match event {
            AccountEvent::Created(e) => { /* apply logic */ }
            AccountEvent::Updated(e) => { /* apply logic */ }
            // ... 10-15 event variants
        }
    }

    pub fn replay(events: Vec<AccountEvent>) -> Self {
        let mut account = Self::default();
        for event in events {
            account.apply(&event);
        }
        account
    }

    pub fn uncommitted_events(&self) -> &[AccountEvent] {
        &self.events
    }

    pub fn mark_events_committed(&mut self) {
        self.events.clear();
    }
}

// 2. Repository boilerplate (~200 lines)
pub struct InMemoryAccountRepository {
    storage: Arc<RwLock<HashMap<AccountId, Account>>>,
}

impl InMemoryAccountRepository {
    pub async fn save(&self, account: Account) -> Result<()> {
        let mut storage = self.storage.write().await;
        storage.insert(account.id, account);
        Ok(())
    }

    pub async fn get(&self, id: AccountId) -> Result<Option<Account>> {
        let storage = self.storage.read().await;
        Ok(storage.get(&id).cloned())
    }

    // ... 8-10 more CRUD methods
}

// 3. DynamoDB repository boilerplate (~250 lines)
pub struct DynamoDbAccountRepository {
    client: CapsuleClient,
    table_name: String,
}

impl DynamoDbAccountRepository {
    pub async fn save(&self, account: Account) -> Result<()> {
        let entity = AccountEntity::from_domain(account);
        let item = to_item(&entity)?;

        self.client
            .client()
            .put_item()
            .table_name(&self.table_name)
            .set_item(Some(item))
            .send()
            .await?;

        Ok(())
    }

    pub async fn get(&self, id: AccountId) -> Result<Option<Account>> {
        let key = AccountEntity::primary_key(id);
        let result = self.client
            .client()
            .get_item()
            .table_name(&self.table_name)
            .set_key(Some(key))
            .send()
            .await?;

        match result.item {
            Some(item) => {
                let entity: AccountEntity = from_item(item)?;
                Ok(Some(entity.to_domain()))
            }
            None => Ok(None)
        }
    }

    // ... 8-10 more CRUD methods
}

// 4. Caching repository wrapper (~150 lines)
pub struct CachedAccountRepository<R> {
    inner: R,
    cache: Cache<AccountId, Account>,
}

// ... more boilerplate
Total per entity: 600-800 lines of nearly identical code. For 7 entities: 4,200-5,600 lines of boilerplate. The pain:
  • Every entity copy-pasted the same patterns
  • Bugs fixed in one repository needed manual propagation to 6 others
  • Adding new CRUD methods required updating 21 repository implementations (7 entities × 3 repository types)
The Hidden Cost: Copy-paste programming creates maintenance debt that scales with entity count.When I found a bug in the DynamoDB save method, I had to manually fix it in 7 places. When I missed one, it caused a production data corruption issue.

The Solution: Derive Macros

Instead of writing boilerplate manually, I built 5 derive macros that generate it automatically:

DomainAggregate

Generates:
  • Event replay logic
  • Uncommitted event tracking
  • Version management
  • Test fixture creation
Usage:
#[derive(DomainAggregate)]
#[id_type = "AccountId"]
#[event = "AccountEvent"]
pub struct Account { /* ... */ }
Eliminates: ~150 lines per entity

DomainEvent

Generates:
  • aggregate_id() accessor
  • tenant_id() accessor
  • event_type() string representation
  • Event metadata methods
Usage:
#[derive(DomainEvent)]
#[aggregate = "Account"]
pub enum AccountEvent {
    Created(AccountCreated),
    Updated(AccountUpdated),
}
Eliminates: ~100 lines per entity

InMemoryRepository

Generates:
  • Full CRUD implementation
  • HashMap-based storage
  • Async trait methods
  • Thread-safe with Arc RwLock
Usage:
#[derive(InMemoryRepository)]
#[aggregate = "Account"]
#[id_type = "AccountId"]
pub struct InMemoryAccountRepository;
Eliminates: ~200 lines per entity

DynamoDbRepository

Generates:
  • save, get, query, delete methods
  • CapsuleClient integration
  • Tenant isolation enforcement
  • TransactWriteItems for atomicity
Usage:
#[derive(DynamoDbRepository)]
#[aggregate = "Account"]
#[entity = "AccountEntity"]
pub struct DynamoDbAccountRepository;
Eliminates: ~250 lines per entity

CachedRepository

Generates:
  • Moka cache integration
  • TTL-based expiration
  • Write-through invalidation
  • Cache hit/miss metrics
Usage:
#[derive(CachedRepository)]
#[inner = "DynamoDbAccountRepository"]
pub struct CachedAccountRepository;
Eliminates: ~150 lines per entity
Total elimination: 600-850 lines per entity × 7 entities = 4,200-5,950 lines of boilerplate removed

The Implementation Journey

Phase 1: Designing the Macros

Evaluator session (Opus):
Planning session for derive macro system to eliminate repository boilerplate.

Context:
- 7 CRM entities each need 3 repository implementations (InMemory, DynamoDB, Cached)
- Current: 4,200+ lines of nearly identical code
- Goal: Reduce to ~50 lines per entity via macros

Analyze existing repository patterns and design macros for:
1. DomainAggregate (event sourcing)
2. DomainEvent (event helpers)
3. InMemoryRepository (test repositories)
4. DynamoDbRepository (production repositories)
5. CachedRepository (performance layer)

Requirements:
- Type safety maintained
- Compile-time errors for invalid patterns
- Support multi-item DynamoDB entities (METADATA + LIST_ITEM)
- GSI query support
- Tenant isolation enforcement
Evaluator’s output (4 hours): A comprehensive plan with:
  • Macro implementation strategy
  • syn/quote usage patterns
  • Test approach for validating generated code
  • Migration plan for existing entities
  • Risk analysis (what could go wrong with code generation)
Key design decision:
Option A: Inference-Heavy Macros
  • Auto-detect fields and generate methods
  • Minimal annotations required
  • Risk: Silent failures when field names change
Option B: Explicit Annotation Macros (CHOSEN)
  • Require explicit #[id_type], #[event], #[aggregate] attributes
  • Fail loudly if required annotations missing
  • Generate helpful compiler errors
Rationale: Explicit macros are more verbose but prevent silent bugs. When a macro fails, it should fail at compile time with a clear message, not at runtime with mysterious errors.

Phase 2: Implementation

Builder session (Sonnet): The implementation took 2 days and generated 2,917 lines of macro code:
  • domain_aggregate.rs (272 lines) - Event sourcing patterns
  • domain_event.rs (311 lines) - Event helper generation
  • inmemory_repository.rs (284 lines) - Test repository generation
  • dynamodb_repository.rs (539 lines) - Production repository generation
  • cached_repository.rs (499 lines) - Caching decorator generation
  • lib.rs (405 lines) - Macro exports and utilities
  • README.md (387 lines) - Comprehensive documentation
Comprehensive test suite:
  • domain_aggregate_test.rs (212 lines) - Validates event sourcing generation
  • complete_workflow_test.rs (522 lines) - End-to-end demonstration
Total: 16 tests, all passing

Phase 3: Verification

Verifier session (fresh Sonnet):
Verify macro implementation for issue #575.

Check:
1. Do macros handle all repository patterns from existing code?
2. Are generated methods type-safe?
3. Edge cases: optional fields, nested structs, multi-item entities?
4. Migration path for existing entities?
5. Compilation errors helpful?
Verifier found 3 issues:

Issue #1: Multi-Item Entity Pattern Not Supported

Builder implemented macros for single-item entities (one DynamoDB record per domain object). But some entities use multi-item pattern:
// Account has 2 DynamoDB items:
// 1. METADATA item (account details)
// 2. LIST_ITEM for queryable fields

// Macro didn't support generating queries for LIST_ITEM pattern
Why Builder missed this: The plan showed examples for single-item entities only. Multi-item pattern was mentioned in a footnote.Impact if shipped: Macros would only work for 4/7 entities. The other 3 (Account, Contact, Opportunity) would need manual repository implementations.Fix: Added #[multi_item] attribute support to DynamoDbRepository macro (3 hours)

Issue #2: GSI Query Methods Missing

DynamoDB entities have Global Secondary Indexes (GSI) for queries:
  • Query accounts by industry
  • Query contacts by company
  • Query opportunities by stage
Macro generated primary key queries but forgot GSI queries.Impact: Common query patterns still required manual implementation.Fix: Added GSI method generation with #[gsi_methods(gsi_name)] attribute (2 hours)

Issue #3: Cache Invalidation Strategy Unclear

CachedRepository macro generated write-through caching but didn’t specify invalidation strategy:
  • Should update invalidate cache immediately?
  • Should delete remove from cache?
  • What about bulk operations?
Impact: Inconsistent caching behavior across entities could lead to stale reads.Fix: Added explicit #[cache_strategy] attribute with options: write_through, write_around, write_back (1.5 hours)
After fixes: Re-verification PASSED ✅

The Migration: Refactoring 7 Entities

With macros approved, I faced the real test: migrate 7 existing entities to use macros. The naive approach I almost took: “Let AI refactor all 7 entities in one session.” Why that would have failed: Refactoring all entities simultaneously makes it impossible to verify each one independently. The approach that worked: Migrate one entity at a time, verify, then next.

Entity #1: Account (The Template)

Builder session:
Refactor Account entity and repositories to use new derive macros.

Current state: Manual implementation (812 lines across 3 files)
Target state: Macro-driven (43 lines)

Steps:
1. Add #[derive(DomainAggregate)] to Account
2. Replace InMemoryAccountRepository with macro version
3. Replace DynamoDbAccountRepository with macro version
4. Replace CachedAccountRepository with macro version
5. Verify all 23 tests still pass
Result:
// BEFORE: 812 lines
// Account domain model (152 lines)
// InMemoryAccountRepository (215 lines)
// DynamoDbAccountRepository (295 lines)
// CachedAccountRepository (150 lines)

// AFTER: 43 lines
#[derive(DomainAggregate, Debug, Clone)]
#[id_type = "AccountId"]
#[event = "AccountEvent"]
pub struct Account {
    pub id: AccountId,
    pub name: String,
    pub industry: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(InMemoryRepository)]
#[aggregate = "Account"]
#[id_type = "AccountId"]
pub struct InMemoryAccountRepository;

#[derive(DynamoDbRepository)]
#[aggregate = "Account"]
#[entity = "AccountEntity"]
#[multi_item]
#[gsi_methods("by_industry")]
pub struct DynamoDbAccountRepository;

#[derive(CachedRepository)]
#[inner = "DynamoDbAccountRepository"]
#[cache_strategy = "write_through"]
pub struct CachedAccountRepository;
Boilerplate eliminated: 769 lines (95% reduction) Tests: All 23 tests passing ✅ Migration time: 2 hours (mostly verification)

The Pattern That Emerged

After migrating Account successfully, I applied the same process to the remaining 6 entities. Results:
EntityBeforeAfterEliminatedTime
Account812 lines43 lines769 (95%)2h
Contact734 lines38 lines696 (95%)1.5h
Lead698 lines41 lines657 (94%)1.5h
Opportunity856 lines47 lines809 (94%)2h
Activity612 lines35 lines577 (94%)1h
Product723 lines39 lines684 (95%)1.5h
Address542 lines32 lines510 (94%)1h
Total4,9772754,702 (94%)11.5h
Key insight: After the first entity migration, the remaining 6 went 2-3x faster because the pattern was established.

What the Macros Actually Generate

Here’s what happens when you write:
#[derive(DomainAggregate)]
#[id_type = "AccountId"]
#[event = "AccountEvent"]
pub struct Account {
    pub id: AccountId,
    pub name: String,
}
The macro generates (behind the scenes):
impl Account {
    // Event sourcing methods
    pub fn apply(&mut self, event: &AccountEvent) {
        match event {
            AccountEvent::Created(e) => {
                self.id = e.account_id;
                self.name = e.name.clone();
            }
            AccountEvent::Updated(e) => {
                if let Some(name) = &e.name {
                    self.name = name.clone();
                }
            }
        }
        self.version += 1;
        self.events.push(event.clone());
    }

    pub fn replay(events: Vec<AccountEvent>) -> Self {
        let mut aggregate = Self::default();
        for event in events {
            aggregate.apply(&event);
        }
        aggregate.mark_events_committed();
        aggregate
    }

    pub fn uncommitted_events(&self) -> &[AccountEvent] {
        &self.events
    }

    pub fn mark_events_committed(&mut self) {
        self.events.clear();
    }

    pub fn test_fixture() -> Self {
        Self {
            id: AccountId::new_v4(),
            name: "Test Account".to_string(),
            version: 1,
            events: vec![],
            ..Default::default()
        }
    }
}
Inspection: Use cargo expand to see exactly what code macros generate.

What AI Taught Me About Code Generation

Lesson 1: AI Excels at Pattern Recognition

The macro design process: I gave Evaluator access to all 7 existing repository implementations and asked:
Analyze these 7 repository implementations.
Find common patterns and identify what can be macro-generated.
AI’s analysis (impressive): “All repositories follow the same pattern:
  1. save() method: convert domain to entity to DynamoDB item
  2. get() method: fetch item to entity to domain
  3. Error handling: same pattern across all entities
  4. Tenant isolation: all methods require tenant_id + capsule_id
Variation points:
  • Entity type (Account vs Contact vs Lead)
  • ID type (AccountId vs ContactId)
  • GSI queries (different fields per entity)
Recommendation: Single macro parameterized by entity type and ID type.” What surprised me: AI didn’t just copy the pattern - it identified the abstraction. It recognized that the differences (Account vs Contact) were parameter values, not fundamentally different code.

Lesson 2: AI Struggles with Macro Error Messages

Builder’s first attempt at error messages:
// When user forgets #[id_type] attribute:
compile_error!("Missing attribute"); // ❌ Not helpful
Verifier caught this:
“Error message doesn’t tell user WHAT attribute is missing or WHERE to add it.”
Improved version:
compile_error!(
    "DomainAggregate macro requires #[id_type = \"YourIdType\"] attribute.\n\
     Add it to the struct definition.\n\
     Example: #[derive(DomainAggregate)]\n\
              #[id_type = \"AccountId\"]\n\
              pub struct Account { ... }"
); // ✅ Helpful with example
Principle established: Macro error messages should include:
  1. What’s wrong
  2. How to fix it
  3. Example showing correct usage
AI limitation: Builder generated technically correct error messages but lacked empathy for developer experience. This required human review.

Lesson 3: Generated Code Needs Manual Review

The bug we almost shipped: DynamoDbRepository macro generated save() method:
// Generated code (WRONG):
pub async fn save(&self, aggregate: Account) -> Result<()> {
    let entity = AccountEntity::from_domain(aggregate);
    let item = to_item(&entity)?;

    self.client.client()
        .put_item()
        .table_name(&self.table_name)
        .set_item(Some(item))
        .send()
        .await?;

    Ok(())  // ❌ Missing tenant isolation check
}
What’s wrong: No validation that aggregate.tenant_id matches self.client.tenant_id. Impact: Potential cross-tenant data corruption (save Account from Tenant A into Tenant B’s partition). Caught by: Manual code inspection using cargo expand. Fixed with:
// Generated code (CORRECT):
pub async fn save(&self, aggregate: Account) -> Result<()> {
    // NEW: Validate tenant isolation
    if aggregate.tenant_id() != self.client.tenant_id() {
        return Err(RepositoryError::TenantMismatch {
            expected: self.client.tenant_id(),
            actual: aggregate.tenant_id(),
        });
    }

    let entity = AccountEntity::from_domain(aggregate);
    // ... rest of save logic
}
Lesson: Always inspect generated code with cargo expand, especially for security-critical patterns.

The Surprise: AI-Generated Documentation

After Builder implemented the 5 macros, I asked:
Write comprehensive README.md for platform-macros crate.

Include:
- Overview of what each macro does
- Usage examples for all 5 macros
- Migration guide from manual to macro-driven
- Common pitfalls and how to avoid them
- Performance considerations
AI generated a 387-line README that included:
  • Complete usage examples
  • Side-by-side before/after comparisons
  • Migration checklist
  • Troubleshooting section
  • Performance benchmarks (estimated)
What impressed me: The README quality was better than what I would have written manually. Why?
  1. Comprehensive: AI didn’t skip sections. Covered every macro thoroughly.
  2. Consistent: Same structure for each macro (usage → example → edge cases)
  3. Practical: Included actual migration steps, not just API docs
The twist: I still needed to review and edit the README. AI’s performance benchmarks were wrong (hallucinated numbers). But the structure and examples were solid.
Insight: AI excels at creating structured documentation from patterns. But verify technical claims (benchmarks, performance characteristics) independently.

The Breaking Change That Taught Us Everything

After merging the macros, I decided to enhance them with auto-generated CRUD methods. The change (commit 508f43e):
// NEW: DynamoDbRepository macro now generates db_save, db_get, db_query, db_delete
#[derive(DynamoDbRepository)]
#[aggregate = "Account"]
pub struct DynamoDbAccountRepository;

// Generates:
impl DynamoDbAccountRepository {
    pub async fn db_save(&self, aggregate: Account) -> Result<()> { /* ... */ }
    pub async fn db_get(&self, id: AccountId) -> Result<Option<Account>> { /* ... */ }
    // ...
}
The problem: This was a breaking change. Existing code called save(), but macro now generated db_save(). Impact: 30 commits of cascading fixes across crm crate. I’ll cover this story in detail in the “When AI Fails: Cascading Errors” article, but the key learning: Macro changes are amplified changes. One macro bug affects every entity using that macro.

What We Built

Before macros:
  • 4,977 lines of repository boilerplate
  • 7 entities × 3 repository types = 21 implementations
  • Average bug fix propagation: 30 minutes × 7 entities = 3.5 hours
After macros:
  • 275 lines of macro usage
  • 2,917 lines of macro implementation (written once, used everywhere)
  • Bug fix propagation: Fix macro once, all entities updated automatically
ROI Calculation:
Boilerplate eliminated: 4,702 linesMacro code written: 2,917 linesNet reduction: 1,785 lines (36% reduction)But the real win: Macro changes propagate instantly to all entities.

Principles Established

What we learned: Good macros don’t just reduce typing - they enforce correctness.Example: DynamoDbRepository macro enforces tenant isolation checks. Can’t forget them because macro generates them automatically.Rule: If a pattern has invariants (tenant checks, error handling, isolation), encode them in macros so they can’t be violated.
What we learned: Macros that infer behavior are brittle. Macros that require explicit annotations are robust.Bad:
#[derive(Repository)]  // Infers everything
pub struct AccountRepository;  // What type? What storage? Unclear!
Good:
#[derive(DynamoDbRepository)]
#[aggregate = "Account"]  // Explicit
#[entity = "AccountEntity"]  // Explicit
pub struct DynamoDbAccountRepository;  // Clear!
Rule: Make macro users be explicit about intent. Better compilation errors than runtime surprises.
What we learned: Macros are black boxes. You must validate what they generate.Tool: cargo expand shows expanded macro output:
cargo expand crm::repositories::account_repository
Practice: After macro changes, always inspect generated code for at least one entity.Rule: Generated code should be readable, not just correct. If cargo expand output is incomprehensible, the macro is too clever.
What we learned: Migrating all entities to macros simultaneously is risky.Bad approach:
  • Refactor all 7 entities in one session
  • “Comprehensive” testing at the end
  • High risk of breaking changes
Good approach:
  • Migrate one entity
  • Verify it thoroughly
  • Use learnings to improve macro
  • Repeat for next entity
Benefit: Each migration surfaces edge cases that improve the macro for subsequent entities.
What we learned: AI was 4x faster at implementing macros than at designing them.Design phase (creative): AI needed extensive human guidance to identify the right abstractionsImplementation phase (systematic): AI independently implemented 5 macros with comprehensive testsDocumentation phase (systematic): AI generated excellent README with minimal guidanceRule: Use AI for implementing macro logic and generating documentation. Use human judgment for designing the abstraction boundaries.

The Mistake I Made (And How AI Caught It)

After migrating 4 entities: I was confident in the macro pattern. I got lazy. Shortcut I took: Migrated the last 3 entities (Product, Activity, Address) in a single Builder session without individual verification. Result: CI failed. 14 compilation errors across the 3 entities. What happened: Product entity had a unique pattern - pricing fields that other entities didn’t have. The DomainAggregate macro’s apply() generation didn’t handle optional Decimal fields correctly. AI’s role in fixing: Instead of manually debugging, I asked a fresh Verifier session:
CI is failing for Product, Activity, Address entities after macro migration.

Errors:
[Show compilation errors]

Analyze:
1. What pattern do these 3 entities have that Account/Contact/Lead don't?
2. Does the macro handle that pattern?
3. Recommend fix
Verifier’s analysis: “Product entity has Decimal fields for pricing. DomainAggregate macro generates apply() method that assumes all fields are Clone. Decimal type doesn’t implement Clone trait. Options:
  1. Require Clone bound on all fields
  2. Add special handling for Decimal
  3. Generate field updates conditionally based on trait bounds
Recommendation: Option 1 (simplest, most explicit)” Fix: Added where T: Clone bound to generated methods. All 3 entities compiled. Lesson: AI Verifier can debug macro issues by analyzing error patterns and generated code. Faster than manual debugging.

Metrics

Boilerplate eliminated:
  • Total: 4,702 lines (94% reduction)
  • Per entity: 670 lines average
Development speedup:
  • New entity time: 4-6h → 1-1.5h (4x faster)
  • Bug fix propagation: 3.5h → 20min (10x faster)
Macro implementation:
  • Design: 4 hours (human + AI)
  • Implementation: 16 hours (AI)
  • Total: 26.5 hours
Value:
  • One-time investment: 26.5 hours
  • Savings per entity: 3-4 hours
  • Break-even: ~7 entities (achieved)
  • Future entities: Pure savings
Tests generated:
  • 16 macro tests
  • 6 complete workflow tests
  • All existing entity tests still passing (142 tests)

Code Example: Before and After

Before Macros

// Manual implementation: 215 lines
pub struct InMemoryAccountRepository {
    storage: Arc<RwLock<HashMap<AccountId, Account>>>,
}

impl InMemoryAccountRepository {
    pub fn new() -> Self {
        Self {
            storage: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    pub async fn save(&self, account: Account) -> Result<()> {
        let mut storage = self.storage.write().await;
        storage.insert(account.id, account);
        Ok(())
    }

    pub async fn get(&self, id: AccountId) -> Result<Option<Account>> {
        let storage = self.storage.read().await;
        Ok(storage.get(&id).cloned())
    }

    pub async fn delete(&self, id: AccountId) -> Result<()> {
        let mut storage = self.storage.write().await;
        storage.remove(&id);
        Ok(())
    }

    pub async fn list(&self) -> Result<Vec<Account>> {
        let storage = self.storage.read().await;
        Ok(storage.values().cloned().collect())
    }

    // ... 8 more methods
}

// + similar implementations for DynamoDbAccountRepository (295 lines)
// + CachedAccountRepository (150 lines)
// Total: 660 lines just for repositories

After Macros

// Macro-driven: 15 lines total
#[derive(InMemoryRepository)]
#[aggregate = "Account"]
#[id_type = "AccountId"]
pub struct InMemoryAccountRepository;

#[derive(DynamoDbRepository)]
#[aggregate = "Account"]
#[entity = "AccountEntity"]
pub struct DynamoDbAccountRepository;

#[derive(CachedRepository)]
#[inner = "DynamoDbAccountRepository"]
pub struct CachedAccountRepository;

// Same functionality, 98% less code

What’s Next: The Articles to Come

This macro system enabled several other patterns worth documenting:
  1. When AI Fails: Cascading Errors - The 30-commit error chase after the CRUD macro breaking change
  2. Configuration Governance - How macros enforced architectural decisions
  3. Multi-Layer Authorization - Using macros to ensure OAuth checks at compile time
The macro story doesn’t end here - it’s the foundation for everything that follows.