18 minlesson

CRUD Endpoints for Addresses

CRUD Endpoints for Addresses

In this presentation you will build the AddressController step by step. Each action method maps to one of the five CRUD operations and returns the correct HTTP status code.

Setting Up the Controller

ASP.NET Core API controllers inherit from ControllerBase and are decorated with [ApiController] and a route template. The [ApiController] attribute enables automatic model validation, binding source inference, and problem details responses for errors.

csharp
1using Microsoft.AspNetCore.Mvc;
2
3namespace ParcelTracking.Api.Controllers;
4
5[ApiController]
6[Route("api/[controller]")]
7public class AddressesController : ControllerBase
8{
9 private readonly ParcelTrackingDbContext _db;
10
11 public AddressesController(ParcelTrackingDbContext db)
12 {
13 _db = db;
14 }
15}

The [Route("api/[controller]")] template replaces [controller] with the class name minus the "Controller" suffix, giving you /api/addresses.

GET All Addresses

The list endpoint returns every address in the database. For now it returns the full collection; you will add filtering and pagination in a later topic.

csharp
1[HttpGet]
2public async Task<ActionResult<IEnumerable<AddressResponse>>> GetAll()
3{
4 var addresses = await _db.Addresses
5 .Select(a => new AddressResponse
6 {
7 Id = a.Id,
8 Street1 = a.Street1,
9 Street2 = a.Street2,
10 City = a.City,
11 State = a.State,
12 PostalCode = a.PostalCode,
13 CountryCode = a.CountryCode,
14 IsResidential = a.IsResidential,
15 ContactName = a.ContactName,
16 CompanyName = a.CompanyName,
17 Phone = a.Phone,
18 Email = a.Email
19 })
20 .ToListAsync();
21
22 return Ok(addresses);
23}

Key points:

  • [HttpGet] with no template matches the controller route /api/addresses
  • Select projects to the DTO inside the query so EF Core only fetches the columns you need
  • Returns 200 OK with the list in the response body, even if the list is empty

GET a Single Address

Retrieving one address by its ID follows the same pattern but includes a route parameter and a 404 check.

csharp
1[HttpGet("{id}")]
2public async Task<ActionResult<AddressResponse>> GetById(Guid id)
3{
4 var address = await _db.Addresses
5 .Where(a => a.Id == id)
6 .Select(a => new AddressResponse
7 {
8 Id = a.Id,
9 Street1 = a.Street1,
10 Street2 = a.Street2,
11 City = a.City,
12 State = a.State,
13 PostalCode = a.PostalCode,
14 CountryCode = a.CountryCode,
15 IsResidential = a.IsResidential,
16 ContactName = a.ContactName,
17 CompanyName = a.CompanyName,
18 Phone = a.Phone,
19 Email = a.Email
20 })
21 .FirstOrDefaultAsync();
22
23 if (address is null)
24 return NotFound();
25
26 return Ok(address);
27}

The {id} segment in [HttpGet("{id}")] is bound to the Guid id parameter by convention. If the address does not exist, the method returns 404 Not Found. The [ApiController] attribute ensures that invalid GUID values in the route return 400 automatically.

POST - Create a New Address

The create endpoint accepts a request body, maps it to an entity, saves it, and returns the created resource with its server-assigned ID.

csharp
1[HttpPost]
2public async Task<ActionResult<AddressResponse>> Create(CreateAddressRequest request)
3{
4 var address = new Address
5 {
6 Street1 = request.Street1,
7 Street2 = request.Street2,
8 City = request.City,
9 State = request.State,
10 PostalCode = request.PostalCode,
11 CountryCode = request.CountryCode,
12 IsResidential = request.IsResidential,
13 ContactName = request.ContactName,
14 CompanyName = request.CompanyName,
15 Phone = request.Phone,
16 Email = request.Email
17 };
18
19 _db.Addresses.Add(address);
20 await _db.SaveChangesAsync();
21
22 var response = new AddressResponse
23 {
24 Id = address.Id,
25 Street1 = address.Street1,
26 Street2 = address.Street2,
27 City = address.City,
28 State = address.State,
29 PostalCode = address.PostalCode,
30 CountryCode = address.CountryCode,
31 IsResidential = address.IsResidential,
32 ContactName = address.ContactName,
33 CompanyName = address.CompanyName,
34 Phone = address.Phone,
35 Email = address.Email
36 };
37
38 return CreatedAtAction(nameof(GetById), new { id = address.Id }, response);
39}

Understanding CreatedAtAction

CreatedAtAction does three things:

  1. Sets the status code to 201 Created
  2. Adds a Location header pointing to the new resource (/api/addresses/42)
  3. Returns the created resource in the body

The first argument is the name of the action that retrieves this resource (GetById). The second is the route values. The third is the response body. This follows the REST convention that a successful creation tells the client exactly where to find the new resource.

Why Not Just Return Ok?

Returning Ok() with a 200 status code after creation is technically functional but loses important information. The Location header in a 201 response allows automated clients and API gateways to locate the newly created resource without parsing the response body.

PUT - Update an Existing Address

The update endpoint replaces all fields of an existing address. It uses PUT because the client sends the full representation.

csharp
1[HttpPut("{id}")]
2public async Task<ActionResult<AddressResponse>> Update(Guid id, UpdateAddressRequest request)
3{
4 var address = await _db.Addresses.FindAsync(id);
5
6 if (address is null)
7 return NotFound();
8
9 address.Street1 = request.Street1;
10 address.Street2 = request.Street2;
11 address.City = request.City;
12 address.State = request.State;
13 address.PostalCode = request.PostalCode;
14 address.CountryCode = request.CountryCode;
15 address.IsResidential = request.IsResidential;
16 address.ContactName = request.ContactName;
17 address.CompanyName = request.CompanyName;
18 address.Phone = request.Phone;
19 address.Email = request.Email;
20
21 await _db.SaveChangesAsync();
22
23 var response = new AddressResponse
24 {
25 Id = address.Id,
26 Street1 = address.Street1,
27 Street2 = address.Street2,
28 City = address.City,
29 State = address.State,
30 PostalCode = address.PostalCode,
31 CountryCode = address.CountryCode,
32 IsResidential = address.IsResidential,
33 ContactName = address.ContactName,
34 CompanyName = address.CompanyName,
35 Phone = address.Phone,
36 Email = address.Email
37 };
38
39 return Ok(response);
40}

The flow is straightforward:

  1. Look up the existing entity by ID
  2. Return 404 if it does not exist
  3. Map request properties onto the tracked entity
  4. Call SaveChangesAsync to persist changes
  5. Return the updated resource with 200 OK

PUT vs PATCH

PUT replaces the entire resource. The client must send every field, even those that did not change. PATCH applies a partial update using the JSON Patch standard (RFC 6902), sending only the operations that describe what changed. This API uses PUT for addresses because address updates typically involve re-entering the full address. The parcel endpoints use JSON Patch with JsonPatchDocument<T> for partial updates like status changes — you will see this in Topic 6.

DELETE - Remove an Address

The delete endpoint removes an address but must check for referential integrity first.

csharp
1[HttpDelete("{id}")]
2public async Task<IActionResult> Delete(Guid id)
3{
4 var address = await _db.Addresses.FindAsync(id);
5
6 if (address is null)
7 return NotFound();
8
9 var isReferenced = await _db.Parcels
10 .AnyAsync(p => p.ShipperAddressId == id || p.RecipientAddressId == id);
11
12 if (isReferenced)
13 {
14 return Conflict(new { message = "Cannot delete address. It is referenced by existing parcels." });
15 }
16
17 _db.Addresses.Remove(address);
18 await _db.SaveChangesAsync();
19
20 return NoContent();
21}

The status codes in the delete endpoint tell a clear story:

ScenarioStatus CodeReason
Address not found404 Not FoundNothing to delete
Address in use by parcels409 ConflictDeletion would break referential integrity
Address deleted204 No ContentSuccess, no body needed

204 No Content is the standard response for a successful delete. The resource is gone, so there is nothing to return in the body.

Extracting a Mapping Helper

The controller has duplicate mapping logic between the entity and response DTO. You can extract this into a private method or an extension method to keep things DRY:

csharp
1public static class AddressMappingExtensions
2{
3 public static AddressResponse ToResponse(this Address address)
4 {
5 return new AddressResponse
6 {
7 Id = address.Id,
8 Street1 = address.Street1,
9 Street2 = address.Street2,
10 City = address.City,
11 State = address.State,
12 PostalCode = address.PostalCode,
13 CountryCode = address.CountryCode,
14 IsResidential = address.IsResidential,
15 ContactName = address.ContactName,
16 CompanyName = address.CompanyName,
17 Phone = address.Phone,
18 Email = address.Email
19 };
20 }
21
22 public static IQueryable<AddressResponse> ProjectToResponse(
23 this IQueryable<Address> query)
24 {
25 return query.Select(a => new AddressResponse
26 {
27 Id = a.Id,
28 Street1 = a.Street1,
29 Street2 = a.Street2,
30 City = a.City,
31 State = a.State,
32 PostalCode = a.PostalCode,
33 CountryCode = a.CountryCode,
34 IsResidential = a.IsResidential,
35 ContactName = a.ContactName,
36 CompanyName = a.CompanyName,
37 Phone = a.Phone,
38 Email = a.Email
39 });
40 }
41}

With these extensions, the GET endpoints become much cleaner:

csharp
1[HttpGet]
2public async Task<ActionResult<IEnumerable<AddressResponse>>> GetAll()
3{
4 var addresses = await _db.Addresses
5 .ProjectToResponse()
6 .ToListAsync();
7
8 return Ok(addresses);
9}
10
11[HttpGet("{id}")]
12public async Task<ActionResult<AddressResponse>> GetById(Guid id)
13{
14 var address = await _db.Addresses
15 .Where(a => a.Id == id)
16 .ProjectToResponse()
17 .FirstOrDefaultAsync();
18
19 if (address is null)
20 return NotFound();
21
22 return Ok(address);
23}

The ProjectToResponse method works as an IQueryable extension, so the projection still happens in SQL rather than in memory.

Complete Controller Summary

Here is the final shape of the controller with all five endpoints:

csharp
1[ApiController]
2[Route("api/[controller]")]
3public class AddressesController : ControllerBase
4{
5 private readonly ParcelTrackingDbContext _db;
6
7 public AddressesController(ParcelTrackingDbContext db)
8 {
9 _db = db;
10 }
11
12 [HttpGet]
13 public async Task<ActionResult<IEnumerable<AddressResponse>>> GetAll() { ... }
14
15 [HttpGet("{id}")]
16 public async Task<ActionResult<AddressResponse>> GetById(Guid id) { ... }
17
18 [HttpPost]
19 public async Task<ActionResult<AddressResponse>> Create(CreateAddressRequest request) { ... }
20
21 [HttpPut("{id}")]
22 public async Task<ActionResult<AddressResponse>> Update(Guid id, UpdateAddressRequest request) { ... }
23
24 [HttpDelete("{id}")]
25 public async Task<IActionResult> Delete(Guid id) { ... }
26}

Each method handles one HTTP verb, uses the correct status codes, and separates the API contract from the entity model through DTOs. The next presentation covers how to add validation to the request DTOs.