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

By January 2025, my SaaS platform had 20+ domain entities (Leads, Accounts, Opportunities, Products, etc.). Each one needed:
  • Event sourcing boilerplate (~100 lines)
  • DynamoDB repository (~300 lines)
  • In-memory test repository (~80 lines)
  • Caching decorator (~150 lines)
  • Event helper methods (~50 lines)
Total: 680 lines of repetitive code per entity. Multiply by 20 entities = 13,600 lines of nearly identical boilerplate. AI Focus: Could AI analyze the patterns across entities and generate macros to eliminate this? System Example: Five derive macros that reduce boilerplate by 80% and enforce architectural consistency.

The Pattern Discovery

I asked the Evaluator agent: “Look at these 5 domain entities and tell me what’s repetitive.” AI’s response:
Analyzing Lead, Account, Opportunity, Product, Contact...

Patterns found:
1. Event sourcing: All have version, uncommitted_events, replay()
2. Repositories: All implement save(), find_by_id(), delete()
3. Test helpers: All have test_fixture(), assert_events()
4. Event enums: All need aggregate_id(), event_type()
5. Caching: All use Moka with same TTL and write-invalidate

Recommendation: Create 5 derive macros to eliminate this.
The Builder agent analyzed the patterns and generated macro implementations with:
  • Proc macro infrastructure (syn, quote, proc-macro2)
  • Attribute parsing for configuration
  • Code generation for each pattern
  • Test helper generation (only in #[cfg(test)])
Total: 2,917 lines of macro code that eliminates 600-800 lines per entity.

The Five Macros

Here’s what AI generated:

1. DomainAggregate (Event Sourcing)

Before (100 lines of boilerplate):
pub struct Lead {
    id: LeadId,
    tenant_id: String,
    name: String,
    version: u64,
    uncommitted_events: Vec<LeadEvent>,
}

impl Lead {
    pub fn replay(events: Vec<LeadEvent>) -> Option<Self> {
        if events.is_empty() {
            return None;
        }

        let first = events.first()?;
        let created = match first {
            LeadEvent::Created(e) => e,
            _ => return None,
        };

        let mut aggregate = Self::from_created_event(created);

        for event in events.into_iter().skip(1) {
            aggregate.apply_event(&event);
            aggregate.version += 1;
        }

        Some(aggregate)
    }

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

    pub fn take_uncommitted_events(&mut self) -> Vec<LeadEvent> {
        std::mem::take(&mut self.uncommitted_events)
    }

    pub fn version(&self) -> u64 {
        self.version
    }

    fn record_event(&mut self, event: LeadEvent) {
        self.apply_event(&event);
        self.version += 1;
        self.uncommitted_events.push(event);
    }
}
After (5 lines + macro):
#[derive(DomainAggregate)]
#[aggregate(
    event = "LeadEvent",
    id_field = "id",
    id_type = "LeadId",
)]
pub struct Lead {
    id: LeadId,
    tenant_id: String,
    name: String,
    version: u64,
    #[serde(skip)]
    uncommitted_events: Vec<LeadEvent>,
}

// Only need to implement domain-specific logic:
impl Lead {
    fn apply_event(&mut self, event: &LeadEvent) {
        match event {
            LeadEvent::Created(e) => self.name = e.name.clone(),
            LeadEvent::Updated(e) => self.name = e.name.clone(),
        }
    }

    fn from_created_event(event: &LeadCreated) -> Self {
        Self {
            id: event.id.clone(),
            tenant_id: event.tenant_id.clone(),
            capsule_id: event.capsule_id.clone(),
            name: event.name.clone(),
            version: 0,
            uncommitted_events: vec![],
        }
    }
}
Generated methods:
  • replay(events) - Event sourcing reconstruction
  • uncommitted_events() - Event access
  • take_uncommitted_events() - Drain events
  • version() - Version tracking
  • record_event(event) - Internal event recording
Test helpers (only in #[cfg(test)]):
  • test_fixture() - Create test instances
  • assert_uncommitted_events(count) - Verify events
  • assert_last_event_type(type) - Check event type

2. DynamoDbRepository (Infrastructure)

Before (300 lines per repository):
pub struct DynamoDbLeadRepository {
    client: DynamoDbClient,
    table_name: String,
}

impl DynamoDbLeadRepository {
    pub fn new(client: DynamoDbClient) -> Self { /* ... */ }

    async fn save(&self, aggregate: &Lead) -> Result<()> {
        let entity = LeadEntity::from_domain(aggregate);

        // METADATA item
        let metadata_item = entity.to_item_with_sk("METADATA")?;

        // LIST_ITEM for queries
        let list_item = entity.to_list_item()?;

        // TransactWriteItems with both
        self.client
            .transact_write_items()
            .transact_items(/* METADATA */)
            .transact_items(/* LIST_ITEM */)
            .send()
            .await?;

        Ok(())
    }

    async fn find_by_id(&self, id: &LeadId) -> Result<Option<Lead>> {
        let pk = format!("LEAD#{}", id.0);

        let result = self.client
            .get_item()
            .table_name(&self.table_name)
            .key("PK", AttributeValue::S(pk))
            .key("SK", AttributeValue::S("METADATA".to_string()))
            .send()
            .await?;

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

    // ... 250 more lines for list(), delete(), update(), etc.
}
After (5 lines + macro):
#[derive(DynamoDbRepository)]
#[repository(
    entity = "LeadEntity",
    domain = "Lead",
    event = "LeadEvent",
    table = "crm_leads",
    gsi_name_lookup = "gsi1_by_name",  // Optional
    multi_item = true,  // METADATA + LIST_ITEM pattern
)]
pub struct DynamoDbLeadRepository;
Generated methods:
  • new(client) - Constructor
  • save(aggregate) - TransactWriteItems with METADATA + LIST_ITEM
  • find_by_id(id) - GetItem query
  • list(tenant_id, capsule_id) - Query by GSI
  • delete(id) - DeleteItem
  • find_by_name(name) - GSI query (if gsi_name_lookup specified)

3. CachedRepository (Performance)

Before (150 lines per cached repository):
pub struct CachedLeadRepository<R: LeadRepository> {
    inner: Arc<R>,
    cache: Arc<Cache<LeadId, Arc<Lead>>>,
}

impl<R: LeadRepository> CachedLeadRepository<R> {
    pub fn new(inner: R) -> Self {
        let cache = Cache::builder()
            .max_capacity(10_000)
            .time_to_live(Duration::from_secs(300))
            .build();

        Self {
            inner: Arc::new(inner),
            cache: Arc::new(cache),
        }
    }
}

#[async_trait]
impl<R: LeadRepository + Send + Sync> LeadRepository for CachedLeadRepository<R> {
    async fn save(&self, aggregate: &Lead) -> Result<()> {
        self.inner.save(aggregate).await?;
        self.cache.invalidate(&aggregate.id);  // Write-invalidate
        Ok(())
    }

    async fn find_by_id(&self, id: &LeadId) -> Result<Option<Lead>> {
        if let Some(cached) = self.cache.get(id) {
            return Ok(Some((*cached).clone()));
        }

        let result = self.inner.find_by_id(id).await?;

        if let Some(ref aggregate) = result {
            self.cache.insert(id.clone(), Arc::new(aggregate.clone()));
        }

        Ok(result)
    }

    // ... 100 more lines for list(), delete(), invalidate(), etc.
}
After (3 lines + macro):
#[derive(CachedRepository)]
#[cache(
    repository = "DynamoDbLeadRepository",
    domain = "Lead",
    id_type = "LeadId",
    max_capacity = 10_000,
    ttl_secs = 300,
)]
pub struct CachedLeadRepository;
Generated features:
  • Read-through caching on find_by_id()
  • Write-invalidate on save() and delete()
  • TTL-based expiration
  • LRU eviction policy (Moka)

The Impact

Here’s what happened when we applied these macros to 20 entities: Before macros:
20 entities × 680 lines boilerplate = 13,600 lines

Repository.rs files:
- lead_repository.rs: 380 lines
- account_repository.rs: 410 lines
- opportunity_repository.rs: 450 lines
(... 17 more)
After macros:
20 entities × 120 lines (domain logic only) = 2,400 lines
+ eva-macros infrastructure: 2,917 lines
= 5,317 lines total

Savings: 13,600 - 5,317 = 8,283 lines (61% reduction)

Repository.rs files:
- lead_repository.rs: 45 lines (90% reduction)
- account_repository.rs: 52 lines (87% reduction)
- opportunity_repository.rs: 68 lines (85% reduction)
Individual savings per entity:
  • DomainAggregate: 100 lines → 5 lines (95% reduction)
  • DynamoDbRepository: 300 lines → 5 lines (98% reduction)
  • CachedRepository: 150 lines → 3 lines (98% reduction)
  • InMemoryRepository: 80 lines → 5 lines (94% reduction)
  • DomainEvent helpers: 50 lines → 4 lines (92% reduction)

What Went Wrong

Mistake: The initial DynamoDbRepository macro generated code that didn’t handle GSI queries correctly.Why it failed: AI generated Query operations but forgot to specify the GSI name via .index_name(). Queries were hitting the primary table instead of the GSI.How we fixed it: Added attribute validation to the macro:
#[proc_macro_derive(DynamoDbRepository, attributes(repository))]
pub fn derive_dynamodb_repository(input: TokenStream) -> TokenStream {
    let attrs = parse_attrs(&input)?;

    // Validate: if gsi_name_lookup is specified, ensure GSI exists
    if let Some(gsi_field) = &attrs.gsi_name_lookup {
        if attrs.gsi_index_name.is_none() {
            return compile_error!(
                "gsi_name_lookup requires gsi_index_name attribute"
            );
        }
    }

    // Generate code...
}
Lesson: AI writes working code for the happy path but misses validation. Proc macros need compile-time checks to catch configuration errors.

The Test Helpers

One of the best features AI added: test helper generation. Example:
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_lead_creation() {
        // Macro-generated test fixture
        let mut lead = Lead::test_fixture("tenant-123", "PRODUS");

        lead.update_name("New Name".to_string());

        // Macro-generated assertions
        lead.assert_uncommitted_events(1);
        lead.assert_last_event_type("lead_updated");
    }

    #[test]
    fn test_event_replay() {
        let events = vec![
            LeadEvent::test_created(/* ... */),
            LeadEvent::test_updated(/* ... */),
        ];

        let lead = Lead::replay(events).unwrap();

        assert_eq!(lead.name, "Updated Name");
        assert_eq!(lead.version, 1);
    }
}
Generated test helpers:
  • test_fixture() - Create test instances
  • test_created(), test_updated() - Generate test events
  • assert_uncommitted_events() - Verify event count
  • assert_last_event_type() - Check event type
  • assert_roundtrip() - Test serialization
These helpers are only generated in test builds (#[cfg(test)]), so they don’t bloat production binaries.

Key Learnings

AI Strength

AI is perfect for extracting repetitive patterns. It analyzed 5 entities and generated macros that work for 20+ entities with zero modifications.

AI Weakness

AI doesn’t validate configurations. The macro API design (what attributes to support) had to be human-driven.

Human Role

Humans design the macro API and add compile-time validation. AI implements the code generation logic once the API is defined.

Process Insight

The best macros generate code that looks like what you’d write by hand. AI-generated macros should be indistinguishable from manual implementations.

Actionable Takeaways

If you’re considering code generation macros:
  1. Find the repetition first - Don’t write macros until you have 5+ examples of the pattern. AI needs repetition to extract patterns.
  2. Design the API, then generate - Spend time on the macro attributes (what configuration to expose). AI can implement once you define the interface.
  3. Add compile-time validation - Use proc-macro compile errors to catch configuration mistakes early. Better than runtime panics.
Pro tip: Use cargo expand to inspect macro-generated code during development. It shows you exactly what the macro expands to, making debugging 10x easier.

Metrics

  • Lines before macros: 13,600 (boilerplate)
  • Lines after macros: 5,317 (domain logic + infrastructure)
  • Reduction: 8,283 lines (61%)
  • Per-entity reduction: 80% (560 lines → 120 lines)

The Macro Infrastructure

The macro crate itself is well-structured:
eva-macros/
├── Cargo.toml
├── README.md (387 lines of documentation)
├── src/
│   ├── lib.rs (405 lines - macro exports)
│   ├── domain_aggregate.rs (272 lines)
│   ├── domain_event.rs (311 lines)
│   ├── inmemory_repository.rs (284 lines)
│   ├── dynamodb_repository.rs (539 lines)
│   └── cached_repository.rs (499 lines)
└── tests/
    └── domain_aggregate_test.rs (212 lines)
Total: 2,917 lines of macro infrastructure that eliminates 8,283 lines of boilerplate across the platform. ROI: Paid for itself after the 4th entity was refactored.

Resources & Further Reading


Next in This Series

We’ve completed the 5-article series on multi-agent AI workflows. Coming up: deep dives into specific implementation challenges and patterns.

More Articles

Browse all articles in the Building with AI series

Discussion

Share Your Experience

Do you use proc macros? How do you balance code generation vs manual implementation?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.