DEV_NET_CORE
GET_STARTED
.NETAPI design and implementation

Content negotiation, status codes and request/response contracts

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:

Code
POST /api/products
Content-Type: application/json
Accept: application/json

Request body:

Code
{
  "name": "Keyboard",
  "price": 49.99
}

Successful response:

Code
201 Created
Location: /api/products/123
Content-Type: application/json
Code
{
  "id": 123,
  "name": "Keyboard",
  "price": 49.99
}

Error response:

Code
400 Bad Request
Content-Type: application/problem+json
Code
{
  "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:

Code
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:

Code
public sealed record ProductResponseDto(
    int Id,
    string Name,
    decimal Price
);

Example create request DTO:

Code
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:

Code
[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:

Code
[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:

Code
public sealed record CreateCustomerRequest(
    string FullName,
    string Email
);

A response often contains server-generated fields:

Code
public sealed record CustomerResponse(
    Guid Id,
    string FullName,
    string Email,
    DateTime CreatedAtUtc
);

An update request may contain a different shape:

Code
public sealed record UpdateCustomerRequest(
    string FullName,
    string Email
);

A list response may contain less detail than a detail response:

Code
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:

Code
Accept: application/json

The server uses the response Content-Type header to tell the client what media type it actually returned:

Code
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:

Code
[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:

Code
Accept: application/json

Content-Type describes the format of the request body being sent by the client:

Code
Content-Type: application/json

For a POST request with a JSON body, both may appear:

Code
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:

Code
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:

Code
GET /api/products/1
Accept: application/xml

If XML output is not configured and ReturnHttpNotAcceptable is enabled, the API may return:

Code
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:

Code
[HttpPost]
[Consumes("application/json")]
public async Task<ActionResult<ProductResponseDto>> Create(CreateProductRequestDto request)
{
    // ...
}

[Produces] describes the response media type:

Code
[Produces("application/json")]
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
}

[ProducesResponseType] documents possible response status codes and response body types:

Code
[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:

Status CodeMeaningCommon Usage
200 OKRequest succeededReturning a resource, search result, or operation result
201 CreatedResource was createdPOST that creates a new resource
202 AcceptedRequest accepted but not completed yetLong-running asynchronous processing
204 No ContentRequest succeeded with no response bodySuccessful delete or update with no body

Common client error status codes:

Status CodeMeaningCommon Usage
400 Bad RequestInvalid requestInvalid JSON, invalid model binding, validation failure
401 UnauthorizedAuthentication required or failedMissing or invalid authentication
403 ForbiddenAuthenticated but not allowedUser lacks permission
404 Not FoundResource does not existMissing entity by id
409 ConflictRequest conflicts with current stateDuplicate resource, concurrency conflict
415 Unsupported Media TypeRequest body format unsupportedWrong Content-Type
422 Unprocessable EntityRequest is syntactically valid but semantically invalidBusiness validation errors, if the API chooses this convention

Common server error status codes:

Status CodeMeaningCommon Usage
500 Internal Server ErrorUnexpected server errorUnhandled exception or unexpected failure
503 Service UnavailableService temporarily unavailableDependency outage, maintenance, overload

The exact status-code convention should be consistent across the API.

Choosing Status Codes for Common API Operations

For GET by id:

Code
[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 OK when found
  • 404 Not Found when not found

For POST that creates a resource:

Code
[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 Created
  • Location header points to the new resource
  • Response body contains the created resource or a representation of it

For PUT update:

Code
[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 Content when update succeeds and no body is returned
  • 200 OK when the updated resource is returned
  • 404 Not Found when the resource does not exist
  • 409 Conflict when there is a concurrency conflict

For DELETE:

Code
[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 Content when delete succeeds
  • 404 Not Found when 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:

Code
[HttpGet]
public async Task<List<ProductResponseDto>> GetAll()
{
    return await productService.GetAllAsync();
}

IActionResult is flexible when there are multiple possible response types:

Code
[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:

Code
[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:

Code
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:

Code
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:

Code
{
  "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:

  • type
  • title
  • status
  • detail
  • instance
  • optional extension fields

Example:

Code
return Problem(
    title: "Unable to create product",
    detail: "A product with the same name already exists.",
    statusCode: StatusCodes.Status409Conflict
);

Example response:

Code
{
  "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:

Code
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:

Code
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:

Code
public sealed record CustomerResponse(
    Guid Id,
    string FullName,
    string? PhoneNumber
);

In this contract:

  • FullName is expected to always exist
  • PhoneNumber may 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 DateTimeOffset when 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:

Code
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:

Code
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:

Code
[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:

Code
public sealed record PagedResponse<T>(
    IReadOnlyList<T> Items,
    int PageNumber,
    int PageSize,
    int TotalCount
);

Example endpoint:

Code
[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:

Code
[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:

Code
{
  "name": "Keyboard",
  "price": 49.99,
  "isDeleted": true,
  "cost": 1.00
}

Better example:

Code
[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:

Code
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:

Code
POST /api/orders/123/cancel

Possible responses:

  • 200 OK with updated order state
  • 204 No Content if the operation succeeds and no body is needed
  • 400 Bad Request for invalid input
  • 404 Not Found if the order does not exist
  • 409 Conflict if the order is already shipped and cannot be cancelled

Example:

Code
[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 OK for errors
  • Returning database entities directly
  • Reusing one DTO for every endpoint
  • Returning inconsistent error response shapes
  • Using string for dates, money, or ids without a strong reason
  • Ignoring Content-Type and Accept
  • Not documenting non-200 responses
  • Returning null with unclear meaning
  • Returning 204 No Content when the client expects a body
  • Using 500 Internal Server Error for 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.

Interview Practice

PreviousASP.NET Core filters and middleware boundariesNext UpControllers vs Minimal APIs