Blog › ICP guides
.NET developer on retainer: ASP.NET Core architecture, C# language features, Entity Framework Core, and async patterns on monthly retainer
August 15, 2026 · ~20 min read
A healthcare SaaS company running an ASP.NET Core application on .NET 6 had accumulated three years of technical debt. The codebase had 847 nullable reference warnings suppressed with #pragma warning disable CS8600 pragmas. The EF Core models used lazy loading throughout, and the patient appointment history endpoint — called 4,000 times per day — was executing 180 SQL queries for a page of 50 appointments because it loaded each appointment’s practitioner, each practitioner’s clinic, and each clinic’s location in separate queries through lazy-loaded navigation properties. The middleware pipeline had no global exception handler, so unhandled exceptions returned stack traces in production error responses.
A fractional .NET architect on monthly retainer started with EF Core query logging: enabling LogLevel.Information for Microsoft.EntityFrameworkCore.Database.Command in development and running the appointment endpoint against a test database with 100 appointments confirmed 181 queries. The fix required adding AsSplitQuery() with explicit Include().ThenInclude() chains, disabling lazy loading, and wrapping the read path in AsNoTracking(). The query count dropped from 181 to 4, and the P99 response time dropped from 3.8 seconds to 85 milliseconds.
The second month’s work addressed the nullable reference situation: enabling #nullable enable at the project level, fixing the 847 warnings through a combination of null guards at controller boundary validation (using FluentValidation), null-forgiving operators at EF Core materialization boundaries where the database schema guaranteed non-null values, and record type refactoring for the 23 DTO classes that were using mutable properties where immutability was the intended design. The third month added RFC 7807 Problem Details middleware, migrated four high-frequency endpoints from Controller to Minimal API for measurable startup time improvement, and implemented IAsyncEnumerable streaming for the export endpoint that had been timing out on large datasets.
.NET developers, .NET architects, and C# consultants on monthly retainer — fractional .NET engineers, ASP.NET Core consultants, and EF Core performance advisors — do their highest-value work in the ASP.NET Core architecture, C# language feature adoption, EF Core query optimization, and async pattern design that produces the reliable, maintainable backend the engineering director defends to the CTO. This guide covers ASP.NET Core architecture in depth, modern C# language features, EF Core performance patterns, async programming, and .NET testing infrastructure — and how to structure a .NET developer retainer that makes the hours behind each optimization visible.
ASP.NET Core architecture
ASP.NET Core’s middleware pipeline, dependency injection container, and configuration system are the foundational pieces that a .NET architect designs once and maintains continuously. The decisions made in the pipeline and DI configuration propagate through every endpoint and service in the application.
Minimal APIs vs. Controllers
Minimal APIs (introduced in .NET 6) register endpoints directly on WebApplication or IEndpointRouteBuilder without the overhead of the Controller base class, MVC model binding, and action filter pipeline. They are appropriate for microservices and APIs where the per-request overhead of the full MVC pipeline is measurable, and for new services where the conventional Controller structure adds more ceremony than clarity. Controllers retain advantages for large existing codebases (existing filters, model binding customizations, and action result conventions), APIs with complex model binding scenarios, and teams that rely on MVC conventions for discoverability.
// Minimal API with endpoint groups (ASP.NET Core 7+):
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(); // RFC 7807 error responses
var app = builder.Build();
app.UseExceptionHandler(); // returns Problem Details for unhandled exceptions
app.UseAuthentication();
app.UseAuthorization();
var orders = app.MapGroup("/api/v1/orders")
.RequireAuthorization()
.WithOpenApi();
orders.MapGet("/", async (
[FromQuery] int page,
[FromQuery] int pageSize,
IOrderRepository repo,
CancellationToken ct) =>
{
var result = await repo.GetPagedAsync(page, pageSize, ct);
return Results.Ok(result);
})
.WithName("GetOrders")
.Produces<PagedResult<OrderDto>>()
.ProducesProblem(StatusCodes.Status401Unauthorized);
orders.MapPost("/", async (
[FromBody] CreateOrderRequest request,
IValidator<CreateOrderRequest> validator,
IOrderService service,
CancellationToken ct) =>
{
var validation = await validator.ValidateAsync(request, ct);
if (!validation.IsValid)
return Results.ValidationProblem(validation.ToDictionary());
var order = await service.CreateAsync(request, ct);
return Results.CreatedAtRoute("GetOrderById", new { id = order.Id }, order);
})
.WithName("CreateOrder")
.Produces<OrderDto>(StatusCodes.Status201Created)
.ProducesValidationProblem();
Middleware pipeline and IHostedService
// Middleware order matters — authentication before authorization:
app.UseExceptionHandler("/error"); // catches unhandled exceptions — must be first
app.UseHttpsRedirection();
app.UseRateLimiter(); // rate limiting before auth (blocks before auth overhead)
app.UseAuthentication(); // populates HttpContext.User
app.UseAuthorization(); // checks HttpContext.User against policy
app.MapControllers(); // route matching last
// Problem Details exception handler endpoint:
app.Map("/error", (HttpContext ctx) =>
{
var exception = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
return exception is NotFoundException
? Results.Problem(title: "Resource not found", statusCode: 404)
: Results.Problem(title: "An unexpected error occurred", statusCode: 500);
});
// IHostedService for background jobs:
public class OutboxProcessorService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxProcessorService> _logger;
public OutboxProcessorService(IServiceScopeFactory factory, ILogger<OutboxProcessorService> logger)
{
_scopeFactory = factory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _scopeFactory.CreateScope(); // new scope for scoped services
var processor = scope.ServiceProvider.GetRequiredService<IOutboxProcessor>();
await processor.ProcessPendingAsync(stoppingToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Outbox processing failed");
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
// Options pattern with validation and hot reload:
builder.Services.AddOptions<SmtpOptions>()
.BindConfiguration("Smtp")
.ValidateDataAnnotations()
.ValidateOnStart();
// IOptionsMonitor for hot reload (re-reads config on change):
public class EmailService(IOptionsMonitor<SmtpOptions> monitor)
{
public SmtpOptions CurrentOptions => monitor.CurrentValue;
}
Modern C# language features
C# has evolved rapidly through versions 9 to 13, with each release adding features that reduce boilerplate and increase type safety. A .NET architect on retainer typically spends hours per month on language feature adoption — migrating from mutable classes to record types, enabling nullable reference types, adopting primary constructors — work that produces no user-visible feature but eliminates entire categories of bugs.
Record types and immutability
// Record types — immutable reference types with value equality:
public record OrderDto(
string Id,
string CustomerId,
decimal Total,
OrderStatus Status,
DateTimeOffset CreatedAt
);
// with-expression for non-destructive mutation:
var updated = original with { Status = OrderStatus.Shipped };
// Positional records support deconstruction:
var (id, customerId, total, status, _) = order;
// Record structs (C# 10) — value type semantics, stack allocated:
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Zero(string currency) => new(0m, currency);
public Money Add(Money other)
{
if (Currency != other.Currency) throw new InvalidOperationException("Currency mismatch");
return new(Amount + other.Amount, Currency);
}
}
// Primary constructors (C# 12) — concise service initialization:
public class OrderService(
IOrderRepository repository,
IEventBus eventBus,
ILogger<OrderService> logger)
{
public async Task<OrderDto> CreateAsync(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd); // domain logic in entity
await repository.AddAsync(order, ct);
await eventBus.PublishAsync(new OrderCreatedEvent(order.Id), ct);
logger.LogInformation("Order {OrderId} created", order.Id);
return order.ToDto();
}
}
// Required members (C# 11) — enforce initialization without constructors:
public class ProductConfiguration
{
public required string ApiKey { get; init; }
public required Uri BaseUrl { get; init; }
public int TimeoutSeconds { get; init; } = 30; // optional with default
}
Nullable reference types
// Enable project-wide in .csproj:
<Nullable>enable</Nullable>
// After enabling, string is non-nullable — compiler warns if null is possible:
string name = GetName(); // OK — GetName() returns string (non-nullable)
string? nullable = null; // OK — declared nullable
string bad = null; // CS8600: cannot assign null to non-nullable
// Patterns for external boundaries:
public CustomerDto GetCustomer(string id)
{
var customer = _db.Customers.FirstOrDefault(c => c.Id == id);
// customer is Customer? — nullable because FirstOrDefault can return null
// Option 1: null guard with throw:
ArgumentNullException.ThrowIfNull(customer);
// Option 2: null-forgiving operator when you know it cannot be null
// (e.g., after validating that the ID came from the DB):
return _mapper.Map(customer!); // ! suppresses nullable warning
// Option 3: pattern matching:
return customer switch
{
null => throw new NotFoundException($"Customer {id} not found"),
_ => _mapper.Map(customer)
};
}
// Nullable annotations for method contracts:
public string? FindByEmail(string email) => // nullable — may not find
_db.Customers.FirstOrDefault(c => c.Email == email)?.Name;
[return: NotNullIfNotNull(nameof(input))]
public string? Transform(string? input) => input?.ToUpperInvariant();
// MaybeNullWhen for TryGet patterns:
public bool TryGetCustomer(string id, [MaybeNullWhen(false)] out Customer customer)
{
customer = _db.Customers.FirstOrDefault(c => c.Id == id);
return customer is not null;
}
Pattern matching and switch expressions
// Switch expression for discriminated union handling:
public decimal CalculateDiscount(Customer customer, Order order) =>
(customer.Tier, order.Total) switch
{
(CustomerTier.Premium, >= 1000m) => order.Total * 0.20m,
(CustomerTier.Premium, _) => order.Total * 0.10m,
(CustomerTier.Standard, >= 500m) => order.Total * 0.05m,
_ => 0m
};
// List patterns (C# 11):
public string DescribeList<T>(IList<T> list) =>
list switch
{
[] => "empty",
[_] => "single item",
[_, _] => "two items",
[var first, .., var last] => $"starts with {first}, ends with {last}"
};
// Type patterns in exception handling (C# 9+):
try { await ProcessOrderAsync(orderId); }
catch (Exception ex) when (ex is TimeoutException or HttpRequestException)
{
logger.LogWarning(ex, "Transient failure for order {Id}", orderId);
throw new RetryableException("Transient failure", ex);
}
Entity Framework Core
EF Core is the most common source of hidden performance problems in .NET applications. The ORM’s transparent lazy loading makes it easy to write code that produces hundreds of database queries per request without the developer being aware. A .NET architect on retainer spends significant time auditing EF Core usage, enabling query logging, and redesigning the data access layer for the queries the application actually executes.
Query splitting and compiled queries
// Enable EF Core query logging in development:
builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Information);
// Problem: multiple collection navigations produce cartesian product JOIN:
// 50 orders × 10 items × 5 tags = 2500 rows in one query
var orders = await context.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Tags)
.ToListAsync();
// Fix: AsSplitQuery executes one SELECT per collection navigation:
var orders = await context.Orders
.Include(o => o.Items)
.ThenInclude(i => i.Tags)
.AsSplitQuery() // 3 queries: Orders, OrderItems, Tags — no cartesian product
.ToListAsync();
// Configure globally (opt-in for all queries):
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlServer(connectionString, o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
// Compiled queries — eliminate query compilation overhead for high-frequency paths:
private static readonly Func<AppDbContext, string, Task<Order?>> GetOrderByIdQuery =
EF.CompileAsyncQuery((AppDbContext ctx, string id) =>
ctx.Orders
.Include(o => o.Items)
.AsNoTracking()
.FirstOrDefault(o => o.Id == id));
public async Task<Order?> GetByIdAsync(string id, CancellationToken ct)
=> await GetOrderByIdQuery(context, id);
// AsNoTracking — critical for read-only paths:
var readOnlyOrders = await context.Orders
.AsNoTracking() // no change tracking overhead — ~30% faster for reads
.Where(o => o.CustomerId == customerId)
.ToListAsync(ct);
Value converters, owned entities, and interceptors
// Value converter — store a domain type as a database primitive:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>()
.Property(o => o.Currency)
.HasConversion(
v => v.Code, // to database: Currency -> "USD"
v => Currency.From(v) // from database: "USD" -> Currency
);
// Owned entity type — value object stored in the same table:
modelBuilder.Entity<Order>()
.OwnsOne(o => o.ShippingAddress, addr =>
{
addr.Property(a => a.Line1).HasColumnName("ShipLine1").IsRequired();
addr.Property(a => a.City).HasColumnName("ShipCity").IsRequired();
addr.Property(a => a.PostalCode).HasColumnName("ShipPostal");
addr.Property(a => a.Country).HasColumnName("ShipCountry").IsRequired();
});
}
// SaveChanges interceptor — audit log without touching business logic:
public class AuditInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var context = eventData.Context;
if (context is null) return base.SavingChangesAsync(eventData, result, ct);
var entries = context.ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.Select(e => new AuditEntry(
e.Metadata.ClrType.Name,
e.State.ToString(),
e.Properties.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)
));
context.Set<AuditLog>().AddRange(entries.Select(e => e.ToAuditLog()));
return base.SavingChangesAsync(eventData, result, ct);
}
}
Async patterns in .NET
ASP.NET Core is async from the ground up, but incorrect async patterns cause performance problems that are difficult to diagnose. ValueTask vs Task selection, IAsyncEnumerable for streaming, CancellationToken propagation, and Channel<T> producer-consumer design are the patterns that a .NET architect on retainer addresses systematically.
ValueTask and IAsyncEnumerable
// ValueTask for synchronous hot paths — avoids Task heap allocation:
// Use when the method frequently completes synchronously (cache hit, buffer has data):
public ValueTask<Product?> GetFromCacheOrDbAsync(string id)
{
if (_cache.TryGetValue(id, out var cached))
return ValueTask.FromResult<Product?>(cached); // synchronous — no Task allocation
return new ValueTask<Product?>(FetchFromDbAsync(id)); // asynchronous path
}
// CAUTION: ValueTask must be awaited exactly once. Do not store and re-await.
// IAsyncEnumerable for streaming large result sets:
public async IAsyncEnumerable<ExportRow> ExportOrdersAsync(
DateTimeOffset from,
DateTimeOffset to,
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in context.Orders
.Where(o => o.CreatedAt >= from && o => o.CreatedAt <= to)
.AsAsyncEnumerable()
.WithCancellation(ct))
{
yield return MapToExportRow(order);
// Each row is streamed to the client as it's produced — no buffering entire result.
}
}
// Minimal API streaming response:
app.MapGet("/export/orders", (
[FromQuery] DateTimeOffset from,
[FromQuery] DateTimeOffset to,
IOrderExporter exporter,
CancellationToken ct) =>
// ASP.NET Core streams IAsyncEnumerable directly:
Results.Ok(exporter.ExportOrdersAsync(from, to, ct))
);
Channel<T> producer-consumer and CancellationToken
// Channel<T> — bounded producer-consumer for background processing:
public class OrderProcessingQueue
{
private readonly Channel<OrderCommand> _channel;
public OrderProcessingQueue(int capacity = 1000)
{
_channel = Channel.CreateBounded<OrderCommand>(new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait, // backpressure: producer waits
SingleWriter = false,
SingleReader = false
});
}
public async ValueTask EnqueueAsync(OrderCommand cmd, CancellationToken ct)
=> await _channel.Writer.WriteAsync(cmd, ct);
public IAsyncEnumerable<OrderCommand> ReadAllAsync(CancellationToken ct)
=> _channel.Reader.ReadAllAsync(ct);
public void Complete() => _channel.Writer.Complete();
}
// CancellationToken propagation — every async method should accept CancellationToken:
public async Task<OrderDto> CreateOrderAsync(
CreateOrderRequest request,
CancellationToken ct) // propagated from HTTP request — cancels if client disconnects
{
await _validator.ValidateAndThrowAsync(request, ct);
var order = await _repository.CreateAsync(request, ct);
await _eventBus.PublishAsync(new OrderCreated(order.Id), ct);
return order.ToDto();
}
// ConfigureAwait(false) in library code — prevents deadlocks in sync-over-async contexts:
public async Task<decimal> GetExchangeRateAsync(string from, string to, CancellationToken ct)
{
var response = await _httpClient.GetFromJsonAsync<ExchangeRateDto>(
$"/rates/{from}/{to}", ct).ConfigureAwait(false); // do not capture SynchronizationContext
return response?.Rate ?? throw new ExchangeRateNotFoundException(from, to);
}
.NET testing with xUnit, FluentAssertions, NSubstitute, and WebApplicationFactory
.NET testing infrastructure design is another category of retainer work that produces no user-visible artifact but dramatically reduces future bug rates and enables confident refactoring. A .NET architect on retainer builds the test infrastructure once and maintains it continuously: WebApplicationFactory setup for integration tests, xUnit Theory parameterization conventions, NSubstitute mock configuration patterns, and FluentAssertions assertion style.
WebApplicationFactory integration tests
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
public class OrderApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public OrderApiTests(WebApplicationFactory<Program> factory)
{
_client = factory
.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Replace real DB with in-memory SQLite for tests:
services.RemoveAll(typeof(DbContextOptions<AppDbContext>));
services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("DataSource=:memory:"));
// Replace external services with NSubstitute mocks:
services.RemoveAll(typeof(IEmailService));
services.AddSingleton(Substitute.For<IEmailService>());
});
})
.CreateClient();
}
[Fact]
public async Task CreateOrder_ValidRequest_Returns201WithLocation()
{
var request = new CreateOrderRequest(
CustomerId: "cust-001",
Items: [new OrderItemRequest("prod-001", 2)]
);
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
response.Should().HaveStatusCode(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var order = await response.Content.ReadFromJsonAsync<OrderDto>();
order.Should().NotBeNull();
order!.CustomerId.Should().Be("cust-001");
order.Status.Should().Be(OrderStatus.Pending);
}
[Theory]
[MemberData(nameof(InvalidRequests))]
public async Task CreateOrder_InvalidRequest_Returns422(CreateOrderRequest request, string expectedError)
{
var response = await _client.PostAsJsonAsync("/api/v1/orders", request);
response.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Values.SelectMany(e => e).Should().Contain(expectedError);
}
public static TheoryData<CreateOrderRequest, string> InvalidRequests => new()
{
{ new(null!, []), "Customer ID is required" },
{ new("cust-001", []), "At least one order item is required" },
};
}
Logging .NET retainer hours so clients understand the work
.NET retainer work is invisible in the same way that all platform engineering work is invisible: an EF Core query optimization that reduces database queries from 181 to 4 per request produces no new endpoint, no new feature, and no visible change in the business logic — only a faster API that handles 10x the concurrent users on the same infrastructure. A nullable reference type migration that eliminates 847 compiler warnings produces a codebase that is safer but looks identical from the product manager’s perspective. A Channel<T> producer-consumer implementation that replaces a synchronous batch job with a streaming background processor produces no change the stakeholder can observe until the next load test.
The work log entry is what connects the invisible .NET platform work to its concrete business outcome. A good entry captures: the advisory category (EF Core query optimization, nullable reference type migration, C# record type refactoring, Minimal API migration, middleware pipeline design, async pattern design, test infrastructure), the specific service or endpoint being worked on, the task performed, the EF Core log or profiler finding, the implementation approach, and the measured outcome.
HourTab turns this structured work log into a public retainer URL that the client can bookmark — a live view of hours logged, progress against the monthly allocation, and the work summaries behind each line. When the client asks “what has our .NET architect been doing this month?”, the HourTab URL answers with the EF Core log analysis, the query count reduction, and the response time improvement, without requiring a status call.
Retainer structure for .NET developer engagements
A .NET developer retainer typically covers four functional areas: feature development (new Minimal API endpoints, new EF Core entities and repositories, new BackgroundService implementations), query optimization advisory (EF Core log analysis, split query configuration, compiled query implementation, AsNoTracking audit), C# language feature adoption (record type migration, nullable reference type enablement, primary constructor refactoring), and testing infrastructure (WebApplicationFactory integration test setup, xUnit Theory conventions, FluentAssertions assertion library, NSubstitute mock patterns). Each area should have its own hour allocation in the retainer agreement.
Monthly retainer amounts for .NET developer advisory and architecture consulting typically range from $4,500 to $9,500 per month for backend architecture advisory retainers (15 to 30 hours per month at mid-to-senior rates), increasing to $10,000 to $22,000 per month for full-stack .NET and ASP.NET Core architecture consulting engagements (30 to 60 hours per month) covering EF Core optimization, nullable reference type migration, async pattern design, test infrastructure build-out, and Azure integration.
The retainer pays for itself when it prevents a single production outage: a codebase that grows for two years without EF Core review typically has 15 to 30 N+1 query patterns that collectively drive database CPU to saturation under moderate load — the kind of incident that requires emergency database scaling, followed by weeks of query optimization under pressure. Monthly retainer advisory prevents that accumulation.
HourTab is a public retainer dashboard for freelance .NET developers and .NET consulting firms. Upload your time-tracker CSV and get a shareable URL your client can bookmark — a live view of hours logged, remaining allocation, and work log summaries. No client login, no portal. Try it free with one active retainer.