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.
Why Use a Service Layer?
In Topic 1, we configured the DbContext in the Infrastructure project. You might expect controllers to inject the DbContext directly. However, in a clean architecture, controllers should never inject DbContext. Instead, they inject services from the Application layer.
The Service Layer Pattern
Controllers (Api) → Services (Application) → DbContext (Infrastructure)
This pattern provides several benefits:
- Separation of concerns: Controllers handle HTTP routing and responses. Services handle business logic and data access.
- Testability: You can unit-test services without a web server. You can test controllers by mocking the service interface.
- Reusability: Multiple controllers can share the same service. Background jobs can use services too.
- Domain logic isolation: Business rules live in services, not scattered across controllers.
In this presentation, you will:
- Define the
IAddressServiceinterface - Implement
AddressServicewith manual DTO mapping - Build thin controllers that delegate to the service
- Register the service in
Program.cs
Defining the Service Interface
Service interfaces define the operations available for a domain entity. Create IAddressService.cs in src/ParcelTracking.Application/Services/:
csharp1using ParcelTracking.Application.DTOs.Addresses;2using ParcelTracking.Application.DTOs.Common;34namespace ParcelTracking.Application.Services;56public interface IAddressService7{8 Task<PagedResult<AddressDto>> GetAllAsync(int page = 1, int pageSize = 50);9 Task<AddressDto?> GetByIdAsync(Guid id);10 Task<AddressDto> CreateAsync(CreateAddressRequest request);11 Task<AddressDto?> UpdateAsync(Guid id, UpdateAddressRequest request);12 Task<bool> DeleteAsync(Guid id);13}
Key design choices:
- Methods return DTOs, not entities: The service translates entities to DTOs so controllers never see domain entities.
- Async all the way: Every method is
asyncbecause data access is async. - Nullable return types:
GetByIdAsyncandUpdateAsyncreturnnullwhen the resource does not exist. - Boolean for delete:
DeleteAsyncreturnstrueif deleted,falseif not found. Business rule violations (like referential integrity) will throw exceptions.
Implementing the Service
Create AddressService.cs in src/ParcelTracking.Application/Services/:
csharp1using Microsoft.EntityFrameworkCore;2using Microsoft.Extensions.Logging;3using ParcelTracking.Application.DTOs.Addresses;4using ParcelTracking.Application.DTOs.Common;5using ParcelTracking.Domain.Entities;6using ParcelTracking.Infrastructure.Data;78namespace ParcelTracking.Application.Services;910public class AddressService : IAddressService11{12 private readonly ParcelTrackingDbContext _db;13 private readonly ILogger<AddressService> _logger;1415 public AddressService(16 ParcelTrackingDbContext db,17 ILogger<AddressService> logger)18 {19 _db = db;20 _logger = logger;21 }2223 public async Task<PagedResult<AddressDto>> GetAllAsync(int page = 1, int pageSize = 50)24 {25 var totalCount = await _db.Addresses.CountAsync();2627 var addresses = await _db.Addresses28 .OrderBy(a => a.City)29 .ThenBy(a => a.Street1)30 .Skip((page - 1) * pageSize)31 .Take(pageSize)32 .ToListAsync();3334 return new PagedResult<AddressDto>35 {36 Items = addresses.Select(MapToDto).ToList(),37 TotalCount = totalCount,38 Page = page,39 PageSize = pageSize40 };41 }4243 public async Task<AddressDto?> GetByIdAsync(Guid id)44 {45 var address = await _db.Addresses.FindAsync(id);46 return address == null ? null : MapToDto(address);47 }4849 public async Task<AddressDto> CreateAsync(CreateAddressRequest request)50 {51 var address = new Address52 {53 Id = Guid.NewGuid(),54 Street1 = request.Street1,55 Street2 = request.Street2,56 City = request.City,57 State = request.State,58 PostalCode = request.PostalCode,59 CountryCode = request.CountryCode,60 IsResidential = request.IsResidential,61 ContactName = request.ContactName,62 CompanyName = request.CompanyName,63 Phone = request.Phone,64 Email = request.Email65 };6667 _db.Addresses.Add(address);68 await _db.SaveChangesAsync();6970 _logger.LogInformation(71 "Created address {AddressId} for {ContactName} in {City}",72 address.Id, address.ContactName, address.City);7374 return MapToDto(address);75 }7677 public async Task<AddressDto?> UpdateAsync(Guid id, UpdateAddressRequest request)78 {79 var address = await _db.Addresses.FindAsync(id);80 if (address == null)81 return null;8283 address.Street1 = request.Street1;84 address.Street2 = request.Street2;85 address.City = request.City;86 address.State = request.State;87 address.PostalCode = request.PostalCode;88 address.CountryCode = request.CountryCode;89 address.IsResidential = request.IsResidential;90 address.ContactName = request.ContactName;91 address.CompanyName = request.CompanyName;92 address.Phone = request.Phone;93 address.Email = request.Email;9495 await _db.SaveChangesAsync();9697 _logger.LogInformation("Updated address {AddressId}", address.Id);9899 return MapToDto(address);100 }101102 public async Task<bool> DeleteAsync(Guid id)103 {104 var address = await _db.Addresses.FindAsync(id);105 if (address == null)106 return false;107108 // Check referential integrity109 var isReferenced = await _db.Parcels110 .AnyAsync(p => p.ShipperAddressId == id || p.RecipientAddressId == id);111112 if (isReferenced)113 {114 throw new InvalidOperationException(115 "Cannot delete address. It is referenced by existing parcels.");116 }117118 _db.Addresses.Remove(address);119 await _db.SaveChangesAsync();120121 _logger.LogInformation("Deleted address {AddressId}", id);122123 return true;124 }125126 private static AddressDto MapToDto(Address address) => new()127 {128 Id = address.Id,129 Street1 = address.Street1,130 Street2 = address.Street2,131 City = address.City,132 State = address.State,133 PostalCode = address.PostalCode,134 CountryCode = address.CountryCode,135 IsResidential = address.IsResidential,136 ContactName = address.ContactName,137 CompanyName = address.CompanyName,138 Phone = address.Phone,139 Email = address.Email140 };141}
Manual Mapping
Notice the MapToDto method at the bottom. This performs manual mapping from Address entity to AddressDto. We avoid libraries like AutoMapper to keep dependencies minimal and mapping logic explicit. Each property is assigned individually.
Logging
The service logs important operations (create, update, delete) with structured logging. This provides an audit trail and helps with debugging.
Business Rules
The DeleteAsync method checks referential integrity and throws an exception if the address is still referenced by parcels. The controller will catch this and return a 409 Conflict response.
Setting Up the Controller
With the service layer in place, the controller becomes much simpler. It injects IAddressService instead of ParcelTrackingDbContext:
csharp1using Microsoft.AspNetCore.Mvc;2using ParcelTracking.Application.DTOs.Addresses;3using ParcelTracking.Application.DTOs.Common;4using ParcelTracking.Application.Services;56namespace ParcelTracking.Api.Controllers;78[ApiController]9[Route("api/[controller]")]10public class AddressesController : ControllerBase11{12 private readonly IAddressService _addressService;13 private readonly ILogger<AddressesController> _logger;1415 public AddressesController(16 IAddressService addressService,17 ILogger<AddressesController> logger)18 {19 _addressService = addressService;20 _logger = logger;21 }22}
The controller depends on the service interface, not the implementation. This allows for easy testing and swapping implementations.
The [ApiController] attribute enables automatic model validation, binding source inference, and problem details responses for errors. 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 delegates to the service to retrieve all addresses with pagination:
csharp1[HttpGet]2public async Task<ActionResult<PagedResult<AddressDto>>> GetAll(3 [FromQuery] int page = 1,4 [FromQuery] int pageSize = 50)5{6 var result = await _addressService.GetAllAsync(page, pageSize);7 return Ok(result);8}
Key points:
[HttpGet]with no template matches the controller route/api/addresses[FromQuery]binds pagination parameters from the query string (?page=2&pageSize=25)- Controller only handles HTTP concerns: parameter binding, calling the service, wrapping in
Ok() - Returns 200 OK with the paged result in the response body
The service handles ordering, pagination math, and DTO mapping. The controller just orchestrates the HTTP layer.
GET a Single Address
Retrieving one address by its ID follows the same pattern:
csharp1[HttpGet("{id}")]2public async Task<ActionResult<AddressDto>> GetById(Guid id)3{4 var address = await _addressService.GetByIdAsync(id);56 if (address is null)7 return NotFound();89 return Ok(address);10}
The {id} segment in [HttpGet("{id}")] is bound to the Guid id parameter by convention. If the address does not exist, the service returns null and the controller returns 404 Not Found. The [ApiController] attribute ensures that invalid GUID values in the route return 400 automatically.
The controller's only responsibilities are:
- Call the service
- Check for null
- Return the correct status code
POST - Create a New Address
The create endpoint accepts a request body and delegates creation to the service:
csharp1[HttpPost]2public async Task<ActionResult<AddressDto>> Create(CreateAddressRequest request)3{4 var address = await _addressService.CreateAsync(request);56 return CreatedAtAction(nameof(GetById), new { id = address.Id }, address);7}
Understanding CreatedAtAction
CreatedAtAction does three things:
- Sets the status code to 201 Created
- Adds a
Locationheader pointing to the new resource (/api/addresses/{id}) - 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 delegates to the service and checks for null:
csharp1[HttpPut("{id}")]2public async Task<ActionResult<AddressDto>> Update(Guid id, UpdateAddressRequest request)3{4 var address = await _addressService.UpdateAsync(id, request);56 if (address is null)7 return NotFound();89 return Ok(address);10}
The service handles:
- Looking up the existing entity by ID
- Mapping request properties onto the tracked entity
- Calling
SaveChangesAsyncto persist changes - Returning the updated DTO
The controller only checks if the service returned null (address not found) and returns the appropriate status code.
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 delegates to the service and handles both not-found and business rule violations:
csharp1[HttpDelete("{id}")]2public async Task<IActionResult> Delete(Guid id)3{4 try5 {6 var deleted = await _addressService.DeleteAsync(id);78 if (!deleted)9 return NotFound();1011 return NoContent();12 }13 catch (InvalidOperationException ex)14 {15 return Conflict(new { message = ex.Message });16 }17}
The status codes 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.
The service throws an InvalidOperationException when referential integrity is violated. The controller catches this and returns 409 Conflict with a descriptive message.
Registering the Service
The service must be registered in the dependency injection container. Add this to Program.cs in the Api project:
csharp1// Register services2builder.Services.AddScoped<IAddressService, AddressService>();
Place this after the DbContext registration and before builder.Build(). The AddScoped lifetime means one instance per HTTP request, which is appropriate for services that use a DbContext.
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 IAddressService _addressService;6 private readonly ILogger<AddressesController> _logger;78 public AddressesController(9 IAddressService addressService,10 ILogger<AddressesController> logger)11 {12 _addressService = addressService;13 _logger = logger;14 }1516 [HttpGet]17 public async Task<ActionResult<PagedResult<AddressDto>>> GetAll(18 [FromQuery] int page = 1, [FromQuery] int pageSize = 50) { ... }1920 [HttpGet("{id}")]21 public async Task<ActionResult<AddressDto>> GetById(Guid id) { ... }2223 [HttpPost]24 public async Task<ActionResult<AddressDto>> Create(CreateAddressRequest request) { ... }2526 [HttpPut("{id}")]27 public async Task<ActionResult<AddressDto>> Update(Guid id, UpdateAddressRequest request) { ... }2829 [HttpDelete("{id}")]30 public async Task<IActionResult> Delete(Guid id) { ... }31}
Each method handles one HTTP verb, uses the correct status codes, and delegates all business logic to the service layer. The controller remains thin and focused only on HTTP concerns.
Key Takeaways
The service layer pattern provides:
- Clean separation: Controllers handle HTTP. Services handle business logic.
- Testability: Services can be tested without a web server. Controllers can be tested with mocked services.
- Reusability: Services can be used by controllers, background jobs, SignalR hubs, etc.
- Maintainability: Business rules live in one place, not scattered across controllers.
In the next presentation, you will add validation to the request DTOs using Data Annotations and FluentValidation.