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.
csharp1using Microsoft.AspNetCore.Mvc;23namespace ParcelTracking.Api.Controllers;45[ApiController]6[Route("api/[controller]")]7public class AddressesController : ControllerBase8{9 private readonly ParcelTrackingDbContext _db;1011 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.
csharp1[HttpGet]2public async Task<ActionResult<IEnumerable<AddressResponse>>> GetAll()3{4 var addresses = await _db.Addresses5 .Select(a => new AddressResponse6 {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.Email19 })20 .ToListAsync();2122 return Ok(addresses);23}
Key points:
[HttpGet]with no template matches the controller route/api/addressesSelectprojects 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.
csharp1[HttpGet("{id}")]2public async Task<ActionResult<AddressResponse>> GetById(Guid id)3{4 var address = await _db.Addresses5 .Where(a => a.Id == id)6 .Select(a => new AddressResponse7 {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.Email20 })21 .FirstOrDefaultAsync();2223 if (address is null)24 return NotFound();2526 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.
csharp1[HttpPost]2public async Task<ActionResult<AddressResponse>> Create(CreateAddressRequest request)3{4 var address = new Address5 {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.Email17 };1819 _db.Addresses.Add(address);20 await _db.SaveChangesAsync();2122 var response = new AddressResponse23 {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.Email36 };3738 return CreatedAtAction(nameof(GetById), new { id = address.Id }, response);39}
Understanding CreatedAtAction
CreatedAtAction does three things:
- Sets the status code to 201 Created
- Adds a
Locationheader pointing to the new resource (/api/addresses/42) - 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.
csharp1[HttpPut("{id}")]2public async Task<ActionResult<AddressResponse>> Update(Guid id, UpdateAddressRequest request)3{4 var address = await _db.Addresses.FindAsync(id);56 if (address is null)7 return NotFound();89 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;2021 await _db.SaveChangesAsync();2223 var response = new AddressResponse24 {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.Email37 };3839 return Ok(response);40}
The flow is straightforward:
- Look up the existing entity by ID
- Return 404 if it does not exist
- Map request properties onto the tracked entity
- Call
SaveChangesAsyncto persist changes - 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.
csharp1[HttpDelete("{id}")]2public async Task<IActionResult> Delete(Guid id)3{4 var address = await _db.Addresses.FindAsync(id);56 if (address is null)7 return NotFound();89 var isReferenced = await _db.Parcels10 .AnyAsync(p => p.ShipperAddressId == id || p.RecipientAddressId == id);1112 if (isReferenced)13 {14 return Conflict(new { message = "Cannot delete address. It is referenced by existing parcels." });15 }1617 _db.Addresses.Remove(address);18 await _db.SaveChangesAsync();1920 return NoContent();21}
The status codes in the delete endpoint tell a clear story:
| Scenario | Status Code | Reason |
|---|---|---|
| Address not found | 404 Not Found | Nothing to delete |
| Address in use by parcels | 409 Conflict | Deletion would break referential integrity |
| Address deleted | 204 No Content | Success, 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:
csharp1public static class AddressMappingExtensions2{3 public static AddressResponse ToResponse(this Address address)4 {5 return new AddressResponse6 {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.Email19 };20 }2122 public static IQueryable<AddressResponse> ProjectToResponse(23 this IQueryable<Address> query)24 {25 return query.Select(a => new AddressResponse26 {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.Email39 });40 }41}
With these extensions, the GET endpoints become much cleaner:
csharp1[HttpGet]2public async Task<ActionResult<IEnumerable<AddressResponse>>> GetAll()3{4 var addresses = await _db.Addresses5 .ProjectToResponse()6 .ToListAsync();78 return Ok(addresses);9}1011[HttpGet("{id}")]12public async Task<ActionResult<AddressResponse>> GetById(Guid id)13{14 var address = await _db.Addresses15 .Where(a => a.Id == id)16 .ProjectToResponse()17 .FirstOrDefaultAsync();1819 if (address is null)20 return NotFound();2122 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:
csharp1[ApiController]2[Route("api/[controller]")]3public class AddressesController : ControllerBase4{5 private readonly ParcelTrackingDbContext _db;67 public AddressesController(ParcelTrackingDbContext db)8 {9 _db = db;10 }1112 [HttpGet]13 public async Task<ActionResult<IEnumerable<AddressResponse>>> GetAll() { ... }1415 [HttpGet("{id}")]16 public async Task<ActionResult<AddressResponse>> GetById(Guid id) { ... }1718 [HttpPost]19 public async Task<ActionResult<AddressResponse>> Create(CreateAddressRequest request) { ... }2021 [HttpPut("{id}")]22 public async Task<ActionResult<AddressResponse>> Update(Guid id, UpdateAddressRequest request) { ... }2324 [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.