.NET Microservices, Performance & Docker

.NET Microservices, Performance & Docker

Explore .net microservices, performance & docker code snippets and tutorials

.NET Microservices, Performance & Docker

EF Core performance beyond N+1 and indexes: DbContext pooling, compiled queries, ExecuteUpdate

Reads: AsNoTracking() and projection to a DTO with only the columns you need (no SELECT *).

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ======================================================================
// Q: Beyond N+1 and indexes: how do you make EF Core faster?
//
// - Reads: AsNoTracking() and projection to a DTO with only the columns you need
//   (no SELECT *).
// - Writes: many changes in one SaveChangesAsync (EF batches them), not
//   SaveChanges inside a loop. For bulk imports: COPY (PostgreSQL) or
//   SqlBulkCopy.
// - Keyset pagination (Q9), and always with a limit: no endpoint without Take.
// - Reports: materialized views or a read replica, so heavy queries don't slow
//   down order processing.
// - Connection pool: right-sized, and connections are released quickly (short
//   scopes, no transactions waiting on HTTP calls).
// - HTTP level: response compression (Brotli / Gzip) for large JSON, HTTP/2,
//   smaller payloads.
// ======================================================================

// DbContext pooling: reuses instances, fewer allocations per request
builder.Services.AddDbContextPool<AppDbContext>(o => o.UseNpgsql(connectionString));

// Compiled query for very frequent queries: the LINQ is not re-translated every time
private static readonly Func<AppDbContext, string, Task<ProductDto?>> GetBySku =
    EF.CompileAsyncQuery((AppDbContext db, string sku) =>
        db.Products.AsNoTracking()
          .Where(p => p.Sku == sku)
          .Select(p => new ProductDto(p.Sku, p.Name, p.ListPrice))
          .FirstOrDefault());

// Bulk changes without loading entities (EF Core 7+)
await db.Products
    .Where(p => p.SupplierId == supplierId)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.ListPrice, p => p.ListPrice * 1.05m), ct);

await db.CartItems
    .Where(c => c.CreatedAt < DateTime.UtcNow.AddDays(-30))
    .ExecuteDeleteAsync(ct);
.NET Microservices, Performance & Docker

Reducing memory allocations and GC pressure in .NET: streaming, Span<T>, BenchmarkDotNet

Streaming instead of buffering: large exports with IAsyncEnumerable, files with Stream rather than ReadToEnd() / byte[].

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ======================================================================
// Q: How do you reduce memory allocations and GC pressure?
//
// - Streaming instead of buffering: large exports with IAsyncEnumerable, files
//   with Stream rather than ReadToEnd() / byte[].
// - Span<T> / ReadOnlySpan<char> for parsing without new strings, ArrayPool<T>
//   for temporary buffers.
// - Objects > 85 KB go to the Large Object Heap, which is collected rarely and
//   expensively.
// - System.Text.Json source generators for faster serialization without
//   reflection (also needed for Native AOT).
// - Memory leaks: usually static collections or singletons that keep growing,
//   event handlers that are never removed, a cache without a size limit.
// Don't optimize allocations in code that runs 10 times a day. Span and
// ArrayPool make sense in hot paths (parsing ERP files with millions of lines,
// middleware), once profiling has shown it.
// ======================================================================

// Streaming export: does not load 100,000 orders into memory
app.MapGet("/api/orders/export", (AppDbContext db) =>
    db.Orders.AsNoTracking()
      .Select(o => new OrderExportDto(o.Id, o.OrderNumber, o.TotalNet))
      .AsAsyncEnumerable());                          // JSON is written while rows are read

// Benchmark: measure before changing code
[MemoryDiagnoser]
public class SkuParsingBenchmarks
{
    private const string Line = "SKU-12345;10;19.90";

    [Benchmark(Baseline = true)]
    public decimal WithSplit() =>
        decimal.Parse(Line.Split(';')[2], CultureInfo.InvariantCulture);       // array + 3 strings

    [Benchmark]
    public decimal WithSpan()
    {
        ReadOnlySpan<char> s = Line;                                         // 0 allocations
        return decimal.Parse(s[(s.LastIndexOf(';') + 1)..], CultureInfo.InvariantCulture);
    }
}
// dotnet run -c Release
.NET Microservices, Performance & Docker

Does async make C# code faster? Throughput, Task.WhenAll and Parallel.ForEachAsync

Async does not make a single request faster.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ======================================================================
// Q: Does async make code faster? How do you get better throughput?
//
// Async does not make a single request faster. It frees the thread while waiting
// for I/O, so the server can handle more requests concurrently. For speed within
// one request: run independent calls in parallel.
// - Careful: DbContext is not thread-safe. Don't use Task.WhenAll with queries
//   on the same context.
// - Task.Run inside ASP.NET Core for I/O doesn't help: it just occupies another
//   thread.
// - ConfigureAwait(false) is not needed in ASP.NET Core (there is no
//   SynchronizationContext). In shared libraries it is good practice.
// - ValueTask for hot paths that usually complete synchronously (e.g. a cache
//   hit).
// ======================================================================

// Sequential: 200 + 200 + 200 = ~600 ms
var price = await pricing.GetAsync(sku, ct);
var stock = await inventory.GetAsync(sku, ct);
var eta   = await shipping.GetEtaAsync(sku, ct);

// Parallel: ~200 ms (independent HTTP calls)
var priceTask = pricing.GetAsync(sku, ct);
var stockTask = inventory.GetAsync(sku, ct);
var etaTask   = shipping.GetEtaAsync(sku, ct);
await Task.WhenAll(priceTask, stockTask, etaTask);
var (price2, stock2, eta2) = (await priceTask, await stockTask, await etaTask);

// Batch with a parallelism limit: we don't "flood" the ERP
await Parallel.ForEachAsync(skus,
    new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
    async (sku, token) => await erp.UpdatePriceAsync(sku, token));
.NET Microservices, Performance & Docker

Cache invalidation and cache stampede in .NET: HybridCache tags, TTLs and what not to cache

Cache-aside: read from the cache, otherwise from the database, and write to the cache.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ======================================================================
// Q: How do you handle cache invalidation and cache stampede?
//
// - Cache-aside: read from the cache, otherwise from the database, and write to
//   the cache. Always with a TTL, even when there is explicit invalidation, so
//   that a mistake fixes itself.
// - Invalidation on change: when a value changes (e.g. a PriceListUpdated event
//   from the ERP), delete the related keys or tags.
// - Stampede: a popular key expires and 500 requests hit the database at once.
//   HybridCache solves this on its own (only one factory call per key).
//   IMemoryCache.GetOrCreateAsync does not. Random jitter on the TTL also helps.
// - Multiple instances with IMemoryCache: each one holds a different version of
//   the data. Solution: Redis, or HybridCache with a short L1 TTL.
// What I do NOT cache, or cache very carefully:
// - Stock at checkout: a stale value means overselling. In the product list an
//   approximate value is enough, but at checkout you check the database (Q3).
// - Per-customer or per-tenant data without the customerId / tenantId in the
//   key: data leaks from one customer to another (Q36).
// - The cache is an optimization, not the source of truth. If Redis goes down,
//   the application keeps working (more slowly) from the database.
// ======================================================================

// Price list change from the ERP: invalidation by tag
public class PriceListUpdatedHandler(HybridCache cache, IOutputCacheStore outputCache)
    : IDomainEventHandler<PriceListUpdated>
{
    public async Task Handle(PriceListUpdated e, CancellationToken ct)
    {
        await cache.RemoveByTagAsync($"prices:{e.PriceListId}", ct);
        await outputCache.EvictByTagAsync("catalog", ct);
    }
}
.NET Microservices, Performance & Docker

ASP.NET Core caching options: IMemoryCache, Redis, HybridCache, output caching, HTTP and CDN

IMemoryCache: lives in process memory.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// ======================================================================
// Q: Which caching options exist in ASP.NET Core, and when do you use each?
//
// - IMemoryCache: lives in process memory. For a single instance and small data
//   that rarely changes (settings, country lists).
// - IDistributedCache (Redis): external, shared by all instances. For multiple
//   instances, sessions, and data that must be identical everywhere.
// - HybridCache (.NET 9): L1 in memory + L2 in Redis. The new default: stampede
//   protection and tags for invalidation.
// - Output caching: the whole HTTP response, on the server. For public pages or
//   APIs (catalog) that are the same for everyone.
// - HTTP caching (Cache-Control, ETag): browser, proxy. You avoid the whole
//   request or send 304 Not Modified.
// - CDN (Azure Front Door): edge servers. Static assets, product images.
// Output caching does not cache authenticated requests by default. In B2B every
// customer has their own prices, so the response is not the same for everyone.
// There you cache the shared data (catalog) and compute the price per customer
// separately.
// ======================================================================

// HybridCache: automatically uses Redis as L2 if it is registered
builder.Services.AddStackExchangeRedisCache(o =>
    o.Configuration = builder.Configuration.GetConnectionString("Redis"));
builder.Services.AddHybridCache(o => o.DefaultEntryOptions = new HybridCacheEntryOptions
{
    Expiration = TimeSpan.FromMinutes(10),            // L2 (Redis)
    LocalCacheExpiration = TimeSpan.FromMinutes(1)    // L1 (memory)
});

public class CatalogService(HybridCache cache, AppDbContext db)
{
    public ValueTask<ProductDto?> GetProductAsync(string sku, CancellationToken ct) =>
        cache.GetOrCreateAsync(
            $"product:{sku}",
            async token => await db.Products.AsNoTracking()
                .Where(p => p.Sku == sku)
                .Select(p => new ProductDto(p.Sku, p.Name, p.ListPrice))
                .FirstOrDefaultAsync(token),
            tags: ["catalog"],
            cancellationToken: ct);
}

// Output caching for a public catalog
builder.Services.AddOutputCache(o => o.AddPolicy("catalog", b => b
    .Expire(TimeSpan.FromMinutes(5))
    .SetVaryByQuery("category", "page")
    .Tag("catalog")));

app.UseOutputCache();
app.MapGet("/api/catalog", GetCatalog).CacheOutput("catalog");