Create your own
Lesson illustration

Implementing a REST Endpoint with Validation and Problem Details Errors

Good to see you again. In the previous lesson, you made a request path asynchronous and cancellable from controller through service and EF Core. Now we will give that path a proper HTTP contract: a client can create a portfolio, receive an unambiguous success response, correct invalid input using field-level errors, and distinguish a genuine business conflict from a server failure.

We will implement POST /api/portfolios. It is deliberately small in business scope, but it uses the conventions interviewers expect from a production ASP.NET Core API: resource-oriented routing, explicit request and response DTOs, automatic validation, 201 Created with a Location header, and consistent Problem Details errors. Plan for about 40–45 minutes, including the capstone implementation pass.


1. Start with the resource contract, not the controller code

A REST endpoint is first an agreement between the Angular client and the API. Model the business resource, not the database tables or an internal command name.

For the portfolio tracker, a client creates a portfolio by sending a representation to the portfolio collection:

Contract elementDecisionWhy
URIPOST /api/portfoliosportfolios is a plural resource collection
Request bodyPortfolio name and base currencyThe client provides creation data, not database fields
IdentifierServer-generated GuidThe server assigns the canonical resource URI
Successful response201 Created with a response bodyA new resource now exists
Location header/api/portfolios/{id}Lets the client find the created resource
Invalid body or fields400 Bad Request with ValidationProblemDetailsThe client can correct specific input
Existing conflicting name409 Conflict with ProblemDetailsThe request is well-formed but conflicts with current state
Unsupported request media type415 Unsupported Media TypeEnforced when the endpoint consumes JSON only

The choice between 201 and 200 is important. A successful POST that creates a portfolio should normally return 201 Created, not a generic 200 OK. Returning the location of the new resource makes the API more self-describing and keeps the client independent of assumptions about identifier generation.

8 Pragmatic REST API Design Tips (From Real Projects)

Watch “8 Pragmatic REST API Design Tips (From Real Projects)” by Milan Jovanović for a compact refresher on the HTTP-level decisions behind the endpoint you are about to build.

Watch method choices for the intended roles of GET, POST, PUT, DELETE, and PATCH. Then watch status outcomes to connect successful creation, invalid input, and conflicts to the status-code families. Finish with error consistency, focusing on why a standard Problem Details schema is easier for API clients and support teams to consume.

A useful interview answer is:

I use nouns and plural collection routes for resources. For a create operation, the client posts to the collection URI, the server assigns the identifier, and a successful response is 201 Created with a Location header. I use 400 for malformed or invalid request data and 409 where valid input conflicts with the current resource state.


2. Separate API input from your entity

Do not bind an HTTP request directly to an EF Core Portfolio entity. An explicit request DTO is a small but meaningful boundary:

  • It documents what the endpoint accepts.
  • It prevents clients from supplying fields they should not control, such as Id, OwnerId, audit timestamps, or internal flags.
  • It allows API validation rules to evolve without exposing persistence design.
  • It gives the Angular client a stable contract.

For this focused endpoint, the client supplies only a name and a three-letter base-currency code.

using System.ComponentModel.DataAnnotations;

public sealed class CreatePortfolioRequest : IValidatableObject
{
    [Required(ErrorMessage = "Portfolio name is required.")]
    [StringLength(
        100,
        MinimumLength = 1,
        ErrorMessage = "Portfolio name must be between 1 and 100 characters.")]
    public string? Name { get; set; }

    [Required(ErrorMessage = "Base currency is required.")]
    [RegularExpression(
        "^[A-Z]{3}$",
        ErrorMessage = "Base currency must be a three-letter uppercase code.")]
    public string? BaseCurrency { get; set; }

    public IEnumerable<ValidationResult> Validate(
        ValidationContext validationContext)
    {
        if (string.IsNullOrWhiteSpace(Name))
        {
            yield return new ValidationResult(
                "Portfolio name cannot be blank.",
                new[] { nameof(Name) });
        }
    }
}

The IValidatableObject check handles a common edge case: whitespace is technically non-empty, so it passes [Required], but it is not a meaningful portfolio name.

Keep boundary validation proportionate:

  • Request validation asks whether the input has an acceptable shape: required fields, text length, number range, date format, and simple cross-field rules.
  • Business validation asks whether the requested action is allowed in the current system state: duplicate names, ownership, market availability, or account limits.
  • Database constraints are a final integrity guarantee, not the only place validation happens.

For example, “currency must contain three uppercase letters” is request validation. “This portfolio name is already used in this user’s portfolio set” is a state-dependent business rule. The latter belongs behind the controller boundary.

Create web APIs with ASP.NET Core

Read the relevant ASP.NET Core guidance from Microsoft Learn to reinforce controller conventions, automatic validation, and the error format generated for API clients.

In the opening controller guidance, read ControllerBase guidance to confirm the right base type for an API-only controller. In the “ApiController attribute” section, especially “Automatic HTTP 400 responses,” read the automatic response discussion. Then, in “Problem details for error status codes,” read the error-format explanation. Focus on why manually checking ModelState.IsValid is unnecessary when [ApiController] is enabled, and why ValidationProblem(...) is preferable to an ad hoc BadRequest(...) payload.

With [ApiController], ASP.NET Core performs model validation before your action executes. If the request is invalid, it returns 400 Bad Request automatically, using ValidationProblemDetails. Do not add this older pattern:

if (!ModelState.IsValid)
{
    return BadRequest(ModelState);
}

That check is redundant under [ApiController], and BadRequest(ModelState) makes it easier to drift away from your standard error contract.

A `400 Bad Request` response in Problem Details format: the common fields identify the problem and trace, while the `errors` object groups actionable validation messages by input field. The response uses the `application/problem+json` media type.

A validation response will look broadly like this:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-6a9d8c...",
  "errors": {
    "Name": [
      "Portfolio name cannot be blank."
    ],
    "BaseCurrency": [
      "Base currency must be a three-letter uppercase code."
    ]
  }
}

The Angular client can display the field messages near inputs, while traceId gives an engineer a correlation point for logs and later telemetry.


3. Implement the endpoint with 201 Created and a stable location

Define a response DTO that represents what the API exposes after creation. It is intentionally different from both the request and the database entity.

public sealed record PortfolioResponse(
    Guid Id,
    string Name,
    string BaseCurrency,
    DateTimeOffset CreatedAtUtc);

Next, expose a small application-service contract. The controller should translate HTTP concerns into an application command and translate the application result back into HTTP semantics.

public sealed record CreatePortfolioCommand(
    string Name,
    string BaseCurrency);

public abstract record CreatePortfolioResult
{
    public sealed record Created(
        PortfolioResponse Portfolio) : CreatePortfolioResult;

    public sealed record DuplicateName : CreatePortfolioResult;
}

public interface IPortfolioService
{
    Task<CreatePortfolioResult> CreateAsync(
        CreatePortfolioCommand command,
        CancellationToken cancellationToken);
}

The service implementation should:

  1. Apply the business rule for duplicate names in the appropriate scope.
  2. Persist the new portfolio.
  3. Return Created with the server-assigned identifier and response representation.
  4. Translate a known unique-constraint race into DuplicateName if concurrent requests could create the same name.

The last point matters. A pre-insert duplicate check gives a helpful response in ordinary cases, but it is not enough on its own: two concurrent requests can both pass the check. In the persistence implementation, a unique database constraint is still the final authority. Later SQL and EF Core lessons will cover that integrity path in more depth.

Here is the controller action:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Net.Mime;

[ApiController]
[Route("api/portfolios")]
[Authorize]
public sealed class PortfoliosController : ControllerBase
{
    private readonly IPortfolioService _portfolioService;

    public PortfoliosController(IPortfolioService portfolioService)
    {
        _portfolioService = portfolioService;
    }

    [HttpPost]
    [Consumes(MediaTypeNames.Application.Json)]
    [ProducesResponseType(
        typeof(PortfolioResponse),
        StatusCodes.Status201Created)]
    [ProducesResponseType(
        typeof(ValidationProblemDetails),
        StatusCodes.Status400BadRequest)]
    [ProducesResponseType(
        typeof(ProblemDetails),
        StatusCodes.Status409Conflict)]
    public async Task<ActionResult<PortfolioResponse>> Create(
        [FromBody] CreatePortfolioRequest request,
        CancellationToken cancellationToken)
    {
        var command = new CreatePortfolioCommand(
            request.Name!.Trim(),
            request.BaseCurrency!);

        var result = await _portfolioService.CreateAsync(
            command,
            cancellationToken);

        if (result is CreatePortfolioResult.Created created)
        {
            return CreatedAtRoute(
                routeName: "GetPortfolioById",
                routeValues: new { id = created.Portfolio.Id },
                value: created.Portfolio);
        }

        return Problem(
            statusCode: StatusCodes.Status409Conflict,
            title: "A portfolio with this name already exists.",
            type: "https://portfolio.example/problems/portfolio-name-conflict");
    }

    [HttpGet("{id:guid}", Name = "GetPortfolioById")]
    public async Task<ActionResult<PortfolioResponse>> GetById(
        Guid id,
        CancellationToken cancellationToken)
    {
        // Implement this read operation in the appropriate service.
        throw new NotImplementedException();
    }
}

A few details are worth noticing.

CreatedAtRoute is more than a convenience method

CreatedAtRoute creates a 201 Created response, serializes the response DTO, and sets the Location header using a named route:

HTTP/1.1 201 Created
Location: https://localhost:7042/api/portfolios/7f80b6f1-6649-4f17-aefa-25b44b9616ec
Content-Type: application/json

A route name is resilient to controller-action renaming. The GetPortfolioById action need not be fully implemented yet for route generation to work, but the named route must exist in the running API.

Problem(...) expresses a business conflict

The duplicate-name request is structurally valid. Returning a field-level validation payload can be defensible in some team conventions, but 409 Conflict describes the key fact more accurately: the request cannot be completed because it conflicts with current resource state.

Keep the type value stable. If you use a documentation URL, publish and maintain that documentation. Do not place raw SQL exceptions, connection strings, stack traces, or personally sensitive values in detail.

OpenAPI metadata is part of the API contract

[ProducesResponseType] does not implement behavior by itself. It documents the possible outcomes for Swagger/OpenAPI consumers and makes the endpoint’s intended contract visible in code review.

The authorization attribute continues the pipeline established in the earlier lesson. Authentication and authorization determine whether a caller may reach the action; this lesson concentrates on the HTTP behavior once the action is allowed to run. Ownership-based authorization will be made explicit in the security phase.


4. Make Problem Details consistent across expected and unexpected failures

There are two related response types:

TypeUse it forImportant addition
ProblemDetailsA general HTTP error such as 404, 409, or unexpected 500type, title, status, optional detail, trace information
ValidationProblemDetailsInvalid request fields or model-binding errorsEverything in ProblemDetails, plus an errors dictionary

For automatic validation, [ApiController] produces ValidationProblemDetails. For a custom, field-specific validation decision, call ValidationProblem(...), rather than returning an anonymous object:

return ValidationProblem(
    new Dictionary<string, string[]>
    {
        ["baseCurrency"] =
            new[] { "The selected currency is not supported." }
    });

For state conflicts, return ordinary ProblemDetails because the response is not fundamentally a collection of malformed fields:

return Problem(
    statusCode: StatusCodes.Status409Conflict,
    title: "A portfolio with this name already exists.",
    type: "https://portfolio.example/problems/portfolio-name-conflict");

At application startup, retain the exception handling arrangement from the previous lesson and add Problem Details support:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();
builder.Services.AddControllers();

var app = builder.Build();

app.UseExceptionHandler();
app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

AddProblemDetails and UseExceptionHandler provide a consistent path for unexpected exceptions. In production, the API should return a generic 500 Problem Details response; detailed exception data belongs in protected logs, not in an Angular-visible HTTP response.

A concise decision guide:

SituationResponse
Missing JSON body, malformed JSON, invalid Name, invalid BaseCurrency400 with ValidationProblemDetails
Valid request but portfolio name conflicts with existing state409 with ProblemDetails
Correct request creates a portfolio201 with Location and PortfolioResponse
Caller is not authenticated401, produced by authentication infrastructure
Caller is authenticated but not allowed403, produced by authorization infrastructure
Unexpected bug or infrastructure failure500 with generic ProblemDetails

Avoid these common shortcuts:

  • Returning 200 OK for a successful create.
  • Returning a bare string such as "Portfolio already exists".
  • Returning entity objects directly from EF Core.
  • Catching every Exception inside the controller and converting it into 400.
  • Returning 500 deliberately for expected domain outcomes.
  • Passing CancellationToken.None to the service after correctly accepting the request token.

5. Capstone implementation pass

Implement and verify the endpoint in this order.

  1. Add CreatePortfolioRequest and PortfolioResponse under an API-contracts folder or project.
  2. Add [ApiController], the plural route, and the Create action to PortfoliosController.
  3. Ensure the portfolio service returns a Created or DuplicateName result rather than making the controller inspect EF Core exceptions.
  4. Add a named GET /api/portfolios/{id} route so CreatedAtRoute can generate the Location header.
  5. Confirm AddProblemDetails, AddControllers, and the global exception handler remain registered.
  6. Use Postman to verify the following requests.

Valid create

POST /api/portfolios
Content-Type: application/json

{
  "name": "Long-Term Investments",
  "baseCurrency": "USD"
}

Expected result: 201 Created, a Location header containing the generated portfolio ID, and a JSON PortfolioResponse body.

Invalid request

POST /api/portfolios
Content-Type: application/json

{
  "name": "   ",
  "baseCurrency": "usd"
}

Expected result: 400 Bad Request, Content-Type: application/problem+json, and an errors object containing entries for Name and BaseCurrency.

Business conflict

Send the valid request a second time when your duplicate-name rule applies.

Expected result: 409 Conflict with a Problem Details body, not a plain-text message and not a 500.

For this endpoint, the previous cancellation work remains active: the controller accepts the request cancellation token and passes it unchanged to the service. Creation is a write operation, so remember the nuance: cancellation can stop unnecessary work, but it does not prove that no database commit occurred. Reliable retry behavior for writes needs an explicit idempotency or concurrency design, which will be addressed later.


Key takeaways

  • Design a resource contract before writing controller logic: POST /api/portfolios creates a portfolio resource.
  • Use 201 Created and a Location header when a POST successfully creates a resource.
  • Accept an explicit request DTO, not an EF Core entity, to create a stable API boundary and prevent unintended client-controlled fields.
  • [ApiController] automatically turns model-validation failures into 400 ValidationProblemDetails responses.
  • Use ValidationProblem(...) for custom field-level validation and Problem(...) for general errors such as a 409 Conflict.
  • Use 409 when a syntactically valid request cannot be completed because of current resource state, such as a duplicate portfolio name.
  • Keep unexpected exceptions on the global exception-handling path, return safe Problem Details to clients, and preserve diagnostic detail in logs.

Next, you will implement a typed market-price client using IHttpClientFactory, including an explicit timeout. That moves the capstone from a self-contained CRUD API toward the outbound HTTP behavior expected in a cloud-ready service.

Can't find a good explanation? Sign up and we'll make it for you

Sign up