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 element | Decision | Why |
|---|---|---|
| URI | POST /api/portfolios | portfolios is a plural resource collection |
| Request body | Portfolio name and base currency | The client provides creation data, not database fields |
| Identifier | Server-generated Guid | The server assigns the canonical resource URI |
| Successful response | 201 Created with a response body | A new resource now exists |
| Location header | /api/portfolios/{id} | Lets the client find the created resource |
| Invalid body or fields | 400 Bad Request with ValidationProblemDetails | The client can correct specific input |
| Existing conflicting name | 409 Conflict with ProblemDetails | The request is well-formed but conflicts with current state |
| Unsupported request media type | 415 Unsupported Media Type | Enforced 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 Createdwith aLocationheader. I use400for malformed or invalid request data and409where 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 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:
- Apply the business rule for duplicate names in the appropriate scope.
- Persist the new portfolio.
- Return
Createdwith the server-assigned identifier and response representation. - Translate a known unique-constraint race into
DuplicateNameif 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:
| Type | Use it for | Important addition |
|---|---|---|
ProblemDetails | A general HTTP error such as 404, 409, or unexpected 500 | type, title, status, optional detail, trace information |
ValidationProblemDetails | Invalid request fields or model-binding errors | Everything 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:
| Situation | Response |
|---|---|
Missing JSON body, malformed JSON, invalid Name, invalid BaseCurrency | 400 with ValidationProblemDetails |
| Valid request but portfolio name conflicts with existing state | 409 with ProblemDetails |
| Correct request creates a portfolio | 201 with Location and PortfolioResponse |
| Caller is not authenticated | 401, produced by authentication infrastructure |
| Caller is authenticated but not allowed | 403, produced by authorization infrastructure |
| Unexpected bug or infrastructure failure | 500 with generic ProblemDetails |
Avoid these common shortcuts:
- Returning
200 OKfor a successful create. - Returning a bare string such as
"Portfolio already exists". - Returning entity objects directly from EF Core.
- Catching every
Exceptioninside the controller and converting it into400. - Returning
500deliberately for expected domain outcomes. - Passing
CancellationToken.Noneto the service after correctly accepting the request token.
5. Capstone implementation pass
Implement and verify the endpoint in this order.
- Add
CreatePortfolioRequestandPortfolioResponseunder an API-contracts folder or project. - Add
[ApiController], the plural route, and theCreateaction toPortfoliosController. - Ensure the portfolio service returns a
CreatedorDuplicateNameresult rather than making the controller inspect EF Core exceptions. - Add a named
GET /api/portfolios/{id}route soCreatedAtRoutecan generate theLocationheader. - Confirm
AddProblemDetails,AddControllers, and the global exception handler remain registered. - 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/portfolioscreates a portfolio resource. - Use
201 Createdand aLocationheader 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 into400ValidationProblemDetailsresponses.- Use
ValidationProblem(...)for custom field-level validation andProblem(...)for general errors such as a409 Conflict. - Use
409when 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