Raw CRUD endpoints – Minimal API

Raw CRUD endpoints – Minimal API

Many issues can arise if we create CRUD endpoints to manage the customers directly (see CustomersEndpoints.cs). First, a little mistake from the client could erase several data points. For example, if the client forgets to send the contracts during a PUT operation, that would delete all the contracts associated with that customer. Here’s the controller code:// PUT raw/customers/1
group.MapPut(“/{customerId}”, async (int customerId, Customer input, ICustomerRepository customerRepository, CancellationToken cancellationToken) =>

The highlighted code represents the customer update. So to mistakenly remove all contracts, a client could send the following HTTP request (from the Minimal.API.http file):PUT {{Minimal.API.BaseAddress}}/customers/1
Content-Type: application/json

That request would result in the following response entity:

{
  “id”: 1,
  “name”: “Some new
name”,
  “contracts”: []

Previously, however, that customer had contracts (seeded when we started the application). Here’s the original data:{
  “id”: 1,
  “name”: “Jonny Boy Inc.”,

As we can see, by exposing our entities directly, we are giving a lot of power to the consumers of our API. Another issue with this design is the dashboard. The user interface would have to calculate the statistics about the contracts. Moreover, if we implement paging the contracts over time, the user interface could become increasingly complex and even overquery the database, hindering our performance.

I implemented the entire API, which is available on GitHub but without UI.

Next, we explore how we can fix those two use cases using DTOs.

DTO-enabled endpoints

To solve our problems, we reimplement the endpoints using DTOs. These endpoints use methods instead of inline delegates and returns Results<T1, T2, …> instead IResult. So, let’s start with the declaration of the endpoints:

var group = routes
    .MapGroup(“/dto/customers”)
    .WithTags(“Customer DTO”)
    .WithOpenApi()
;
group.MapGet(“/”, GetCustomersSummaryAsync)
    .WithName(“GetAllCustomersSummary”);
group.MapGet(“/{customerId}”, GetCustomerDetailsAsync)
    .WithName(“GetCustomerDetailsById”);
group.MapPut(“/{customerId}”, UpdateCustomerAsync)
    .WithName(“UpdateCustomerWithDto”);
group.MapPost(“/”, CreateCustomerAsync)
    .WithName(“CreateCustomerWithDto”);
group.MapDelete(“/{customerId}”, DeleteCustomerAsync)
    .WithName(“DeleteCustomerWithDto”);

Next, 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 DTOEndpoints.cs file):// PUT dto/customers/1
private static async Task<Results<
    Ok<CustomerDetails>,

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 when the operation succeeds.

However, we can see more code in our endpoint than before. That’s because it now handles the changes instead of the clients. The action now does:

  1. Load the data from the database.
  2. Ensure the entity exists.
  3. Use the input DTO to update the data, limiting the clients to a subset of properties.
  4. Proceed with the update.
  5. Ensure the entity still exists (handles conflicts).
  6. 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”,

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 endpoint executes the calculations and copies the entity’s relevant properties to the DTO. Here’s the code:// GET: dto/customers
private static async Task<Ok<IEnumerable<CustomerSummary>>> GetCustomersSummaryAsync(
    ICustomerRepository customerRepository,
    CancellationToken cancellationToken)

In the preceding code, the action method:

  1. Read the entities
  2. Create the DTOs and calculate the number of open contracts.
  3. 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

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 endpoints become too complex, it is good practice to encapsulate them into other classes. We explore many techniques to organize application code in Section 4: Applications patterns.

Leave a Reply

Your email address will not be published. Required fields are marked *