Updated on

A write action in an ASP.NET Core Web API does four things: it accepts a DTO from the request body, validates it, asks the repository to stage the change, and returns a status code that tells the client what happened. POST returns 201 Created, PUT and DELETE return 204 No Content.

In this part we add those three actions to the OwnerController we built while handling GET requests with a repository, on top of the repository pattern from part 4. That completes the .NET side of this series.


VIDEO: Handling POST, PUT, and DELETE Requests.


The embedded video writes these actions synchronously and validates the model by hand. The code below is the current version: the same three actions, async, with validation left to [ApiController].

If you want to see all the basic instructions and complete navigation for this series, please follow this link: Introduction page for this tutorial.

To download the source code for this article, you can visit the UsingRepositoryForWriteRequests folder in our GitHub repository. The source code for the whole series is here.

How Do We Handle a POST Request in ASP.NET Core Web API?

A POST action creates a resource, and it needs four pieces before it can say so honestly.

A creation DTO. OwnerForCreationDto carries the fields a client may set and omits the ones it may not: no Id, no Accounts. The validation attributes live here, on the shape that crosses the wire, not on the entity.

A mapping rule, so the DTO becomes an Owner the repository understands.

A repository method that stages the new entity, and a save that commits it. EF Core fills the Id in when we stage the entity, not when we save, so the response can name the new resource straight away.

A response that says where the new thing lives. CreatedAtRoute returns 201 Created, puts the created object in the body, and adds a Location header holding the URL that will fetch it. That is why the GET-by-id action is given a name: CreatedAtRoute looks the route up by that name to build the URL.

Firstly, let’s modify the decoration attribute for the GetOwnerById action method in the Owner controller:

[HttpGet("{id}", Name = "OwnerById")]

The route name is OwnerById and it is the string CreatedAtRoute looks up, so it stays the same even though the C# method name changes when the action becomes asynchronous.

Before we continue, we should create another DTO class. As we said in the previous part, we use the model class just to fetch the data from the database, and to return the result we need a DTO. It is the same for the create action. So, let’s create the OwnerForCreationDto class in the Entities/DataTransferObjects folder:

public class OwnerForCreationDto
{
    [Required(ErrorMessage = "Name is required")]
    [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")]
    public string? Name { get; set; }

    [Required(ErrorMessage = "Date of birth is required")]
    public DateTime DateOfBirth { get; set; }

    [Required(ErrorMessage = "Address is required")]
    [StringLength(100, ErrorMessage = "Address cannot be longer than 100 characters")]
    public string? Address { get; set; }
}

As we can see, we don’t have the Id and Accounts properties.

We are going to continue with the interface modification:

public interface IOwnerRepository
{
    Task<IEnumerable<Owner>> GetAllOwnersAsync();
    Task<Owner?> GetOwnerByIdAsync(Guid ownerId);
    Task<Owner?> GetOwnerWithDetailsAsync(Guid ownerId);
    void CreateOwner(Owner owner);
}

After the interface modification, we are going to implement that method:

public void CreateOwner(Owner owner) => Create(owner);

CreateOwner stays synchronous on purpose. It only stages the entity on the change tracker, and staging touches no database, so there is nothing to await here. The awaited call is the save on the wrapper, and that is the one that talks to SQL Server.

Before we modify the OwnerController, we have to create an additional mapping rule:

CreateMap<OwnerForCreationDto, Owner>();

The sample pins AutoMapper to 14.0.0, which is the last release under the MIT licence. From 15.0.0 the package moved to the Reciprocal Public License 1.5 or a commercial licence, and the registration call in Program.cs changed shape at the same time, so moving up is a decision rather than a version bump. One thing to weigh before pinning 14.0.0 in a project of our own: it is affected by GHSA-rvv3-g6hj-g44x, a high-severity denial-of-service advisory caused by uncontrolled recursion, and the first fixed release, 15.1.1, is already under the new non-MIT licence, which is why restoring this sample prints NU1903. If that decides it, our comparison of AutoMapper and Mapster weighs the alternative in detail.

Lastly, let’s modify the controller:

[HttpPost]
public async Task<IActionResult> CreateOwner([FromBody] OwnerForCreationDto owner)
{
    var ownerEntity = _mapper.Map<Owner>(owner);

    _repository.Owner.CreateOwner(ownerEntity);
    await _repository.SaveAsync();

    var createdOwner = _mapper.Map<OwnerDto>(ownerEntity);

    return CreatedAtRoute("OwnerById", new { id = createdOwner.Id }, createdOwner);
}

Right now is a good time to test this code by sending the POST request by using Postman.

Let’s examine the result:

POST HTTP request in .NET Core

What the Create Action Does

Let’s talk a little bit about this code. The interface and the repository parts are pretty clear, so we won’t talk about those. However, the code in the controller contains several things worth mentioning.

The CreateOwner method has its own [HttpPost] decoration attribute, which restricts it to POST requests. The owner parameter comes from the client, and it comes from the request body rather than from the URL, which is what [FromBody] says. On a controller decorated with [ApiController] that attribute is inferred for a complex type, so it can be left off; we keep it because it states the contract for anyone reading the action.

We could bind the same parameter from the query string with [FromQuery] or from the route with [FromRoute], but a complex object does not belong in a URL: it is long, it is logged, and it ends up in browser history.

There is no null check and no ModelState check in this action, and that is deliberate. [ApiController] validates the model before the action runs and short-circuits an invalid request with a 400 Bad Request carrying a ValidationProblemDetails body, and it makes [FromBody] parameters implicitly required, so an empty body is rejected the same way. Writing those checks by hand puts twelve lines into three actions that can never execute. This is what the [ApiController] attribute does for us, and there is more on how model validation works in a Web API in its own article.

An empty body produces this response:

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"":["A non-empty request body is required."],"owner":["The owner field is required."]}}

And a body that misses required properties produces this one, naming each of them:

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Name":["Name is required"],"Address":["Address is required"]}}

Both come from the [Required] and [StringLength] attributes on the DTO, with the messages we wrote there. The behaviour is opt-out rather than fixed: SuppressModelStateInvalidFilter and SuppressInferBindingSourcesForParameters turn each half of it off if an application needs to handle validation itself.

We have two map actions as well. The first one is from the OwnerForCreationDto type to the Owner type, because we accept the OwnerForCreationDto object from the client and we have to use the Owner object for the create action. The second map action is from the Owner type to the OwnerDto type, which is the type we return as a result.

The last thing to mention is this part of the code:

CreatedAtRoute("OwnerById", new { id = createdOwner.Id }, createdOwner);

CreatedAtRoute returns status code 201, which stands for Created. It also populates the body of the response with the new owner object, and the Location header with the address that retrieves that owner. We provide the name of the route where the created entity can be fetched, which is the name we added to the GET-by-id action a few paragraphs ago:

Post action header

If we copy that address and paste it into Postman, sending the GET request returns the newly created owner object. The id in the header is worth a second look: EF Core generated it while staging the entity, before the save, which is why the action can build the URL at all.

How Do We Handle a PUT Request?

A PUT action replaces a resource, and the word replaces is the whole specification. The client sends the complete new state; anything left out of the body is not “unchanged”, it is absent.

That is why the update DTO exists separately from the creation DTO even when the two look identical. They are different contracts, and their validation rules will diverge the first time a field becomes optional on one and not the other.

The action fetches the existing entity by id before mapping onto it. Mapping the DTO onto that existing entity, rather than constructing a new one, is what preserves the fields the DTO does not carry.

The response is 204 No Content. The client already knows the new state, since it sent it, so returning the object again is a payload nobody reads. A missing id returns 404 Not Found instead.

First, we are going to add a new DTO class:

public class OwnerForUpdateDto
{
    [Required(ErrorMessage = "Name is required")]
    [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")]
    public string? Name { get; set; }

    [Required(ErrorMessage = "Date of birth is required")]
    public DateTime DateOfBirth { get; set; }

    [Required(ErrorMessage = "Address is required")]
    [StringLength(100, ErrorMessage = "Address cannot be longer than 100 characters")]
    public string? Address { get; set; }
}

We did the same thing as with the OwnerForCreationDto class, and the properties are nullable for the same reason: the request body decides whether they arrive, and the [Required] attributes are what reject a body that leaves them out.

One more thing. If we want to remove the code duplication between OwnerForCreationDto and OwnerForUpdateDto, we can create an abstract class, move the shared properties to it, and let both classes inherit from it. For the sake of simplicity we won’t do that here.

After that, we have to create a new map rule:

CreateMap<OwnerForUpdateDto, Owner>();

Then, let’s change the interface:

public interface IOwnerRepository
{
    Task<IEnumerable<Owner>> GetAllOwnersAsync();
    Task<Owner?> GetOwnerByIdAsync(Guid ownerId);
    Task<Owner?> GetOwnerWithDetailsAsync(Guid ownerId);
    void CreateOwner(Owner owner);
    void UpdateOwner(Owner owner);
}

Of course, we have to modify the OwnerRepository class:

public void UpdateOwner(Owner owner) => Update(owner);

Finally, let’s alter the OwnerController:

[HttpPut("{id}")]
public async Task<IActionResult> UpdateOwner(Guid id, [FromBody] OwnerForUpdateDto owner)
{
    var ownerEntity = await _repository.Owner.GetOwnerByIdAsync(id);
    if (ownerEntity is null)
    {
        _logger.LogError($"Owner with id: {id}, hasn't been found in db.");
        return NotFound();
    }

    _mapper.Map(owner, ownerEntity);

    _repository.Owner.UpdateOwner(ownerEntity);
    await _repository.SaveAsync();

    return NoContent();
}

The action is decorated with the [HttpPut] attribute and it receives two parameters: the id of the entity we want to update, and the entity with the updated fields taken from the request body. After fetching the owner, _mapper.Map(owner, ownerEntity) copies the DTO’s three properties onto the entity we just read, which is how the Id and the accounts survive an update that never mentions them.

One consequence is worth knowing before it surprises us in a query log. Our repository reads with AsNoTracking(), so the entity arrives detached, and Update() re-attaches it with every property marked as modified. The generated statement therefore writes all three columns, not only the ones that changed.

Finally, we return NoContent, which stands for the status code 204:

PUT HTTP request in .NET Core

We can read more about update actions in ASP.NET Core with EF Core to get a better picture of how things work behind the scenes. If two clients may update the same owner at the same time, handling concurrent updates to the same record is the next thing to add.

How Do We Handle a DELETE Request?

A DELETE action removes a resource and returns 204 No Content, because there is nothing left to describe.

The interesting part is what happens when the row cannot be removed. Our owner has accounts, and the database schema this series created in part 1 sets the foreign key to restrict deletes rather than cascade them. Deleting an owner that still has accounts is refused by the database, and the request fails.

Returning that failure as a 500 would be wrong. Nothing broke; the client asked for something the data does not allow. 400 Bad Request with a message naming the reason is the honest answer, and the action checks for related accounts before attempting the delete.

DELETE requests should not carry a body. RFC 9110 gives content on a DELETE no generally defined semantics and lets a server reject it outright, so the id lives in the route and the action needs no [FromBody] parameter.

For the delete request, we follow the same three steps. The interface:

public interface IOwnerRepository
{
    Task<IEnumerable<Owner>> GetAllOwnersAsync();
    Task<Owner?> GetOwnerByIdAsync(Guid ownerId);
    Task<Owner?> GetOwnerWithDetailsAsync(Guid ownerId);
    void CreateOwner(Owner owner);
    void UpdateOwner(Owner owner);
    void DeleteOwner(Owner owner);
}

The OwnerRepository class:

public void DeleteOwner(Owner owner) => Delete(owner);

Before the controller, one more thing. An owner that still has accounts cannot be deleted, so we need a way to ask. Let’s modify the IAccountRepository interface:

using Entities.Models;

namespace Contracts
{
    public interface IAccountRepository
    {
        Task<IEnumerable<Account>> AccountsByOwnerAsync(Guid ownerId);
    }
}

Notice that IAccountRepository does not inherit IRepositoryBase<Account>, while AccountRepository still inherits RepositoryBase<Account> and calls FindByCondition below. That is the design part 5 argued for: the implementation gets the generic operations, and the interface exposes only the one query a caller is allowed to make.

Then let’s modify the AccountRepository class by adding the new method:

public async Task<IEnumerable<Account>> AccountsByOwnerAsync(Guid ownerId) =>
    await FindByCondition(a => a.OwnerId.Equals(ownerId)).ToListAsync();

Finally, the action in the OwnerController:

[HttpDelete("{id}")]
public async Task<IActionResult> DeleteOwner(Guid id)
{
    var owner = await _repository.Owner.GetOwnerByIdAsync(id);
    if (owner is null)
    {
        _logger.LogError($"Owner with id: {id}, hasn't been found in db.");
        return NotFound();
    }

    var accounts = await _repository.Account.AccountsByOwnerAsync(id);
    if (accounts.Any())
    {
        _logger.LogError($"Cannot delete owner with id: {id}. It has related accounts.");
        return BadRequest("Cannot delete owner. It has related accounts. Delete those accounts first");
    }

    _repository.Owner.DeleteOwner(owner);
    await _repository.SaveAsync();

    return NoContent();
}

Sending a DELETE request for an owner that still has accounts now answers with a 400 and a sentence a client can show a user:

Cannot delete owner. It has related accounts. Delete those accounts first

The pre-check buys that message, and it is not the guard. Between the check and the save, another request can insert an account, and the delete then fails at the database instead: EF Core raises a DbUpdateException wrapping a Microsoft.Data.SqlClient.SqlException with Number = 547, the foreign key violation. The constraint is the real protection; the pre-check is the good error message.

For a repository built asynchronously from the ground up in a standalone project, see Implementing Async Repository in .NET Core. We still recommend finishing all the parts of this series first, to get an easier understanding of the project’s business logic.

Errors are handled outside these actions, by a global handler, which is why each one reads as four lines of intent: how IExceptionHandler implements that, and why global error handling is the right shape for an API.

Which Status Code Should Each Action Return?

Three verbs, three success codes, and the differences are not arbitrary.

POST returns 201 Created because something now exists that did not before, and the client needs its address. The Location header carries that address, which is why the response is not merely 200 OK.

PUT and DELETE both return 204 No Content because the client already knows the outcome. After a successful replace, the new state is the body the client sent. After a successful delete, there is no state.

Failures divide the same way. 400 Bad Request means the request itself was unacceptable: a DTO that fails validation, or a delete the data does not permit. 404 Not Found means the URL addresses nothing. 500 means our code broke, and returning it for anything else hides real faults among expected ones.

Every one of these is a decision the client depends on.

ActionRequestRequest bodyOn successOn failure
CreateOwnerPOST /api/ownerOwnerForCreationDto201 Created, the new OwnerDto, and a Location header pointing at it400 Bad Request when validation fails
UpdateOwnerPUT /api/owner/{id}OwnerForUpdateDto204 No Content400 Bad Request when validation fails, 404 Not Found when the id does not exist
DeleteOwnerDELETE /api/owner/{id}none204 No Content404 Not Found when the id does not exist, 400 Bad Request when the owner still has accounts

When something really does break, returning a 500 properly is a subject of its own, and ProblemDetails, the standard shape for an error response, is what our handler writes into the body.

Conclusion

That completes the .NET side of this series: a database, a configured host, logging, a repository, and a full set of CRUD endpoints.

From here the API is ready for the things a real one needs, starting with pagination, filtering, searching and sorting, each covered in its own article.

If we want to build a client for this API instead, part 7 starts the Angular half of the series.

In this post we have learned:

  • How to handle a POST request and return the new resource’s address
  • How to handle a PUT request, and why it replaces rather than patches
  • How to handle a DELETE request, and what to return when the data refuses one
  • Why [ApiController] makes the hand-written validation guards unnecessary
  • Which status code each of the three actions returns, and why

A good exercise from here is to repeat all three actions for the Account entity, because nothing beats the practice.

This series builds a working API. The Ultimate ASP.NET Core Web API course builds a production one: a service layer, validation and content negotiation, versioning, caching, authentication, and the tests that hold it together.

Tested with .NET 10 (SDK 10.0.302), Microsoft.EntityFrameworkCore.SqlServer 10.0.11 and AutoMapper 14.0.0 against SQL Server Express LocalDB.