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.

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:

  1. Define the IAddressService interface
  2. Implement AddressService with manual DTO mapping
  3. Build thin controllers that delegate to the service
  4. 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/:

csharp
1using ParcelTracking.Application.DTOs.Addresses;
2using ParcelTracking.Application.DTOs.Common;
3
4namespace ParcelTracking.Application.Services;
5
6public interface IAddressService
7{
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 async because data access is async.
  • Nullable return types: GetByIdAsync and UpdateAsync return null when the resource does not exist.
  • Boolean for delete: DeleteAsync returns true if deleted, false if not found. Business rule violations (like referential integrity) will throw exceptions.

Implementing the Service

Create AddressService.cs in src/ParcelTracking.Application/Services/:

csharp
1using Microsoft.EntityFrameworkCore;
2using Microsoft.Extensions.Logging;
3using ParcelTracking.Application.DTOs.Addresses;
4using ParcelTracking.Application.DTOs.Common;
5using ParcelTracking.Domain.Entities;
6using ParcelTracking.Infrastructure.Data;
7
8namespace ParcelTracking.Application.Services;
9
10public class AddressService : IAddressService
11{
12 private readonly ParcelTrackingDbContext _db;
13 private readonly ILogger<AddressService> _logger;
14
15 public AddressService(
16 ParcelTrackingDbContext db,
17 ILogger<AddressService> logger)
18 {
19 _db = db;
20 _logger = logger;
21 }
22
23 public async Task<PagedResult<AddressDto>> GetAllAsync(int page = 1, int pageSize = 50)
24 {
25 var totalCount = await _db.Addresses.CountAsync();
26
27 var addresses = await _db.Addresses
28 .OrderBy(a => a.City)
29 .ThenBy(a => a.Street1)
30 .Skip((page - 1) * pageSize)
31 .Take(pageSize)
32 .ToListAsync();
33
34 return new PagedResult<AddressDto>
35 {
36 Items = addresses.Select(MapToDto).ToList(),
37 TotalCount = totalCount,
38 Page = page,
39 PageSize = pageSize
40 };
41 }
42
43 public async Task<AddressDto?> GetByIdAsync(Guid id)
44 {
45 var address = await _db.Addresses.FindAsync(id);
46 return address == null ? null : MapToDto(address);
47 }
48
49 public async Task<AddressDto> CreateAsync(CreateAddressRequest request)
50 {
51 var address = new Address
52 {
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.Email
65 };
66
67 _db.Addresses.Add(address);
68 await _db.SaveChangesAsync();
69
70 _logger.LogInformation(
71 "Created address {AddressId} for {ContactName} in {City}",
72 address.Id, address.ContactName, address.City);
73
74 return MapToDto(address);
75 }
76
77 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;
82
83 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;
94
95 await _db.SaveChangesAsync();
96
97 _logger.LogInformation("Updated address {AddressId}", address.Id);
98
99 return MapToDto(address);
100 }
101
102 public async Task<bool> DeleteAsync(Guid id)
103 {
104 var address = await _db.Addresses.FindAsync(id);
105 if (address == null)
106 return false;
107
108 // Check referential integrity
109 var isReferenced = await _db.Parcels
110 .AnyAsync(p => p.ShipperAddressId == id || p.RecipientAddressId == id);
111
112 if (isReferenced)
113 {
114 throw new InvalidOperationException(
115 "Cannot delete address. It is referenced by existing parcels.");
116 }
117
118 _db.Addresses.Remove(address);
119 await _db.SaveChangesAsync();
120
121 _logger.LogInformation("Deleted address {AddressId}", id);
122
123 return true;
124 }
125
126 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.Email
140 };
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:

csharp
1using Microsoft.AspNetCore.Mvc;
2using ParcelTracking.Application.DTOs.Addresses;
3using ParcelTracking.Application.DTOs.Common;
4using ParcelTracking.Application.Services;
5
6namespace ParcelTracking.Api.Controllers;
7
8[ApiController]
9[Route("api/[controller]")]
10public class AddressesController : ControllerBase
11{
12 private readonly IAddressService _addressService;
13 private readonly ILogger<AddressesController> _logger;
14
15 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:

csharp
1[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:

csharp
1[HttpGet("{id}")]
2public async Task<ActionResult<AddressDto>> GetById(Guid id)
3{
4 var address = await _addressService.GetByIdAsync(id);
5
6 if (address is null)
7 return NotFound();
8
9 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:

  1. Call the service
  2. Check for null
  3. Return the correct status code

POST - Create a New Address

The create endpoint accepts a request body and delegates creation to the service:

csharp
1[HttpPost]
2public async Task<ActionResult<AddressDto>> Create(CreateAddressRequest request)
3{
4 var address = await _addressService.CreateAsync(request);
5
6 return CreatedAtAction(nameof(GetById), new { id = address.Id }, address);
7}

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/{id})
  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 delegates to the service and checks for null:

csharp
1[HttpPut("{id}")]
2public async Task<ActionResult<AddressDto>> Update(Guid id, UpdateAddressRequest request)
3{
4 var address = await _addressService.UpdateAsync(id, request);
5
6 if (address is null)
7 return NotFound();
8
9 return Ok(address);
10}

The service handles:

  1. Looking up the existing entity by ID
  2. Mapping request properties onto the tracked entity
  3. Calling SaveChangesAsync to persist changes
  4. 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:

csharp
1[HttpDelete("{id}")]
2public async Task<IActionResult> Delete(Guid id)
3{
4 try
5 {
6 var deleted = await _addressService.DeleteAsync(id);
7
8 if (!deleted)
9 return NotFound();
10
11 return NoContent();
12 }
13 catch (InvalidOperationException ex)
14 {
15 return Conflict(new { message = ex.Message });
16 }
17}

The status codes 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.

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:

csharp
1// Register services
2builder.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:

csharp
1[ApiController]
2[Route("api/[controller]")]
3public class AddressesController : ControllerBase
4{
5 private readonly IAddressService _addressService;
6 private readonly ILogger<AddressesController> _logger;
7
8 public AddressesController(
9 IAddressService addressService,
10 ILogger<AddressesController> logger)
11 {
12 _addressService = addressService;
13 _logger = logger;
14 }
15
16 [HttpGet]
17 public async Task<ActionResult<PagedResult<AddressDto>>> GetAll(
18 [FromQuery] int page = 1, [FromQuery] int pageSize = 50) { ... }
19
20 [HttpGet("{id}")]
21 public async Task<ActionResult<AddressDto>> GetById(Guid id) { ... }
22
23 [HttpPost]
24 public async Task<ActionResult<AddressDto>> Create(CreateAddressRequest request) { ... }
25
26 [HttpPut("{id}")]
27 public async Task<ActionResult<AddressDto>> Update(Guid id, UpdateAddressRequest request) { ... }
28
29 [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.