16 minlesson

Response Caching & Pipeline Status

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:

csharp
1public async Task<List<PipelineStatusDto>> GetPipelineAsync()
2{
3 var pipeline = await _context.Parcels
4 .GroupBy(p => p.Status)
5 .Select(g => new PipelineStatusDto
6 {
7 Status = g.Key.ToString(),
8 Count = g.Count()
9 })
10 .OrderByDescending(p => p.Count)
11 .ToListAsync();
12
13 return pipeline;
14}

This generates a single SQL query:

sql
1SELECT Status, COUNT(*) AS Count
2FROM Parcels
3GROUP BY Status
4ORDER BY Count DESC

The result might look like:

StatusCount
InTransit1,247
Delivered892
LabelCreated456
OutForDelivery203
PickedUp178
Exception34
Returned12

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:

csharp
1public async Task<List<PipelineStatusDto>> GetPipelineAsync()
2{
3 var counts = await _context.Parcels
4 .GroupBy(p => p.Status)
5 .Select(g => new
6 {
7 Status = g.Key,
8 Count = g.Count()
9 })
10 .ToListAsync();
11
12 var allStatuses = Enum.GetValues<ParcelStatus>();
13
14 return allStatuses.Select(status => new PipelineStatusDto
15 {
16 Status = status.ToString(),
17 Count = counts.FirstOrDefault(c => c.Status == status)?.Count ?? 0
18 }).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

csharp
1public class DeliveryStatsDto
2{
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

csharp
1public class ExceptionReasonDto
2{
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

csharp
1public class ServiceBreakdownDto
2{
3 public string ServiceType { get; set; } = string.Empty;
4 public int Count { get; set; }
5 public double AverageDeliveryTimeHours { get; set; }
6}

PipelineStatusDto

csharp
1public class PipelineStatusDto
2{
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:

csharp
1[ApiController]
2[Route("api/[controller]")]
3public class AnalyticsController : ControllerBase
4{
5 private readonly IAnalyticsService _analytics;
6
7 public AnalyticsController(IAnalyticsService analytics)
8 {
9 _analytics = analytics;
10 }
11
12 [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;
20
21 var stats = await _analytics.GetDeliveryStatsAsync(fromDate, toDate);
22 return Ok(stats);
23 }
24
25 [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;
33
34 var reasons = await _analytics.GetTopExceptionReasonsAsync(fromDate, toDate);
35 return Ok(reasons);
36 }
37
38 [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;
46
47 var breakdown = await _analytics.GetServiceBreakdownAsync(fromDate, toDate);
48 return Ok(breakdown);
49 }
50
51 [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-31
2GET /api/analytics/exception-reasons?from=2025-01-01&to=2025-01-31
3GET /api/analytics/service-breakdown?from=2025-01-01&to=2025-01-31
4GET /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:

http
1Cache-Control: public, max-age=300

This tells three different layers to cache the response:

  1. The browser stores the response and reuses it for 300 seconds
  2. CDNs and reverse proxies (if present) cache the response for downstream clients
  3. 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:

csharp
1// In Program.cs
2builder.Services.AddResponseCaching();
3
4var app = builder.Build();
5
6app.UseResponseCaching(); // Must be before endpoint mapping
7app.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:

csharp
1[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:

csharp
1builder.Services.AddControllers(options =>
2{
3 options.CacheProfiles.Add("Analytics", new CacheProfile
4 {
5 Duration = 600,
6 Location = ResponseCacheLocation.Any
7 });
8
9 options.CacheProfiles.Add("RealTime", new CacheProfile
10 {
11 Duration = 60,
12 Location = ResponseCacheLocation.Any
13 });
14});

Then reference the profile by name:

csharp
1[HttpGet("exception-reasons")]
2[ResponseCache(CacheProfileName = "Analytics")]
3public async Task<ActionResult<List<ExceptionReasonDto>>> GetExceptionReasons(...)
csharp
1[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:

LocationCache-Control HeaderWho Caches
AnypublicBrowser, CDN, server middleware
ClientprivateBrowser only
Noneno-cacheNo 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 = Client or 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:

csharp
1// Services
2builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
3builder.Services.AddResponseCaching();
4
5builder.Services.AddControllers(options =>
6{
7 options.CacheProfiles.Add("Analytics", new CacheProfile
8 {
9 Duration = 600,
10 Location = ResponseCacheLocation.Any
11 });
12
13 options.CacheProfiles.Add("RealTime", new CacheProfile
14 {
15 Duration = 60,
16 Location = ResponseCacheLocation.Any
17 });
18});
19
20var app = builder.Build();
21
22// Middleware - order matters
23app.UseResponseCaching();
24app.MapControllers();

Testing the Cache Headers

Use curl -I to inspect response headers and verify caching is working:

bash
1curl -I https://localhost:5001/api/analytics/delivery-stats
2
3HTTP/1.1 200 OK
4Content-Type: application/json
5Cache-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:

csharp
1// Instead of throwing when no data exists
2if (totalParcels == 0)
3{
4 return new DeliveryStatsDto
5 {
6 From = from,
7 To = to,
8 TotalParcels = 0,
9 Delivered = 0,
10 InTransit = 0,
11 Exceptions = 0,
12 AverageDeliveryTimeHours = 0,
13 OnTimePercentage = 0
14 };
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

  1. The pipeline endpoint uses GroupBy(p => p.Status) with no date filter for a live snapshot
  2. Fill in missing enum values with zero counts so the front-end always gets a complete dataset
  3. [ResponseCache] sets Cache-Control headers; the response caching middleware performs server-side caching
  4. Use VaryByQueryKeys to cache different query parameter combinations separately
  5. Cache profiles centralize caching configuration for related endpoints
  6. Use ResponseCacheLocation.Any for non-user-specific analytics data
  7. Return zero-value DTOs instead of errors when the date range has no matching data
Response Caching & Pipeline Status - Anko Academy