Response Caching & Pipeline Status
This presentation covers building the pipeline status endpoint, designing the analytics controller with response caching, and wiring everything together with proper DTOs and HTTP conventions.
The Pipeline Status Endpoint
The pipeline endpoint answers a simple question: how many parcels are in each status right now? This gives operations teams a live snapshot of the system.
Pipeline Query
Unlike the other analytics queries, the pipeline has no date filter. It groups all parcels by their current status:
csharp1public async Task<List<PipelineStatusDto>> GetPipelineAsync()2{3 var pipeline = await _context.Parcels4 .GroupBy(p => p.Status)5 .Select(g => new PipelineStatusDto6 {7 Status = g.Key.ToString(),8 Count = g.Count()9 })10 .OrderByDescending(p => p.Count)11 .ToListAsync();1213 return pipeline;14}
This generates a single SQL query:
sql1SELECT Status, COUNT(*) AS Count2FROM Parcels3GROUP BY Status4ORDER BY Count DESC
The result might look like:
| Status | Count |
|---|---|
| InTransit | 1,247 |
| Delivered | 892 |
| LabelCreated | 456 |
| OutForDelivery | 203 |
| PickedUp | 178 |
| Exception | 34 |
| Returned | 12 |
Including All Statuses
The GroupBy query only returns statuses that have at least one parcel. If no parcels are in the Returned status, that row is missing from the result. To always include all statuses, fill in the gaps after the query:
csharp1public async Task<List<PipelineStatusDto>> GetPipelineAsync()2{3 var counts = await _context.Parcels4 .GroupBy(p => p.Status)5 .Select(g => new6 {7 Status = g.Key,8 Count = g.Count()9 })10 .ToListAsync();1112 var allStatuses = Enum.GetValues<ParcelStatus>();1314 return allStatuses.Select(status => new PipelineStatusDto15 {16 Status = status.ToString(),17 Count = counts.FirstOrDefault(c => c.Status == status)?.Count ?? 018 }).ToList();19}
Now every ParcelStatus value appears in the response, even if its count is zero. This is better for the front-end because the UI does not need to handle missing statuses.
Summary DTOs in Detail
Each analytics endpoint returns a specific DTO. Keep them in a dedicated file or folder for clarity.
DeliveryStatsDto
csharp1public class DeliveryStatsDto2{3 public DateTime From { get; set; }4 public DateTime To { get; set; }5 public int TotalParcels { get; set; }6 public int Delivered { get; set; }7 public int InTransit { get; set; }8 public int Exceptions { get; set; }9 public double AverageDeliveryTimeHours { get; set; }10 public double OnTimePercentage { get; set; }11}
The From and To fields echo back the date range that was used. This makes the response self-describing: the client can see exactly what time window the stats cover.
ExceptionReasonDto
csharp1public class ExceptionReasonDto2{3 public string Reason { get; set; } = string.Empty;4 public int Count { get; set; }5 public double Percentage { get; set; }6}
The Percentage field shows each reason's share of total exceptions. This is easier for dashboards to render than raw counts alone.
ServiceBreakdownDto
csharp1public class ServiceBreakdownDto2{3 public string ServiceType { get; set; } = string.Empty;4 public int Count { get; set; }5 public double AverageDeliveryTimeHours { get; set; }6}
PipelineStatusDto
csharp1public class PipelineStatusDto2{3 public string Status { get; set; } = string.Empty;4 public int Count { get; set; }5}
Why Strings Instead of Enums?
The DTOs use string for Status, Reason, and ServiceType instead of their enum types. This makes the JSON serialization predictable. The client receives "InTransit" instead of 2 (the integer enum value). If you use JsonStringEnumConverter globally, you could use the enum types directly, but string properties are safer across different serializer configurations.
The Analytics Controller
Wire up all four endpoints in a single controller:
csharp1[ApiController]2[Route("api/[controller]")]3public class AnalyticsController : ControllerBase4{5 private readonly IAnalyticsService _analytics;67 public AnalyticsController(IAnalyticsService analytics)8 {9 _analytics = analytics;10 }1112 [HttpGet("delivery-stats")]13 [ResponseCache(Duration = 300)]14 public async Task<ActionResult<DeliveryStatsDto>> GetDeliveryStats(15 [FromQuery] DateTime? from,16 [FromQuery] DateTime? to)17 {18 var fromDate = from ?? DateTime.UtcNow.AddDays(-30);19 var toDate = to ?? DateTime.UtcNow;2021 var stats = await _analytics.GetDeliveryStatsAsync(fromDate, toDate);22 return Ok(stats);23 }2425 [HttpGet("exception-reasons")]26 [ResponseCache(Duration = 600)]27 public async Task<ActionResult<List<ExceptionReasonDto>>> GetExceptionReasons(28 [FromQuery] DateTime? from,29 [FromQuery] DateTime? to)30 {31 var fromDate = from ?? DateTime.UtcNow.AddDays(-30);32 var toDate = to ?? DateTime.UtcNow;3334 var reasons = await _analytics.GetTopExceptionReasonsAsync(fromDate, toDate);35 return Ok(reasons);36 }3738 [HttpGet("service-breakdown")]39 [ResponseCache(Duration = 600)]40 public async Task<ActionResult<List<ServiceBreakdownDto>>> GetServiceBreakdown(41 [FromQuery] DateTime? from,42 [FromQuery] DateTime? to)43 {44 var fromDate = from ?? DateTime.UtcNow.AddDays(-30);45 var toDate = to ?? DateTime.UtcNow;4647 var breakdown = await _analytics.GetServiceBreakdownAsync(fromDate, toDate);48 return Ok(breakdown);49 }5051 [HttpGet("pipeline")]52 [ResponseCache(Duration = 60)]53 public async Task<ActionResult<List<PipelineStatusDto>>> GetPipeline()54 {55 var pipeline = await _analytics.GetPipelineAsync();56 return Ok(pipeline);57 }58}
Default Date Range Logic
Each endpoint that accepts a date range uses DateTime.UtcNow.AddDays(-30) as the default from and DateTime.UtcNow as the default to. This ensures the query always has bounds, preventing full-table scans.
Route Structure
All endpoints are under /api/analytics/:
1GET /api/analytics/delivery-stats?from=2025-01-01&to=2025-01-312GET /api/analytics/exception-reasons?from=2025-01-01&to=2025-01-313GET /api/analytics/service-breakdown?from=2025-01-01&to=2025-01-314GET /api/analytics/pipeline
Response Caching Deep Dive
How [ResponseCache] Works
The [ResponseCache] attribute does not cache anything by itself. It sets the Cache-Control HTTP header on the response:
http1Cache-Control: public, max-age=300
This tells three different layers to cache the response:
- The browser stores the response and reuses it for 300 seconds
- CDNs and reverse proxies (if present) cache the response for downstream clients
- ASP.NET Core response caching middleware (if configured) caches the response in server memory
Enabling Server-Side Caching
To enable the middleware that actually caches responses on the server:
csharp1// In Program.cs2builder.Services.AddResponseCaching();34var app = builder.Build();56app.UseResponseCaching(); // Must be before endpoint mapping7app.MapControllers();
Without the middleware, [ResponseCache] only sets HTTP headers. The browser and proxies cache, but every request that reaches your server still executes the controller action.
VaryByQueryKeys
The delivery stats endpoint accepts from and to query parameters. The cache must treat different date ranges as different cache entries:
csharp1[HttpGet("delivery-stats")]2[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "from", "to" })]3public async Task<ActionResult<DeliveryStatsDto>> GetDeliveryStats(4 [FromQuery] DateTime? from,5 [FromQuery] DateTime? to)6{7 // ...8}
Without VaryByQueryKeys, a cached response for January could be served when the client requests February data. The VaryByQueryKeys property tells the middleware to include the specified query parameters in the cache key.
Cache Profiles
If multiple endpoints share the same caching settings, define a cache profile to avoid repetition:
csharp1builder.Services.AddControllers(options =>2{3 options.CacheProfiles.Add("Analytics", new CacheProfile4 {5 Duration = 600,6 Location = ResponseCacheLocation.Any7 });89 options.CacheProfiles.Add("RealTime", new CacheProfile10 {11 Duration = 60,12 Location = ResponseCacheLocation.Any13 });14});
Then reference the profile by name:
csharp1[HttpGet("exception-reasons")]2[ResponseCache(CacheProfileName = "Analytics")]3public async Task<ActionResult<List<ExceptionReasonDto>>> GetExceptionReasons(...)
csharp1[HttpGet("pipeline")]2[ResponseCache(CacheProfileName = "RealTime")]3public async Task<ActionResult<List<PipelineStatusDto>>> GetPipeline()
Cache Location Options
The Location property controls who can cache the response:
| Location | Cache-Control Header | Who Caches |
|---|---|---|
Any | public | Browser, CDN, server middleware |
Client | private | Browser only |
None | no-cache | No one (forces revalidation) |
For analytics endpoints, Any (the default for public) is appropriate because the data is not user-specific. Every caller gets the same aggregated stats for a given date range.
When Not to Cache
Do not apply response caching to endpoints that:
- Return user-specific data (use
Location = Clientor do not cache) - Accept POST, PUT, or DELETE requests (only GET and HEAD are cacheable)
- Return data that must be real-time accurate (use very short durations instead)
Putting It All Together
Here is the complete Program.cs registration for the analytics feature:
csharp1// Services2builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();3builder.Services.AddResponseCaching();45builder.Services.AddControllers(options =>6{7 options.CacheProfiles.Add("Analytics", new CacheProfile8 {9 Duration = 600,10 Location = ResponseCacheLocation.Any11 });1213 options.CacheProfiles.Add("RealTime", new CacheProfile14 {15 Duration = 60,16 Location = ResponseCacheLocation.Any17 });18});1920var app = builder.Build();2122// Middleware - order matters23app.UseResponseCaching();24app.MapControllers();
Testing the Cache Headers
Use curl -I to inspect response headers and verify caching is working:
bash1curl -I https://localhost:5001/api/analytics/delivery-stats23HTTP/1.1 200 OK4Content-Type: application/json5Cache-Control: public, max-age=300
The Cache-Control header confirms the response is cacheable for 300 seconds.
Testing Cache Behavior
To verify server-side caching, watch the logs. The first request hits the controller and runs the database query. The second request (within the cache window) should return instantly without a log entry from the controller, because the middleware serves the cached response.
Error Handling for Analytics
Analytics endpoints should be resilient. If the database is empty or the date range returns no data, return a valid response with zero values rather than an error:
csharp1// Instead of throwing when no data exists2if (totalParcels == 0)3{4 return new DeliveryStatsDto5 {6 From = from,7 To = to,8 TotalParcels = 0,9 Delivered = 0,10 InTransit = 0,11 Exceptions = 0,12 AverageDeliveryTimeHours = 0,13 OnTimePercentage = 014 };15}
A dashboard that receives zeros can display "No data for this period." An error response forces the dashboard to handle failure states differently.
Key Takeaways
- The pipeline endpoint uses
GroupBy(p => p.Status)with no date filter for a live snapshot - Fill in missing enum values with zero counts so the front-end always gets a complete dataset
[ResponseCache]setsCache-Controlheaders; the response caching middleware performs server-side caching- Use
VaryByQueryKeysto cache different query parameter combinations separately - Cache profiles centralize caching configuration for related endpoints
- Use
ResponseCacheLocation.Anyfor non-user-specific analytics data - Return zero-value DTOs instead of errors when the date range has no matching data