Overview
Content negotiation, status codes, DTOs, and request/response contracts are core parts of building reliable HTTP APIs in C# and ASP.NET Core.
In a Web API, the server and client communicate through a contract. That contract includes the request shape, response shape, media type, validation rules, status codes, error format, and behavioral expectations. A well-designed API contract makes the system easier to consume, test, document, version, and maintain.
This topic matters because API bugs are often not caused by business logic alone. They often come from unclear response codes, leaking database entities directly to clients, inconsistent error formats, undocumented response bodies, breaking DTO changes, incorrect Content-Type or Accept handling, and endpoints that return 200 OK for every scenario.
In ASP.NET Core, this topic appears in controller-based APIs, Minimal APIs, OpenAPI/Swagger documentation, integration tests, frontend-backend communication, microservice boundaries, public APIs, internal APIs, and versioned enterprise systems.
For interviews, this is important because it tests whether a developer understands practical API design rather than only knowing how to create a controller action. Strong candidates can explain how HTTP semantics, ASP.NET Core return types, DTO design, validation, ProblemDetails, and content negotiation work together to create predictable and production-ready APIs.
Core Concepts
API Contracts
An API contract defines what a client can send to an API and what the API promises to return.
A request/response contract usually includes:
- Endpoint path and HTTP method
- Request headers
- Request body schema
- Query string parameters
- Route parameters
- Required and optional fields
- Validation rules
- Response body schema
- Success status codes
- Error status codes
- Error response format
- Supported media types
- Versioning and compatibility expectations
Example contract:
POST /api/products
Content-Type: application/json
Accept: application/json
Request body:
{
"name": "Keyboard",
"price": 49.99
}
Successful response:
201 Created
Location: /api/products/123
Content-Type: application/json
{
"id": 123,
"name": "Keyboard",
"price": 49.99
}
Error response:
400 Bad Request
Content-Type: application/problem+json
{
"type": "https://example.com/errors/validation",
"title": "Validation failed",
"status": 400,
"errors": {
"name": ["Name is required."]
}
}
The contract is more than just C# classes. It is the full agreement between the API and the consumer.
DTOs
DTO stands for Data Transfer Object.
A DTO is an object designed to carry data across a boundary, such as from an API client to a server or from a server to a client.
DTOs are commonly used to avoid exposing domain entities, database entities, or internal implementation details directly through API responses.
Example domain entity:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public decimal Cost { get; set; }
public bool IsDeleted { get; set; }
public DateTime CreatedAtUtc { get; set; }
}
Example response DTO:
public sealed record ProductResponseDto(
int Id,
string Name,
decimal Price
);
Example create request DTO:
public sealed record CreateProductRequestDto(
string Name,
decimal Price
);
The response DTO hides internal fields such as Cost, IsDeleted, and CreatedAtUtc. The create request DTO also prevents the client from supplying server-owned fields such as Id.
Why DTOs Matter
DTOs help keep API contracts stable and intentional.
Without DTOs, an API may accidentally expose internal fields:
[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> GetById(int id)
{
var product = await db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
return product;
}
This looks simple, but it couples the public API contract to the database model. If the entity changes, the API response may change unexpectedly.
A better approach is to map the entity to a response DTO:
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
var product = await db.Products.FindAsync(id);
if (product is null)
{
return NotFound();
}
return new ProductResponseDto(
product.Id,
product.Name,
product.Price
);
}
DTOs are useful for:
- Preventing over-posting attacks
- Hiding sensitive fields
- Avoiding accidental breaking changes
- Separating API design from database design
- Returning only data needed by the client
- Supporting different shapes for create, update, list, detail, and search operations
Request DTOs vs Response DTOs
Request DTOs and response DTOs should often be different.
A create request usually contains fields the client is allowed to submit:
public sealed record CreateCustomerRequest(
string FullName,
string Email
);
A response often contains server-generated fields:
public sealed record CustomerResponse(
Guid Id,
string FullName,
string Email,
DateTime CreatedAtUtc
);
An update request may contain a different shape:
public sealed record UpdateCustomerRequest(
string FullName,
string Email
);
A list response may contain less detail than a detail response:
public sealed record CustomerListItemResponse(
Guid Id,
string FullName
);
public sealed record CustomerDetailResponse(
Guid Id,
string FullName,
string Email,
DateTime CreatedAtUtc,
IReadOnlyList<OrderSummaryResponse> RecentOrders
);
A common mistake is to reuse one DTO for every operation. This often leads to confusing optional properties, weak validation, and unclear contracts.
Content Negotiation
Content negotiation is the process where the client and server agree on the response format.
The client uses the Accept header to tell the server what response media types it can handle:
Accept: application/json
The server uses the response Content-Type header to tell the client what media type it actually returned:
Content-Type: application/json; charset=utf-8
In ASP.NET Core, content negotiation is commonly handled by ObjectResult and output formatters. When a controller returns Ok(dto) or an object wrapped in ActionResult<T>, ASP.NET Core chooses an output formatter that can serialize the response.
Example:
[HttpGet("{id:int}")]
public ActionResult<ProductResponseDto> GetById(int id)
{
var product = productService.GetById(id);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
If JSON is selected, the response body is serialized as JSON.
Accept Header vs Content-Type Header
Accept and Content-Type are often confused.
Accept describes the response format the client wants:
Accept: application/json
Content-Type describes the format of the request body being sent by the client:
Content-Type: application/json
For a POST request with a JSON body, both may appear:
POST /api/products
Accept: application/json
Content-Type: application/json
The API uses Content-Type to understand how to read the request body. It uses Accept to decide how to format the response.
Input Formatters and Output Formatters
ASP.NET Core uses formatters to read request bodies and write response bodies.
Input formatters deserialize request bodies into C# objects. For example, they can read JSON from the request body and create a DTO.
Output formatters serialize C# objects into response bodies. For example, they can convert a DTO into JSON.
Example:
builder.Services.AddControllers(options =>
{
options.ReturnHttpNotAcceptable = true;
});
With ReturnHttpNotAcceptable = true, the API can return 406 Not Acceptable when the client asks for a response format that the API cannot produce.
Example request:
GET /api/products/1
Accept: application/xml
If XML output is not configured and ReturnHttpNotAcceptable is enabled, the API may return:
406 Not Acceptable
Consumes and Produces Metadata
ASP.NET Core provides attributes that document and constrain request and response formats.
[Consumes] describes the supported request body media type:
[HttpPost]
[Consumes("application/json")]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductRequestDto request)
{
// ...
}
[Produces] describes the response media type:
[Produces("application/json")]
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
}
[ProducesResponseType] documents possible response status codes and response body types:
[HttpGet("{id:int}")]
[ProducesResponseType<ProductResponseDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
var product = await productService.GetByIdAsync(id);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
These attributes improve generated OpenAPI documentation and help clients understand the API contract.
Status Codes
HTTP status codes communicate the result of a request.
A good API should use status codes intentionally instead of returning 200 OK for every scenario.
Common success status codes:
Common client error status codes:
Common server error status codes:
The exact status-code convention should be consistent across the API.
Choosing Status Codes for Common API Operations
For GET by id:
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
var product = await productService.GetByIdAsync(id);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
Common responses:
200 OKwhen found404 Not Foundwhen not found
For POST that creates a resource:
[HttpPost]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductRequestDto request)
{
var product = await productService.CreateAsync(request);
var response = new ProductResponseDto(
product.Id,
product.Name,
product.Price
);
return CreatedAtAction(
nameof(GetById),
new { id = response.Id },
response
);
}
Common response:
201 CreatedLocationheader points to the new resource- Response body contains the created resource or a representation of it
For PUT update:
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, UpdateProductRequestDto request)
{
var updated = await productService.UpdateAsync(id, request);
if (!updated)
{
return NotFound();
}
return NoContent();
}
Common responses:
204 No Contentwhen update succeeds and no body is returned200 OKwhen the updated resource is returned404 Not Foundwhen the resource does not exist409 Conflictwhen there is a concurrency conflict
For DELETE:
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var deleted = await productService.DeleteAsync(id);
if (!deleted)
{
return NotFound();
}
return NoContent();
}
Common responses:
204 No Contentwhen delete succeeds404 Not Foundwhen the resource does not exist
Controller Return Types
ASP.NET Core supports multiple return type styles for controller actions.
A specific type is simple when there is only one possible response:
[HttpGet]
public async Task<List<ProductResponseDto>> GetAll()
{
return await productService.GetAllAsync();
}
IActionResult is flexible when there are multiple possible response types:
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var product = await productService.GetByIdAsync(id);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
ActionResult<T> is often a good choice because it gives both strong typing and status-code flexibility:
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductResponseDto>> GetById(int id)
{
var product = await productService.GetByIdAsync(id);
if (product is null)
{
return NotFound();
}
return Ok(product);
}
For interviews, ActionResult<T> is usually a strong default for controller-based APIs because it communicates the success response type while still allowing error responses.
Minimal API Typed Results
Minimal APIs can use Results, TypedResults, and typed result unions.
Example:
app.MapGet("/api/products/{id:int}",
async Task<Results<Ok<ProductResponseDto>, NotFound>> (int id, IProductService productService) =>
{
var product = await productService.GetByIdAsync(id);
return product is null
? TypedResults.NotFound()
: TypedResults.Ok(product);
});
Typed result unions are useful because the endpoint signature documents possible outcomes in code.
This improves readability, testing, and OpenAPI metadata.
Validation and Error Contracts
Validation is part of the API contract.
A request DTO can use data annotations:
public sealed class CreateProductRequestDto
{
[Required]
[StringLength(100)]
public string Name { get; init; } = string.Empty;
[Range(0.01, 999999)]
public decimal Price { get; init; }
}
With [ApiController], ASP.NET Core automatically returns a 400 Bad Request response when model validation fails.
Example response shape:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["The Name field is required."]
}
}
This is typically represented by ValidationProblemDetails.
A consistent error contract matters because frontend applications and API clients need predictable error handling.
ProblemDetails
ProblemDetails is a standard structure for machine-readable error responses.
A typical problem response includes:
typetitlestatusdetailinstance- optional extension fields
Example:
return Problem(
title: "Unable to create product",
detail: "A product with the same name already exists.",
statusCode: StatusCodes.Status409Conflict
);
Example response:
{
"type": "about:blank",
"title": "Unable to create product",
"status": 409,
"detail": "A product with the same name already exists."
}
Use ProblemDetails for consistent API errors instead of inventing many unrelated error formats.
DTO Validation vs Domain Validation
DTO validation and domain validation are related but not the same.
DTO validation checks whether the incoming request shape is acceptable. Examples:
- Required fields
- String length
- Numeric range
- Valid email format
- Valid enum value
Domain validation checks business rules. Examples:
- Product name must be unique
- Order cannot be cancelled after shipment
- Customer cannot exceed credit limit
- Booking date must be available
Example:
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductRequestDto request)
{
if (await productService.ExistsByNameAsync(request.Name))
{
return Conflict(new ProblemDetails
{
Title = "Product already exists",
Status = StatusCodes.Status409Conflict,
Detail = "Another product already uses the same name."
});
}
var product = await productService.CreateAsync(request);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
A common interview mistake is saying all validation belongs in DTO attributes. Attributes are useful, but business rules usually belong in application or domain logic.
Serialization and JSON Contracts
Most ASP.NET Core APIs use JSON by default.
JSON contract design includes decisions such as:
- Property naming style
- Null handling
- Date/time format
- Enum representation
- Required properties
- Backward-compatible changes
- Numeric precision
- Polymorphism rules
Example DTO with explicit JSON property names:
public sealed record ProductResponseDto
{
[JsonPropertyName("id")]
public int Id { get; init; }
[JsonPropertyName("displayName")]
public string DisplayName { get; init; } = string.Empty;
[JsonPropertyName("price")]
public decimal Price { get; init; }
}
Explicit JSON names can make the wire contract more stable even if C# property names change.
Nullability in API Contracts
Nullability should be intentional.
Example:
public sealed record CustomerResponse(
Guid Id,
string FullName,
string? PhoneNumber
);
In this contract:
FullNameis expected to always existPhoneNumbermay be missing or unknown
Nullable reference types help developers express intent in C# code, but the API contract should also be clear in documentation and validation.
Avoid returning inconsistent null behavior, such as sometimes omitting a field, sometimes returning null, and sometimes returning an empty string for the same concept.
Dates and Times in Contracts
Date and time fields are common sources of bugs.
Good API habits include:
- Use UTC for server timestamps when possible
- Use
DateTimeOffsetwhen the offset matters - Use ISO 8601 style JSON values
- Avoid ambiguous local times
- Document whether a field is a date-only value, time-only value, or timestamp
- Avoid sending formatted display strings as the primary contract value
Example:
public sealed record OrderResponse(
Guid Id,
DateTimeOffset CreatedAt,
DateOnly DeliveryDate
);
Use display formatting in the frontend when possible. The API should usually send precise data, not UI-formatted strings.
Versioning and Backward Compatibility
API contracts should evolve carefully.
Usually safe changes:
- Adding an optional response property
- Adding a new endpoint
- Adding a new optional query parameter
- Adding a new enum value only if clients can handle unknown values
Usually breaking changes:
- Removing a property
- Renaming a property
- Changing a property type
- Changing a status code meaning
- Making an optional request field required
- Changing error response shape
- Changing date/time format
- Changing pagination format
DTOs help manage versioning because the API contract is separated from internal models.
Example versioned DTOs:
public sealed record ProductResponseV1(
int Id,
string Name
);
public sealed record ProductResponseV2(
int Id,
string Name,
decimal Price
);
Request/Response Contract Documentation
OpenAPI documentation is commonly used to describe API contracts.
ASP.NET Core can generate better OpenAPI metadata when endpoints clearly declare:
- Request DTOs
- Response DTOs
- Status codes
- Content types
- Validation metadata
- Route parameters
- Query parameters
Controller example:
[HttpPost]
[Consumes("application/json")]
[ProducesResponseType<ProductResponseDto>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductRequestDto request)
{
// ...
}
This helps frontend developers, testers, and external consumers know what to expect.
Pagination, Filtering, and List Contracts
List endpoints should have explicit response contracts.
Avoid returning a raw list if the client needs metadata such as total count, page size, or continuation tokens.
Example:
public sealed record PagedResponse<T>(
IReadOnlyList<T> Items,
int PageNumber,
int PageSize,
int TotalCount
);
Example endpoint:
[HttpGet]
public async Task<ActionResult<PagedResponse<ProductListItemDto>>> Search(
[FromQuery] ProductSearchRequest request)
{
var result = await productService.SearchAsync(request);
return Ok(result);
}
For large or cloud-backed datasets, continuation-token pagination may be better than page-number pagination.
Over-Posting and Under-Posting
Over-posting happens when a client sends fields that it should not control, and the API accidentally applies them.
Bad example:
[HttpPost]
public async Task<ActionResult<Product>> Create(Product product)
{
db.Products.Add(product);
await db.SaveChangesAsync();
return product;
}
A malicious client might send fields such as:
{
"name": "Keyboard",
"price": 49.99,
"isDeleted": true,
"cost": 1.00
}
Better example:
[HttpPost]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductRequestDto request)
{
var product = new Product
{
Name = request.Name,
Price = request.Price,
CreatedAtUtc = DateTime.UtcNow
};
db.Products.Add(product);
await db.SaveChangesAsync();
var response = new ProductResponseDto(product.Id, product.Name, product.Price);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, response);
}
DTOs protect the write contract.
Mapping Between Entities and DTOs
Mapping can be done manually or with mapping libraries.
Manual mapping is explicit and easy to debug:
public static ProductResponseDto ToResponse(Product product)
{
return new ProductResponseDto(
product.Id,
product.Name,
product.Price
);
}
For larger systems, teams may use mapping libraries to reduce repetitive code. However, mapping should still be reviewed carefully because hidden mapping rules can produce unexpected API contract changes.
For interview answers, it is useful to mention that manual mapping is often preferred for important boundaries because API contracts should be explicit.
Commands, Queries, and Response Shape
Not every POST creates a resource.
For example, a command endpoint might execute a business action:
POST /api/orders/123/cancel
Possible responses:
200 OKwith updated order state204 No Contentif the operation succeeds and no body is needed400 Bad Requestfor invalid input404 Not Foundif the order does not exist409 Conflictif the order is already shipped and cannot be cancelled
Example:
[HttpPost("{id:guid}/cancel")]
public async Task<ActionResult<OrderResponseDto>> Cancel(Guid id)
{
var result = await orderService.CancelAsync(id);
return result.Status switch
{
CancelOrderStatus.NotFound => NotFound(),
CancelOrderStatus.AlreadyShipped => Conflict(new ProblemDetails
{
Title = "Order cannot be cancelled",
Status = StatusCodes.Status409Conflict,
Detail = "The order has already been shipped."
}),
CancelOrderStatus.Cancelled => Ok(result.Order),
_ => Problem(statusCode: StatusCodes.Status500InternalServerError)
};
}
The status code should describe the result of the operation, not just the HTTP method.
Common Mistakes
Common mistakes include:
- Returning
200 OKfor errors - Returning database entities directly
- Reusing one DTO for every endpoint
- Returning inconsistent error response shapes
- Using
stringfor dates, money, or ids without a strong reason - Ignoring
Content-TypeandAccept - Not documenting non-
200responses - Returning
nullwith unclear meaning - Returning
204 No Contentwhen the client expects a body - Using
500 Internal Server Errorfor validation or business-rule failures - Mixing authentication, authorization, validation, and business errors into the same status code
- Creating API contracts that mirror the database instead of the client use case
- Breaking clients by renaming fields or changing response shapes without versioning
Best Practices
Use DTOs for request and response models.
Keep request DTOs separate from response DTOs.
Use ActionResult<T> for controller actions that return a typed success body and possible error responses.
Use CreatedAtAction or equivalent 201 Created responses when creating resources.
Use NoContent() for successful operations that intentionally return no body.
Use ProblemDetails and ValidationProblemDetails for consistent error responses.
Use [ProducesResponseType], [Consumes], and [Produces] to document contracts.
Avoid exposing EF Core entities directly from API endpoints.
Treat JSON property names, nullability, date formats, and status codes as part of the public contract.
Prefer explicit mapping at API boundaries.
Write integration tests for important status codes and response shapes.