REST API ASP.NET Core — Silent 500s from Middleware Order
All endpoints returned 500 with empty body in production because UseExceptionHandler after UseRouting.
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- ASP.NET Core is a cross-platform framework for building high-performance REST APIs
- Key components: Controllers, Routing, Middleware, Dependency Injection, Configuration
- Kestrel web server handles millions of requests with minimal overhead
- Missing middleware ordering (e.g., exception handler after routing) causes silent 500 errors
- Biggest mistake: assuming [ApiController] handles all validation automatically—model state errors can still slip through
- Performance insight: async I/O and response compression reduce latency by 30-50% under load
Imagine you own a restaurant. You don't let customers walk into the kitchen — they order through a waiter who brings back exactly what they asked for. A REST API is that waiter: your frontend (the customer) sends a request, the API fetches or saves data, and returns a structured response. ASP.NET Core is the kitchen — fast, organized, and capable of serving thousands of orders at once. Every app you've ever used — Uber, Spotify, your bank — has a 'waiter' like this running behind the scenes.
Build a REST API in ASP.NET Core and you've built the backbone of most modern apps. It's how your React frontend talks to your database, how mobile apps fetch user data, how third-party services integrate with your platform. Microsoft rebuilt ASP.NET from scratch in 2016 to be cross-platform, fast, and genuinely pleasant to work with. Startups and enterprises alike rely on it for millions of daily requests.
The problem it solves: you need a reliable HTTP layer that handles requests, validates input, talks to a database, and returns meaningful responses — without turning into a maintenance nightmare six months in. ASP.NET Core gives you a clean middleware pipeline, built-in DI, and attribute routing. But the magic comes with traps. Middleware order matters. [ApiController] doesn't validate everything. Route constraints can return 404s when you expect 400s.
By the end of this article, you'll understand every piece — routing, controllers, DTOs, error handling, DI — and the real-world patterns that separate senior engineers from copy-paste coders. You'll also learn why each piece exists, so you can make architectural decisions based on trade-offs, not boilerplate.
Here's the thing most guides skip: the subtle mistakes that cause silent outages. That 404 that should be a 200? Wrong middleware order. That 500 with no log? Exception handler after routing. That validation passing invalid data? [ApiController] doesn't validate parameters from [FromQuery]. Senior engineers don't just know the syntax — they know where the framework lies to you.
What is REST API with ASP.NET Core?
ASP.NET Core REST APIs expose your backend logic over HTTP. Each request flows through a middleware pipeline — a sequence of components handling cross-cutting concerns like logging, CORS, authentication, and routing. The pipeline order is everything: get it wrong and your exception handler may never run. Kestrel, the web server, is fast and lightweight, capable of handling millions of requests per second with minimal GC pressure.
The framework gives you a clean separation of concerns. You decide which middleware to include and in what order. That's power — but also responsibility. A common mistake is placing exception handling after authentication: if auth throws, your custom handler never catches it. The framework swallows the exception and returns an empty 500 in production. No log, no trace. That's the kind of bug that wakes you up at 3 AM.
Routing and Controllers — Mapping URLs to Actions
Routing maps URLs to controller actions. For REST APIs, attribute routing keeps route definitions next to the action, so you can see endpoint shapes without jumping between files. Each controller is a class that groups related endpoints. Methods decorated with [HttpGet], [HttpPost], etc. become endpoints.
A subtle trace: route constraints like [HttpGet("{id:int}")] cause 404s if the client sends a non-integer. The framework treats constraint failure as route-not-matched, not validation failure. Many teams assume it returns 400, but it falls through to the next route. If no route matches, you get 404. You need a catch-all or manual validation in the action to get the expected 400.
Use [ApiController] on your controller class — it enables automatic model validation, binding source inference, and requires attribute routing. Without it, you have to specify [FromBody], [FromQuery], etc. explicitly.
- [Route("api/[controller]")] maps to the class name minus 'Controller' suffix.
- HTTP verb attributes (HttpGet, HttpPost) narrow the action selection.
- Combining both gives you RESTful CRUD endpoints.
- Always return ActionResult<T> to let ASP.NET Core handle status codes.
DTOs and Validation — Separating Internal Models from API Contracts
DTOs shape what your API sends and receives. They decouple your internal entity models from the public contract, so you can refactor internals without breaking clients. Use DTOs for both request and response — never expose EF Core entities directly.
ASP.NET Core integrates with Data Annotations for validation. Attributes like [Required], [StringLength], [Range] are evaluated automatically when binding from [FromBody]. With [ApiController], invalid DTOs return a 400 with standard ProblemDetails.
But here's where it gets you: [ApiController] only validates complex types bound from the body. Query parameters, route parameters, and headers are not automatically validated. You need to check ModelState manually or use FluentValidation with a custom filter. I've seen production data corrupted because a query param with an invalid type slipped through — the binding silently failed, and the parameter was null or default, not an error. Your code proceeds with bad input.
Global Error Handling — Don't Let Exceptions Leak to the Client
A production API must handle exceptions gracefully. Use DeveloperExceptionPage in development and a custom exception handler in production. The handler should log the exception and return a consistent ProblemDetails response with a correlation ID.
Write a custom middleware that catches all exceptions, logs them with request path and correlation ID, and returns a standard error object. Include a trace ID in every response, not just errors — then when a client complains about a slow response, you can trace it through logs using HttpContext.TraceIdentifier.
Register the handler first in the pipeline, before routing. If it's after routing, exceptions thrown during routing (e.g., missing route) bypass your handler. The fallback server error page in production returns an empty 500 with no details — that's the silent outage I described earlier.
- Register UseExceptionHandler before UseRouting, UseAuthentication, UseAuthorization.
- Include a correlation ID in the response so clients and logs can be matched.
- Use ProblemDetails format (RFC 7807) for all error responses.
- Log the full exception in the middleware, not just a message.
Dependency Injection — Loose Coupling for Testable APIs
DI is baked into ASP.NET Core. You register services in Program.cs with one of three lifetimes: Singleton (once per app start), Scoped (once per request), Transient (every request for the service). The container injects them wherever needed — typically via constructor injection in controllers.
Always program to interfaces. IProductService, IProductRepository — that way you can swap implementations for testing, caching, or fallback logic without changing the consumer. The built-in DI container is good enough for most apps; you rarely need third-party containers.
The trap: captive dependencies. If you register a Scoped service as Singleton, it works — but only one instance is created for the app's lifetime, reused across all requests. That means your DbContext holds stale data and causes concurrency errors. ASP.NET Core validates this in development and throws an exception, but in production it silently ignores the mismatch and works with the wrong behavior. Always verify your service lifetimes, especially when using custom factories.
Authorization: Don’t Let Your Endpoints Run Naked
Most junior devs treat authentication like a checkbox — slap JWT middleware on the pipeline and call it done. That’s how you get an API where every endpoint is a public bathroom door.
Authorization is the gatekeeper that says “you have a token? Cool. Does this token have the right claims to delete pizzas?”. In ASP.NET Core, you enforce this with [Authorize] attributes and policy-based checks. No attribute means open season for the entire internet.
Don’t handle JWT parsing yourself. Let ASP.NET’s built-in authentication middleware validate tokens, extract claims, and hydrate HttpContext.User. Then use [Authorize(Policy = “InventoryManager”)] to lock down write operations. That keeps your controller code clean and your security logic declarative.
One production leak I fixed: a contractor threw [AllowAnonymous] on a controller “for testing” and forgot to remove it. The Pizza catalog’s delete endpoint was wide open for two weeks. Policy-based authorization prevents that — you control access at the module level, not per-method guesswork.
[AllowAnonymous] at the controller level — put it only on the smallest set of endpoints that genuinely need public access. One catch-all override wipes out your entire auth fence.Serialization: Stop Writing Custom Mappers — JSON Is Not Your Enemy
I’ve seen teams write 200 lines of manual JsonSerializer calls because they “wanted control”. Every single time, the root cause was fear of a black-box framework. ASP.NET Core’s default JSON serialization via System.Text.Json is battle-tested and handles 99% of cases out of the box.
Here’s the deal: when you return an object from a controller action, ASP.NET serializes it to JSON automatically. No JsonConvert.SerializeObject, no custom ContentResult hacks. The framework respects your DTO shapes, ignores nulls if you configure it, and even handles enums as strings with a single option.
But the caveat? Circular references. If your EF models have navigation properties looping back to each other, the default serializer throws a JsonException. You have two choices: (1) use DTOs that flatten the graph (recommended), or (2) configure reference handling with ReferenceHandler.IgnoreCycles. The first option keeps your API contract clean; the second is a band-aid.
Real example: I inherited a pizza API where every endpoint returned {“$id”:”1″,”pizzas”:[{“$ref”:”1″}]} because someone enabled PreserveReferences globally. Customers got useless JSON. Fix: switch to DTO projection in the query and delete the config.
JsonSerializerOptions instance and pass it to JsonSerializer.Serialize() in integration tests. That way your test assertions match exactly what the production API returns — no brittleness from default config drift.Designed With Security In Mind — Stop Patching, Start Building
Security isn't a feature you bolt on after the demo. It's the foundation. ASP.NET Core gives you the weapons: built-in anti-forgery tokens, CSP headers, rate limiting middleware, and HTTPS enforcement out of the box.
Why does this matter? Because most breaches happen through exposed endpoints that trust the client too much. You validate input on every controller, you enforce HTTPS redirects globally, and you never, ever store secrets in appsettings.json — that's what User Secrets and Azure Key Vault are for.
How you implement it: start with the pipeline. Add app. before any route mapping. Use UseHttpsRedirection()[ValidateAntiForgeryToken] on state-changing actions. Lock down CORS to specific origins, not wildcards. And for the love of production, sanitize all user input even if you "trust" your frontend. You don't control the HTTP client calling your API, so treat every byte as hostile.
Senior move: Write a global authorization filter that rejects unauthenticated requests by default, then opt-in with [AllowAnonymous] only where needed.
Great Tools For Any Platform — Your API, Their Client
ASP.NET Core APIs don't care what language your client speaks. iOS, Android, React, Vue, even a curl script — the contract is JSON over HTTP. That's the point of REST: platform-agnostic communication.
Why should you care? Because you're not writing desktop apps anymore. Your API will be consumed by a web frontend, a mobile app, a partner system, and a CLI tool all at once. If you tie your API to a specific platform (like returning HTML views), you break that flexibility.
How to build for any platform: return proper HTTP status codes (201 for creation, 204 for deletion, 401 for unauthorized — no custom "success: false" garbage). Use content negotiation so clients can request JSON, XML, or even Protobuf. Document your API with OpenAPI/Swagger so any client team can self-serve. Don't expose your database schema — use DTOs that match what the client actually needs.
Senior move: Write a health endpoint (/health) that returns 200 with a JSON body. Every platform can hit it. If you ever change infrastructure, your clients won't break — they just check the status code.
Minimal APIs vs Controllers for REST APIs in 2026
As of 2026, ASP.NET Core offers two primary paradigms for building REST APIs: Controllers (the traditional MVC-based approach) and Minimal APIs (introduced in .NET 6 and refined through .NET 9). Both are fully supported and production-ready, but they cater to different scenarios.
Controllers are ideal for large, complex APIs with many endpoints, shared dependencies, and standardized patterns. They provide a structured way to organize code using classes, attributes, and dependency injection. Controllers excel when you need to enforce conventions, such as authorization policies or model validation, across multiple endpoints.
Minimal APIs are lightweight and focus on simplicity and performance. They are perfect for microservices, small APIs, or endpoints that don't require the overhead of controllers. With .NET 9, Minimal APIs have become even more powerful, supporting features like filters, dependency injection, and OpenAPI generation without extra packages.
When to choose which? - Use Controllers for large teams, complex business logic, or when you need to leverage existing MVC patterns. - Use Minimal APIs for simple CRUD operations, prototypes, or when you want minimal boilerplate.
Example: Minimal API endpoint ```csharp var builder = WebApplication.CreateBuilder(args); var app = builder.Build();
app.MapGet("/api/products/{id}", async (int id, ProductDbContext db) => { var product = await db.Products.FindAsync(id); return product is not null ? Results.Ok(product) : Results.NotFound(); });
app.Run(); ```
Example: Controller endpoint ``csharp [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { [HttpGet("{id}")] public async Task``NotFound(); return Ok(product); } }
In 2026, both approaches are equally capable. The choice often comes down to team preference and project scale. Many projects even mix both: using Minimal APIs for simple endpoints and Controllers for complex ones.
Built-in OpenAPI Support in .NET 9 (No Swashbuckle Needed)
Starting with .NET 9, ASP.NET Core includes built-in support for generating OpenAPI documents, eliminating the need for third-party packages like Swashbuckle or NSwag. This is a game-changer for API documentation, as it reduces dependencies and ensures compatibility with the latest .NET features.
The built-in OpenAPI support is part of the Microsoft.AspNetCore.OpenApi package, which is included in the ASP.NET Core SDK. To enable it, simply call AddOpenApi() and MapOpenApi() in your Program.cs.
Example: Minimal API with OpenAPI ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); // Enables OpenAPI document generation
var app = builder.Build();
app.MapGet("/api/products/{id}", (int id) => { return Results.Ok(new Product { Id = id, Name = "Sample" }); }) .WithName("GetProduct") .WithOpenApi(); // Adds OpenAPI metadata
app.MapOpenApi(); // Exposes the OpenAPI endpoint (default: /openapi/v1.json)
app.Run(); ```
For Controllers, OpenAPI support is automatically enabled when you add AddOpenApi() and use [ApiController]. You can customize the document using OpenApiOptions.
Customizing the OpenAPI document ``csharp builder.Services.AddOpenApi(options => { options.AddDocumentTransformer((document, context) => { document.Info.Title = "My API"; document.Info.Version = "v1"; return Task.CompletedTask; }); }); ``
Benefits over Swashbuckle - No extra NuGet packages - Better integration with .NET 9 features like TypedResults - Lighter and faster startup - Automatic generation of JSON schema from DTOs
The built-in support also includes a Swagger UI middleware if you need a visual interface, but the core focus is on generating the OpenAPI specification.
This shift means you can now ship your API with documentation out-of-the-box, reducing maintenance and dependency risks.
TypedResults for Strongly-Typed API Responses
TypedResults, introduced in .NET 7 and enhanced in .NET 9, provide a strongly-typed way to return IResult from Minimal API endpoints. Unlike the traditional Results class, TypedResults returns concrete types like Ok, NotFound, BadRequest, etc., which improves testability and documentation generation.
Why TypedResults? - Type safety: The return type is explicit, making it easier to understand what the endpoint returns. - OpenAPI integration: Built-in OpenAPI support can infer response types from TypedResults, generating accurate schemas. - Testability: You can assert on the exact type returned, e.g., Assert.IsType.
Example: Using TypedResults ``csharp app.MapGet("/api/products/{id}", async (int id, ProductDbContext db) => { var product = await db.Products.FindAsync(id); if (product is null) { return ``TypedResults.NotFound(); } return TypedResults.Ok(product); });
Comparison with Results - Results.Ok(product) returns IResult (non-generic). - TypedResults.Ok(product) returns Ok which implements IResult.
Testing with TypedResults ```csharp [Fact] public async Task GetProduct_ReturnsOk_WhenProductExists() { // Arrange var db = new ProductDbContext(); var product = new Product { Id = 1, Name = "Test" }; db.Products.Add(product); await db.SaveChangesAsync();
// Act var result = await GetProductEndpoint(1, db);
// Assert var okResult = Assert.IsType
Best Practices - Use TypedResults for all Minimal API endpoints to leverage type safety. - Combine with Results for complex scenarios like file downloads or redirects. - In Controllers, you can still use ActionResult which provides similar benefits.
TypedResults are a key part of building robust, self-documenting APIs in .NET 9.
The Missing Exception Handler That Swallowed Every Error
- Always place global error handling middleware first in the pipeline — before routing, authentication, or any other middleware.
- Test error responses with integration tests that simulate invalid requests.
- Enable server-side logging of unhandled exceptions using ILogger or a dedicated exception logging middleware.
UseMiniProfiler(). Check database connection pooling, inefficient LINQ queries, and synchronous I/O in async endpoints. Enable response compression for large payloads.dotnet run --launch-profile httpscurl -v https://localhost:5001/api/your-endpoint| File | Command / Code | Purpose |
|---|---|---|
| ForgeExample.java | public class ForgeExample { | What is REST API with ASP.NET Core? |
| Controllers | [ApiController] | Routing and Controllers |
| Models | public class CreateProductDto | DTOs and Validation |
| Middleware | public class ExceptionHandlingMiddleware | Global Error Handling |
| Program.cs | var builder = WebApplication.CreateBuilder(args); | Dependency Injection |
| InventoryAuthorization.cs | using Microsoft.AspNetCore.Authorization; | Authorization |
| SerializationConfig.cs | var builder = WebApplication.CreateBuilder(args); | Serialization: Stop Writing Custom Mappers |
| SecurityPipeline.cs | using Microsoft.AspNetCore.Mvc; | Designed With Security In Mind |
| HealthEndpoint.cs | using Microsoft.AspNetCore.Mvc; | Great Tools For Any Platform |
| minimal-vs-controllers.cs | var builder = WebApplication.CreateBuilder(args); | Minimal APIs vs Controllers for REST APIs in 2026 |
| openapi-builtin.cs | var builder = WebApplication.CreateBuilder(args); | Built-in OpenAPI Support in .NET 9 (No Swashbuckle Needed) |
| typedresults-example.cs | app.MapGet("/api/products/{id}", async (int id, ProductDbContext db) => | TypedResults for Strongly-Typed API Responses |
Key takeaways
Interview Questions on This Topic
Explain the role of the [ApiController] attribute and what it does automatically.
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
That's ASP.NET. Mark it forged?
9 min read · try the examples if you haven't