API Endpoints - a (better?) alternative to the traditional controller
It's no secret that I'm a big fan of .NET Core for API development. It's a framework that allows the development of powerful, high-performance applications and facilitates and encourages the use of good practices.
However, there is one detail that always makes me uncomfortable: controllers!
In most projects, controllers are a collection of methods without any direct relationship except for the relationship to a business entity. Usually, just looking at the number of dependencies injected into the controller is enough to raise suspicion that the S in SOLID has been lost.
For an API like:
It's not uncommon to have a controller with a constructor like:
1[ApiController]
2[Route("[controller]")]
3public class ProductsController : ControllerBase
4{
5...
6 public ProductsController(IProductsCrudServices crudService, IProductsSalesReporting salesReporting, IProductsInventoryReports inventoryReports)
7 {
8 _crudService = crudService;
9 _salesReporting = salesReporting;
10 _inventoryReports = inventoryReports;
11 }
12...
13}
We can easily imagine a scenario where the number of dependencies grows exponentially.
So far, my preferred way to solve this problem is to separate the functionalities into several controllers, each dependent on a single service. I use the Swagger Annotations package to control the generation of the Open API definition.
1dotnet add package Swashbuckle.AspNetCore.Annotations
And annotate the controller methods:
1services.AddSwaggerGen(c =>
2{
3 c.SwaggerDoc("v1", new OpenApiInfo { Title = "Api", Version = "v1" });
4 c.EnableAnnotations();
5});
1[ApiController]
2[Route("products")]
3public class ProductsCrudController : ControllerBase
4{
5 private readonly IProductsCrudServices _crudService;
6 public ProductsCrudController(IProductsCrudServices crudService) => _crudService = crudService;
7
8 [HttpGet]
9 [SwaggerOperation(Tags = new[] { "products" })]
10 public async Task<IActionResult> ListAll() { ... }
11
12 [HttpPost]
13 [SwaggerOperation(Tags = new[] { "products" })]
14 public async Task<IActionResult> Post(Product product) { ... }
15}
It's an approach that works but depends heavily on the care with which it is implemented. Nothing prevents an unaware developer from violating the rule.
Another approach I recently discovered is using the APIEndpoints package developed by Steve 'Ardalis' Smith.
It's a library that facilitates API creation by defining endpoints. Its use creates highly focused classes that adhere to the SRP (Single Responsibility Principle).
1dotnet add package Ardalis.ApiEndpoints
To create a new endpoint, just inherit from BaseEndpoint or BaseAsyncEndpoint and override Handle.
1public class TopSalesReportEndpoint : BaseAsyncEndpoint.WithoutRequest.WithResponse<SalesReportResponse>
2{
3 private readonly IProductsSalesReporting _service;
4 public TopSalesReportEndpoint(IProductsSalesReporting service) => _service = service;
5
6 [HttpGet("products/reports/topsales")]
7 [SwaggerOperation(Tags = new[] { "products" })]
8 public override Task<ActionResult<SalesReportResponse>> HandleAsync(CancellationToken cancellationToken = default)
9 {
10 ...
11 }
12}
As before, we use Swagger annotations to generate the Open API documentation.
What I like most about this solution is the possibility to standardize the creation of APIs without inflating the number of dependencies, resulting in a series of small, easy-to-test classes.