‹ Back to Blog Software Engineering

C# .NET 10: Building Enterprise APIs That Last

April 6, 2026 · 11 min read
Abstract technology visualisation

.NET is Pepla's primary back-end technology. The majority of the systems we build and maintain for clients run on C# and .NET -- from RESTful APIs serving mobile apps to complex enterprise platforms processing millions of transactions daily. This is not an accident. .NET offers the rare combination of enterprise-grade reliability, exceptional performance, and a developer experience that has improved dramatically with every release.

.NET 10, the latest long-term support release, continues this trajectory. This article covers the architecture patterns, frameworks, and practices we use to build .NET APIs that are maintainable, testable, and performant at scale.

Minimal APIs vs Controllers: Choosing the Right Model

Since .NET 6, developers have had two options for defining HTTP endpoints: the traditional MVC controller pattern and the newer minimal API pattern. At Pepla, we use both, but the decision is deliberate, not arbitrary.

Minimal APIs are ideal for microservices, lightweight APIs, and vertical slice architectures. They reduce ceremony and put the endpoint logic front and centre:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddDbContext<AppDbContext>();

var app = builder.Build();

app.MapGet("/api/orders", async (IOrderService service) =>
{
    var orders = await service.GetAllAsync();
    return Results.Ok(orders);
});

app.MapGet("/api/orders/{id:guid}", async (Guid id, IOrderService service) =>
{
    var order = await service.GetByIdAsync(id);
    return order is not null
        ? Results.Ok(order)
        : Results.NotFound();
});

app.MapPost("/api/orders", async (CreateOrderRequest request, IOrderService service) =>
{
    var result = await service.CreateAsync(request);
    return result.Match(
        success: order => Results.Created($"/api/orders/{order.Id}", order),
        failure: errors => Results.ValidationProblem(errors)
    );
});

app.Run();

Controllers remain the better choice for large APIs with shared concerns. Attribute routing, action filters, model binding, and the [ApiController] attribute provide a structured framework that scales to hundreds of endpoints:

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
        => _orderService = orderService;

    [HttpGet]
    [ProducesResponseType<List<OrderDto>>(200)]
    public async Task<IActionResult> GetAll(
        [FromQuery] OrderFilterRequest filter,
        CancellationToken ct)
    {
        var orders = await _orderService.GetAllAsync(filter, ct);
        return Ok(orders);
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType<OrderDto>(200)]
    [ProducesResponseType(404)]
    public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
    {
        var order = await _orderService.GetByIdAsync(id, ct);
        return order is not null ? Ok(order) : NotFound();
    }
}

Minimal APIs for focused microservices, controllers for large enterprise APIs -- choose based on the complexity of your endpoint surface.

Dependency Injection: The Foundation of Testability

.NET's built-in dependency injection container is production-ready and sufficient for most applications. The key is understanding service lifetimes and designing your dependency graph correctly.

// Registration
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddSingleton<ICacheService, RedisCacheService>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

// Keyed services (.NET 8+)
builder.Services.AddKeyedScoped<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedScoped<IPaymentGateway, PayFastGateway>("payfast");

The three lifetimes are Transient (new instance every time), Scoped (one instance per HTTP request), and Singleton (one instance for the application lifetime). The critical rule is that a singleton should never depend on a scoped or transient service -- this creates a captive dependency that silently breaks scoping guarantees. .NET will throw an InvalidOperationException in development if you enable scope validation, and you should always enable it.

At Pepla, our standard pattern is: repositories are scoped (tied to the DbContext lifetime), services are scoped (they depend on repositories), and infrastructure services like caching and configuration are singletons.

Entity Framework Core: Code-First Done Right

Entity Framework Core is an ORM that has matured dramatically. With proper configuration, it generates efficient SQL and provides a productive development experience. The code-first approach, where your C# classes define the database schema, is our standard at Pepla.

Dual monitor development setup
public class Order
{
    public Guid Id { get; init; }
    public required string CustomerName { get; set; }
    public OrderStatus Status { get; set; }
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; init; }
    public List<OrderLineItem> LineItems { get; set; } = [];
}

public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);
        builder.Property(o => o.CustomerName).HasMaxLength(200).IsRequired();
        builder.Property(o => o.Total).HasPrecision(18, 2);
        builder.Property(o => o.Status)
            .HasConversion<string>()
            .HasMaxLength(50);
        builder.HasMany(o => o.LineItems)
            .WithOne()
            .HasForeignKey(li => li.OrderId)
            .OnDelete(DeleteBehavior.Cascade);
        builder.HasIndex(o => o.Status);
        builder.HasIndex(o => o.CreatedAt);
    }
}

Query Optimisation

EF Core generates SQL, and like all ORMs, it can generate terrible SQL if you are not careful. The most common performance killers are the N+1 query problem and over-fetching.

// Bad: loads full entities, tracks changes, N+1 on LineItems
var orders = await _context.Orders.ToListAsync();

// Good: projection, no tracking, eager loading
var orders = await _context.Orders
    .AsNoTracking()
    .Where(o => o.Status == OrderStatus.Pending)
    .Select(o => new OrderSummaryDto
    {
        Id = o.Id,
        CustomerName = o.CustomerName,
        Total = o.Total,
        ItemCount = o.LineItems.Count,
    })
    .OrderByDescending(o => o.Total)
    .Take(20)
    .ToListAsync(ct);

The Middleware Pipeline

The .NET middleware pipeline is where cross-cutting concerns live: authentication, CORS, rate limiting, exception handling, request logging. The order of middleware registration matters because each middleware can short-circuit the pipeline or modify the request/response:

app.UseExceptionHandler("/error");
app.UseHsts();
app.UseHttpsRedirection();
app.UseCors("AllowFrontend");
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.UseResponseCaching();
app.MapControllers();

Authentication, authorisation, rate limiting, and error handling all live in the middleware pipeline -- order matters.

Authentication: JWT and Azure AD

For API authentication, we use JWT bearer tokens for machine-to-machine and mobile clients, and Azure AD (now Entra ID) integration for enterprise clients with existing Microsoft identity infrastructure:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = builder.Configuration["Jwt:Audience"],
            ValidateLifetime = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),
            ClockSkew = TimeSpan.FromSeconds(30),
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireClaim("role", "admin"));
    options.AddPolicy("OrderAccess", policy =>
        policy.RequireAssertion(ctx =>
            ctx.User.HasClaim("role", "admin") ||
            ctx.User.HasClaim("permission", "orders.read")));
});

Error Handling with the Result Pattern

Throwing exceptions for expected failures -- validation errors, not-found conditions, business rule violations -- is expensive and makes control flow hard to follow. The Result pattern makes success and failure explicit in the type system:

public abstract record Result<T>
{
    public record Success(T Value) : Result<T>;
    public record Failure(string Code, string Message) : Result<T>;
    public record ValidationFailure(
        Dictionary<string, string[]> Errors) : Result<T>;
}

// Service method
public async Task<Result<OrderDto>> CreateAsync(CreateOrderRequest request)
{
    var errors = _validator.Validate(request);
    if (errors.Any())
        return new Result<OrderDto>.ValidationFailure(errors);

    var customer = await _customerRepo.GetByIdAsync(request.CustomerId);
    if (customer is null)
        return new Result<OrderDto>.Failure(
            "CUSTOMER_NOT_FOUND",
            $"Customer {request.CustomerId} does not exist.");

    var order = Order.Create(customer, request.Items);
    await _orderRepo.AddAsync(order);
    await _unitOfWork.SaveChangesAsync();

    return new Result<OrderDto>.Success(_mapper.Map<OrderDto>(order));
}
API integration architecture

Structured Logging with Serilog

Logging is critical for production debugging. Serilog provides structured logging that writes machine-parseable log events rather than flat text strings. The difference matters when you are searching through millions of log entries in Seq, Elasticsearch, or Application Insights:

Log.Information(
    "Order {OrderId} created for customer {CustomerId}, total {Total:C}",
    order.Id, customer.Id, order.Total);

// Produces structured output:
// { "OrderId": "abc-123", "CustomerId": "def-456", "Total": 1299.99,
//   "Message": "Order abc-123 created for customer def-456, total R1,299.99" }

The template syntax with named placeholders creates searchable, structured properties while still producing a human-readable message. You can query by OrderId, aggregate by CustomerId, or alert on Total thresholds -- none of which is possible with string.Format or interpolation.

Health Checks and OpenAPI

Every API we deploy at Pepla includes health check endpoints and auto-generated OpenAPI documentation. Health checks verify that the API can reach its dependencies -- database, cache, external services -- and return a structured status report:

builder.Services.AddHealthChecks()
    .AddSqlServer(connectionString, name: "database")
    .AddRedis(redisConnection, name: "cache")
    .AddUrlGroup(new Uri("https://external-api.com/health"), name: "payment-gateway");

app.MapHealthChecks("/health", new HealthCheckOptions
{
    ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
});

For OpenAPI, .NET 10 includes built-in OpenAPI document generation that replaces the need for Swashbuckle. Endpoint metadata flows directly from your route definitions and XML documentation comments into the generated specification.

Every Pepla API ships with health checks, structured logging, OpenAPI documentation, and correlation IDs from day one. These are not luxuries for mature systems -- they are prerequisites for operating in production. We configure them in our project templates so that no team starts without them.

.NET is not the trendiest technology in the industry, and that is precisely why we chose it as our primary stack. It is battle-tested, exceptionally fast (consistently among the top performers in the TechEmpower benchmarks), backed by Microsoft's long-term commitment, and supported by a mature ecosystem of libraries and tools. For enterprise APIs that need to run reliably for years, not months, .NET is the foundation we trust, and the foundation our clients trust us to build on.

Need help with this?

Pepla builds enterprise .NET APIs that scale with your business. Let us architect yours.

Get in Touch

Contact Us

Schedule a Meeting

Book a free consultation to discuss your project requirements.

Book a Meeting ›

Let's Connect