To solve our problems, we reimplement the controller using DTOs. To make it easier to follow along, here are all the DTOs as a reference:
namespace Shared.DTO;
public record class ContractDetails(
int Id,
string Name,
string Description,
int StatusTotalWork,
int StatusWorkDone,
string StatusWorkState,
string PrimaryContactFirstName,
string PrimaryContactLastName,
string PrimaryContactEmail
);
public record class CustomerDetails(
int Id,
string Name,
IEnumerable<ContractDetails> Contracts
);
public record class CustomerSummary(
int Id,
string Name,
int TotalNumberOfContracts,
int NumberOfOpenContracts
);
public record class CreateCustomer(string Name);
public record class UpdateCustomer(string Name);
First, let’s fix our update problem, starting with the reimplementation of the update endpoint leveraging DTOs (see the DTOCustomersController.cs file):
// PUT dto/customers/1
[HttpPut(“{customerId}”)]
public async Task<ActionResult<CustomerDetails>> PutAsync(
int customerId,
[FromBody] UpdateCustomer input,
ICustomerRepository customerRepository)
{
// Get the customer
var customer = await customerRepository.FindAsync(
customerId,
HttpContext.RequestAborted
);
if (customer == null)
{
return NotFound();
}
// Update the customer’s name using the UpdateCustomer DTO
var updatedCustomer = await customerRepository.UpdateAsync(
customer with { Name = input.Name },
HttpContext.RequestAborted
);
if (updatedCustomer == null)
{
return Conflict();
}
// Map the updated customer to a CustomerDetails DTO
var dto = MapCustomerToCustomerDetails(updatedCustomer);
// Return the DTO
return dto;
}
In the preceding code, the main differences are (highlighted):
- The request body is now bound to the UpdateCustomer class instead of the Customer itself.
- The action method returns an instance of the CustomerDetails class instead of the Customer itself.
However, we can see more code in our controller action than before. That’s because the controller now handles the data changes instead of the clients. The action now does:
- Load the data from the database.
- Ensure the entity exists.
- Use the input DTO to update the data, limiting the clients to a subset of properties.
- Proceed with the update.
- Ensure the entity still exists (handles conflicts).
- Copy the Customer into the output DTO and return it.
By doing this, we now control what the clients can do when they send a PUT request through the input DTO (UpdateCustomer). Moreover, we encapsulated the logic to calculate the statistics on the server. We hid the computation behind the output DTO (CustomerDetails), which lowers the complexity of our user interface and allows us to improve the performance without impacting any of our clients (loose coupling).Furthermore, we now use the customerId parameter.If we send the same HTTP request as before, which sends more data than we accept, only the customer’s name will change. On top of that, we get all the data we need to display the customer’s statistics. Here’s a response example:
{
“id”: 1,
“name”: “Some new name”,
“contracts”: [
{
“id”: 1,
“name”: “First contract”,
“description”: “This is the first contract.”,
“statusTotalWork”: 100,
“statusWorkDone”: 100,
“statusWorkState”: “Completed”,
“primaryContactFirstName”: “John”,
“primaryContactLastName”: “Doe”,
“primaryContactEmail”: “[email protected]”
},
{
“id”: 2,
“name”: “Some other contract”,
“description”: “This is another contract.”,
“statusTotalWork”: 100,
“statusWorkDone”: 25,
“statusWorkState”: “InProgress”,
“primaryContactFirstName”: “Jane”,
“primaryContactLastName”: “Doe”,
“primaryContactEmail”: “[email protected]”
}
]
}
As we can see from the preceding response, only the customer’s name changed, but we now received the statusWorkDone and statusTotalWork fields. Lastly, we flattened the data structure.
DTOs are a great resource to flatten data structures, but you don’t have to. You must always design your systems, including DTOs and data contracts, for specific use cases.
As for the dashboard, the “get all customers” endpoint achieves this by doing something similar. It outputs a collection of CustomerSummary objects instead of the customers themselves. In this case, the controller executes the calculations and copies the entity’s relevant properties to the DTO. Here’s the code:
// GET: dto/customers
[HttpGet]
public async Task<IEnumerable<CustomerSummary>> GetAllAsync(
ICustomerRepository customerRepository)
{
// Get all customers
var customers = await customerRepository.AllAsync(
HttpContext.RequestAborted
);
// Map customers to CustomerSummary DTOs
var customersSummary = customers
.Select(customer => new CustomerSummary(
Id: customer.Id,
Name: customer.Name,
TotalNumberOfContracts: customer.Contracts.Count,
NumberOfOpenContracts: customer.Contracts.Count(x => x.Status.State != WorkState.Completed)
))
;
// Return the DTOs
return customersSummary;
}
In the preceding code, the action method:
- Read the entities
- Create the DTOs and calculate the number of open contracts.
- Return the DTOs.
As simple as that, we now encapsulated the computation on the server.
You should optimize such code based on your real-life data source. In this case, a static List<T> is low latency. However, querying the whole database to get a count can become a bottleneck.
Calling the endpoint results in the following:
[
{
“id”: 1,
“name”: “Some new name”,
“totalNumberOfContracts”: 2,
“numberOfOpenContracts”: 1
},
{
“id”: 2,
“name”: “Some mega-corporation”,
“totalNumberOfContracts”: 1,
“numberOfOpenContracts”: 1
}
]
It is now super easy to build our dashboard. We can query that endpoint once and display the data in the UI. The UI offloaded the calculation to the backend.
User interfaces tend to be more complex than APIs because they are stateful. As such, offloading as much complexity to the backend helps. You can use a Backend-for-frontend (BFF) to help with this task. We explore ways to layer APIs, including the BFF pattern in Chapter 19, Introduction to Microservices Architecture.
Lastly, you can play with the API using the HTTP requests in the MVC.API.DTO.http file. I implemented all the endpoints using a similar technique. If your controller logic becomes too complex, it is good practice to encapsulate it into other classes. We explore many techniques to organize application code in Section 4: Applications patterns.