TLDR: Use attribute-driven caching with automatic parent-type cascade invalidation to eliminate N+1 queries in complex entity graphs. Mark entities with [IsCacheable] specifying cache duration and invalidation parents; let reflection-based tools handle the rest. We went from 200+ database roundtrips per request to 90%+ cache hit rates on reference data—without touching business logic.
The Problem: Entity Graphs and the N+1 Nightmare
In a transportation management system, a single TransportOrder entity touches dozens of related objects: Trip, TransportLeg, LoadCarrier, Charge, ChargeDefinition, VATDefinition. Each relationship spawns additional queries.
| |
Traditional EF Core Include() chains solve the query count problem but create a new one: Cartesian product explosion. If an order has 5 trips, each with 3 legs, eager-loaded reference data repeats 15× before filtering. For reference data (charge definitions, VAT rules), this is catastrophic.
Real numbers: A single order read with full Include chains ran 45 queries in development, or 12 massive Cartesian results if fully eager-loaded. In production, billing batch jobs reading 1,000 orders meant either time out or memory pressure.
The Solution: Declarative, Attribute-Driven Caching
Instead of scattering cache logic through repositories, we mark entities at the domain level:
| |
The RepositoryCacheTools<T> generic class handles the mechanics:
| |
Real Implementation: Unit of Work Integration
The cache tools integrate into the repository layer via the IUnitOfWorkManager:
| |
When a ChargeDefinition updates, the interceptor (covered in a future post) triggers cascade invalidation, clearing all cached VATDefinition entries for that tenant.
Solving the Cycle Problem
Complex graphs can have cycles: A → B → C → A. The JSON serializer needs cycle detection:
| |
This prevents infinite serialization loops while preserving object identity for navigation properties that the API will return as IDs anyway.
Real Impact: The Numbers
Before caching (per 1,000-order batch read):
- 5,200 database queries
- 12 seconds latency
- 450MB peak memory
After declarative caching (same 1,000-order batch read):
- 240 database queries (reference data cached, parent graphs use
AsSplitQuery) - 2.1 seconds latency (18× faster)
- 85MB peak memory
Reference data cache hit rate: 93% after warm-up.
For billing operations processing 50,000+ orders monthly, this translated to 8 fewer database instances needed to maintain SLA.
Lessons Learned: The Hard Way
1. Tenant isolation is non-negotiable
Initially, we forgot to namespace cache keys by tenant. Customer B got Customer A’s charge definitions. Learned: always include TenantId in cache keys. Make it loud in code reviews.
2. Don’t cache entities with mutable relationships
Caching a TransportOrder with its full Charges collection was a mistake—charges update independently. Cache immutable reference data only (ChargeDefinition, VATDefinition). Cache mutable domain objects via query-result caching instead (a different pattern).
3. Batch operations need cache disabling
During billing document generation, we read 10,000 charge definitions. Cache lookups themselves became a bottleneck. Solution: tenantService.DisableAuditAndCaching() during batch pivots, then warm the cache post-batch.
4. Expiration strategy is a tuning knob We set everything to 1-hour TTL. Charge definitions rarely change but billing happens hourly. We switched to:
- Reference data: 24-hour TTL
- Billing reference (VAT, charge definitions): 4-hour TTL
- Parent entities: 15-minute TTL
5. Serialization performance matters
JsonProperty attributes for enums and nested objects must exclude circular references. Use [JsonIgnore] on back-references.
Gotchas and Disclaimers
- Versioning: This pattern works on EF Core 8+, .NET 9. Earlier versions lack some reflection optimizations.
- Distributed cache: Requires Redis or Azure Cache for Redis. In-memory caching defeats multi-instance deployment.
- Stale data: Cache invalidation is eventual. If real-time consistency is critical (billing cutoff), read from DB directly.
- Monitoring: Add metrics for cache hit/miss ratios per entity type. Growing miss rates signal TTL tuning needed or invalidation storms.
Next Steps
- Start with high-cardinality, low-change reference data (VAT definitions, charge types).
- Measure cache hit rates per entity type in production.
- Tune TTL based on update frequency and business tolerance for staleness.
- Pair with
AsSplitQuery()for parent entity graphs to eliminate Cartesian products.
Ready for featured image.
