An ASP.NET Core Web API can return JSON and still be difficult to operate. Clients need stable contracts, meaningful status codes, consistent error bodies, and requests that stop doing work after the caller disconnects. This guide turns the original .NET 8 staff API into a practical ASP.NET Core Web API production baseline without pretending the historical sample was rebuilt on a newer framework.
The baseline is deliberately narrow: define request and response DTOs, let [ApiController] validate input, return resource-oriented HTTP outcomes, standardize failures with Problem Details, propagate cancellation, and verify the boundary with executable requests and an integration test.
Table of Contents
Start from the historical .NET 8 sample
The original September 2024 article built a controller-based staff API on .NET 8. It introduced employee, department, and project DTOs; a service layer; Entity Framework Core persistence; and CRUD endpoints. The original GitHub repository remains useful historical evidence for that implementation.
This refresh does not claim that repository was rerun or upgraded. Instead, it focuses on the HTTP boundary that a production client experiences. The persistence examples still relate to the earlier generic repository discussion, but the controller must not expose persistence behavior as an accidental public contract.
.NET 8 is an LTS release supported until November 2026. Existing applications should stay on the latest .NET 8 servicing update while teams plan a supported upgrade. The patterns below are suitable for a .NET 8 controller-based API and remain useful beyond that version, but always check the documentation for the runtime you deploy.
Define the ASP.NET Core Web API production baseline contract
A database entity answers persistence questions. An API contract answers client questions. Returning an EF Core entity directly can leak navigation properties, persistence-only fields, and graph changes into the public response. Separate request and response shapes make compatibility decisions explicit.
public sealed record CreateStaffEmployeeRequest(
string Name,
string Job,
DateOnly HireDate,
decimal Salary,
int DepartmentId);
public sealed record StaffEmployeeResponse(
int Id,
string Name,
string Job,
DateOnly HireDate,
decimal Salary,
int DepartmentId);
Use a dedicated request type for each operation when the rules differ. A create request should not accept an ID generated by the server. An update request may require a concurrency token. A response may expose a stable department identifier without serializing the entire tracked object graph.
Contract changes also become easier to review. Adding an internal entity property no longer changes JSON by accident, and removing a response field becomes an intentional breaking change rather than a side effect of refactoring persistence.
Make validation part of the contract
Validation should reject malformed input before business logic or database work begins. With [ApiController], ASP.NET Core automatically returns HTTP 400 when model validation fails. The default response is ValidationProblemDetails, giving clients a structured error document instead of an arbitrary string.
public sealed class CreateStaffEmployeeRequest
{
[Required, StringLength(120, MinimumLength = 2)]
public string Name { get; init; } = string.Empty;
[Required, StringLength(80)]
public string Job { get; init; } = string.Empty;
public DateOnly HireDate { get; init; }
[Range(typeof(decimal), "0", "1000000")]
public decimal Salary { get; init; }
[Range(1, int.MaxValue)]
public int DepartmentId { get; init; }
}
Attribute validation covers shape-level rules. Rules that require data access—such as “the department must exist”—belong in the application or domain layer. Keep the distinction clear: an invalid positive department ID can be structurally valid while still referring to a missing resource.
Return outcomes that match the operation
The original sample returned NoContent() after creation and used NotFound() when a write affected zero rows. That hides useful distinctions. A create endpoint should normally return 201 Created with a Location header. A missing target can return 404. A concurrency conflict should not be reported as “not found,” and invalid input should not reach the write path.
[ApiController]
[Route("api/staff-employees")]
public sealed class StaffEmployeesController(
IStaffEmployeeService service) : ControllerBase
{
[HttpGet("{id:int}")]
[ProducesResponseType<StaffEmployeeResponse>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<StaffEmployeeResponse>> GetById(
int id,
CancellationToken cancellationToken)
{
var employee = await service.GetByIdAsync(id, cancellationToken);
return employee is null ? NotFound() : Ok(employee);
}
[HttpPost]
[ProducesResponseType<StaffEmployeeResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<StaffEmployeeResponse>> Create(
CreateStaffEmployeeRequest request,
CancellationToken cancellationToken)
{
var created = await service.CreateAsync(request, cancellationToken);
return CreatedAtAction(
nameof(GetById),
new { id = created.Id },
created);
}
}
ActionResult<T> documents the success body while still allowing HTTP results such as 404. CreatedAtAction generates a 201 response and a route-based Location header. The endpoint path uses a plural resource noun; the HTTP method carries the operation.
Decide collection semantics explicitly. A filtered collection commonly returns 200 OK with an empty array when nothing matches. Returning 404 for every empty query forces clients to treat a normal search result as an exceptional resource lookup.
Standardize failures with Problem Details
Clients should not parse one error shape for validation, another for 404, and an HTML exception page for an unhandled production failure. Register the Problem Details service and place exception handling early enough to cover the remaining pipeline.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
context.HttpContext.TraceIdentifier;
};
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
}
app.UseStatusCodePages();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
public partial class Program { }
The trace identifier helps operators correlate a client-visible failure with server logs, but do not put exception messages, SQL, stack traces, secrets, or internal identifiers into production responses. Log diagnostic detail on the server and return a stable public description.
UseStatusCodePages() can generate bodies for otherwise empty error responses. Review authentication and authorization behavior separately: a 401 means authentication is required or failed, while 403 means an authenticated principal is not allowed. Do not convert either outcome to 404 unless hiding resource existence is a deliberate security decision.
Propagate request cancellation
ASP.NET Core binds an action CancellationToken to the request cancellation signal. When a client disconnects or cancels the request, downstream work should receive that token. Otherwise the API can continue a database query after nobody is waiting for the answer.
public async Task<StaffEmployeeResponse?> GetByIdAsync(
int id,
CancellationToken cancellationToken)
{
return await dbContext.Employees
.AsNoTracking()
.Where(employee => employee.Id == id)
.Select(employee => new StaffEmployeeResponse(
employee.Id,
employee.Name,
employee.Job,
DateOnly.FromDateTime(employee.HireDate),
employee.Salary,
employee.DepartmentId))
.SingleOrDefaultAsync(cancellationToken);
}
Pass the same token through the controller, service, repository, EF Core query, and outbound HTTP calls. Do not create a new token inside each layer. Do not swallow OperationCanceledException and translate a normal client cancellation into HTTP 500.
Cancellation is cooperative. It does not guarantee that every external system stops immediately, and it cannot roll back a side effect that already committed. For writes, combine cancellation with transaction boundaries, idempotency rules where appropriate, and clear retry behavior.
Verify the production baseline
Verification should exercise the HTTP pipeline, not only call controller methods directly. An integration test built with WebApplicationFactory<Program> can cover routing, model binding, automatic validation, filters, middleware, serialization, and response headers together.
public sealed class StaffEmployeeApiTests(
WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient client = factory.CreateClient();
[Fact]
public async Task Create_without_name_returns_validation_problem()
{
var response = await client.PostAsJsonAsync(
"/api/staff-employees",
new
{
name = "",
job = "Developer",
hireDate = "2024-09-15",
salary = 70000,
departmentId = 1
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal(
"application/problem+json",
response.Content.Headers.ContentType?.MediaType);
}
}
Use a test database configuration that cannot touch production data. Then verify the essential contract matrix:
- Valid create: 201, response body, and a resolvable
Locationheader. - Invalid create: 400 with
application/problem+jsonand field errors. - Existing resource: 200 with the documented response shape.
- Missing resource: 404 with the standard error shape.
- Empty filtered collection: the chosen documented outcome, normally 200 with an empty array.
- Unexpected exception in production mode: no stack trace or internal exception text in the body.
A quick manual check can confirm the same public behavior:
curl -i https://localhost:5001/api/staff-employees/999999
curl -i -X POST https://localhost:5001/api/staff-employees \
-H "Content-Type: application/json" \
-d '{"name":"","job":"Developer","hireDate":"2024-09-15","salary":70000,"departmentId":1}'
Check the status line, Content-Type, response schema, and absence of sensitive detail. A successful HTTP response alone is not enough evidence that the contract is correct.
Production risks this baseline does not hide
This baseline improves predictability, but a production API still needs decisions that depend on its threat model and workload:
- Authorization: enforce resource-level access, not only a global authenticated-user check.
- Concurrency: use a version or ETag strategy when two clients can update the same resource.
- Pagination: never return an unbounded employee table as the dataset grows.
- Idempotency: define how duplicate create requests are handled when clients retry after a timeout.
- Observability: correlate logs, traces, and metrics without recording secrets or unnecessary personal data.
- Database behavior: use projections and
AsNoTracking()for read-only queries, and inspect generated queries on important paths.
Add these controls because the API needs them, not because a checklist says every endpoint must use every feature. The important rule is that each externally observable behavior is intentional, documented, and testable.
Conclusion
A useful ASP.NET Core Web API production baseline starts at the HTTP boundary. Separate contracts from entities, validate input before business work, map operations to accurate status codes, return consistent Problem Details, propagate cancellation, and test the complete request pipeline.
The original .NET 8 repository remains a record of the staff API implementation. This refresh adds current production guidance around that historical code without claiming a new build or test result.
References
- Microsoft: Create web APIs with ASP.NET Core
- Microsoft: Controller action return types
- Microsoft: Handle errors in ASP.NET Core APIs
- Microsoft: HttpContext.RequestAborted
- Microsoft: Integration tests in ASP.NET Core
- Microsoft: .NET releases and support
Found this useful? Support more practical developer content.