21 minutes
Decoupling Controllers with ApiEndpoints
Admittedly, or not, we’ve all worked on projects during our careers that took the above meme’s approach of “just put it in the controller and we’ll figure it out later”. Unfortunately for some, this is a way of life due to project budget constraints, unrealistic product deadlines, and more senior developers refusing to change their ways because it “works.” It’s like how the old saying goes, you can’t teach an old programmer to decouple independent concerns.
On a recent weekend getaway to the mountains, I did what I always do on long drives when my wife inevitably falls asleep in the car: put on episode of .NET Rocks! and let Carl, Richard, and their guests fascinate me with the latest in the .NET ecosystem. On this fateful day, the guest happened to be Steve Smith talking about his relatively new project - ApiEndpoints. I’ve listened to a lot of .NET Rocks! over the years, and needless to say, a problem that has always bothered me throughout my relatively young career as a developer seemed to finally have a simple solution.
The Problem
As previously mentioned, we’ve all most likely worked on a legacy project at some point during our careers that makes the company gobbles of money with no immediate plans of being sunsetted in place of a greenfield application, leaving other poor souls to maintain the mountain of tech debt accumulated over years of ignorance. While we could go down the rabbit hole of how a project eventually gets to this near unmaintainable state, I want to focus on a single area these projects, more often than not, have in common: the fat controller.
Bloated controllers
Not to be confused with the Thomas the Tank Engine character of the same name, fat controllers are a code smell, anti-pattern, etc. (pick your favorite buzzword) that boils down to a single issue at its root - controllers that are doing way too much, violating the SRP to the fullest extent of the law.
Controller bloat, in essence, is the product of compounding controller files with a plethora of injected services and action methods that, while related by their respective domain or managed resource, have no real dependence on one another. I’m not sure about you, but I don’t think I’ve ever seen a controller action being called by another action within the same file. Sure, we might route resource requests at the API level to other methods with the same controller, but rarely is there a reason to directly call an action method explicitly from another. An unfortunate side effect of this phenomenon is a god class mentality developers take on, ignoring architectural boundaries, and injection of dependencies that service only a specific use case within said controller, ignored by 90% of the other actions.
What this eventually leads to (not in all cases, but a sizable amount), are controllers with hundreds to thousands of lines of code containing an uncomfortable amount of business logic, constructors with an unnecessary amount of injected dependencies, and a regular trip to our local pharmacy for headache medication due to the maintenance effort of these beasts.
ApiEndpoints to the rescue
Enter ApiEndpoints, a project started by Steve Smith with one goal in mind: decoupling from controller-based solutions by encouraging a package by feature vertical slice architecture from within our API project layers.
What this means, in plain english, is a mindset change from the traditional MVC patterns we see in large web API projects where there’s most likely a Controllers
folder that might contain tens of hundreds (yes, seriously) controllers that act as the gateway into the lower level working parts of our application and act as the liaison
for client requests. Traditionally, this sort of architecture is akin to package by layer which we see in a grand majority of projects within the enterprise, GitHub, and your friend’s sweet new app that’s going to make them millions of dollars.
What this boils down to, at the surface level, is an attempt to group related concerns and request work flows, i.e. how a request enters and trickles through the system interacting with our various application resources, within the same domain. What we’re used to seeing might be similar to the following:
\Controllers
\Models
\Views
\Services
// ...and any number of layer-based components
Our controller directory might be broken down further:
\Controllers
HomeController.cs
\Orders
OrdersController.cs
OrderProcessingController.cs
\Products
ProductsController.cs
ProductInventoryController.cs
// ...again, any number of controllers nested within
Our Models
, Views
, and Services
folders might very well contain the same, or very similar, structure. In this example, we’ve created a package by layer architecture within our application - though everything exists in a single DLL, these would be more often utilized and referenced as separate class libraries, JARs, etc.
What happens when a new business requirement comes in requiring a change, update, or addition to a specific feature? As you might have guessed, from our example we’ll most likely be making changes in four separate places/layers of our application, though the feature falls under a single domain. As with everything in software, your preferred package methodology will always have tradeoffs, and the tried and true, handy dandy, all encompassing answer to the question of which ideology is best is simply… it depends.
While we could dedicate an entire post about putting things where they belong and the tradeoffs of different packaging architectures, we’re focusing on just the API layer of our applications, namely everything under the Controllers
folder. Our aim, with help from the ApiEndpoints library, will be to sort concerns within individual Feature
folders. Specific to the API layer, a.k.a. our controllers, we want to decouple services, dependencies, and independent processes from bloated, monolithic controllers. Imagine our orders controllers containing the following actions:
OrdersController.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SomeAwesomeNamespace.Services.Orders;
namespace SomeAwesomeNamespace.Controllers.Orders
{
[ApiController]
[Route("[controller]")]
public class OrdersController : ControllerBase
{
private readonly ILogger<OrdersController> _logger;
private readonly OrderServiceOne _serviceOne;
private readonly OrderServiceTwo _serviceTwo;
private readonly OrderServiceThree _serviceThree;
public OrdersController(
ILogger<OrdersController> logger,
OrderServiceOne serviceOne,
OrderServiceTwo serviceTwo,
OrderServiceThree serviceThree)
{
_logger = logger;
_serviceOne = serviceOne
_serviceTwo = serviceTwo
_serviceThree = serviceThree
}
[HttpGet]
public ActionResult SomeActionThatUsesServiceOne()
{
// Do some processing requiring service one...
}
[HttpPost]
public ActionResult SomeActionThatUsesServiceTwo()
{
// Do some processing requiring service two...
}
[HttpPut]
public ActionResult SomeActionThatUsesServiceThree()
{
// Do some processing requiring service three...
}
// ...and any number of action methods to be utilized elsewhere
}
}
Our controller contains three service-based dependencies only utilized by a single method. Our controller is now coupled to three services, independent of one another, and consumed in only a third of its methods on a per service basis. While this might be a bit of a contrived example, it’s easy to see how we might extrapolate this controller into a real world scenario, adding more services and methods that have nothing to do with one another, making it more difficult to change and modify this controller as it becomes more coupled to its injected dependencies. When the time comes to test this bad boy, it will inevitably become a mocking nightmare.
So… how can we improve upon the paved path the old guard has laid before us?
Endpoints as units of work
Continuing from our example above, let’s think about what our API routing structure might look like:
/api/orders
/api/orders/process
/api/orders/:orderId
/api/orders/:orderId/products
/api/products
/api/products/:productId
/api/products/:productId/orders
// ...and any number of routes our application might service
From the above, we could argue that based on domain, those routes probably belong in two separate controllers, product-based and order-based controllers. While that would suffice and get the job done for us, what about taking each of the above routes as an individual unit of work? Not to be confused with the design pattern of the same name, our definition of a unit of work in this context represents a processing silo in charge of one thing, and one thing only: /api/orders
would be in charge of retrieving all outstanding/pending orders, /api/products/:productId
, would be in charge of retrieving products given a unique identifying key, /api/orders/:orderId/products
retrieves all the products on a particular order, etc. Each of these routes, while related by domain, performs a very specific task unrelated to its sibling routes with a good chance that each requires some sort of injected service that may, or may not, be utilized by the others.
While we could, again, dedicate an entire post to discuss API design semantics, let’s break away from our conventional thinking and explore building an API without traditional controllers.
Individual endpoints with ApiEndpoints
As I’m sure the fine folks reading this article would love for me to continue aimlessly writing about orders and products for a fictional company, I’ll shut up for now and finally get into some code. To start, let’s create a new web API project using your preferred project bootstrapping method. I’ll be using Visual Studio for Mac, so I’ll go ahead and select a new ASP.NET Core Web Application project using the API template, since we won’t be doing anything with views.
Once we’ve got a project ready to roll, let’s open up our solution and do a bit of immediate refactoring. Let’s start by adding a package reference to Ardalis.ApiEndpoints
:
Once our package has been added, let’s create a Features
folder at the root of our project, and immediately beneath that, a Weather
directory.
Let’s go ahead and create two more directories beneath our Weather
folder to house our concerns that have to deal with everything related to weather in Models
and Endpoints
. By creating feature slices within our application, we can group things by concern rather than by layer so that every feature request coming in from the business will be easily contained within its corresponding domain. Let’s start by offering up an endpoint to retrieve a weather forecast, akin to the already existing method within the WeatherController.cs
file underneath the Controllers
folder. Go ahead and add a new file underneath our Endpoints
folder called GetWeatherForecasts.cs
, where we’ll place the action method’s code from the WeatherController
’s Get()
method:
GetWeatherForecasts.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Ardalis.ApiEndpoints;
using DecoupledControllersWithApiEndpoints.Features.Beers;
using Microsoft.AspNetCore.Mvc;
namespace DecoupledControllersWithApiEndpoints.Features.Weather.Endpoints
{
[Route(Routes.WeatherUri)]
public class GetWeatherForecast : BaseEndpoint<IEnumerable<WeatherForecast>>
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
[HttpGet]
public override ActionResult<IEnumerable<WeatherForecast>> Handle()
{
var rng = new Random();
var forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray();
return Ok(forecasts);
}
}
}
As the method definition for Handle()
is the same as the Get()
action method along with the other parts I’ve directly copied over from the default WeatherController
that the ASP.NET Core scaffold tools includes in its template, let’s focus on the unfamiliar parts of this file that ApiEndpoints brings to the table:
- We’re still utilizing the
[Route]
and[HttpGet]
attributes available to our controllers thanks to theMicrosoft.AspNetCore.Mvc
namespace - We’re inheriting from the
BaseEndpoint<TResponse>
class that ApiEndpoints provides for us, signaling on application startup that this is, in fact, a controller in disguise and will be treated just like a regular old ASP.NET Core controller BaseEndpoint<TResponse>
is an abstract class with a single method exposed for us to override inHandle()
that returns anActionResult<TResponse>
type, akin to action methods from within a controller- If we follow the inheritance chain of
BaseEndpoint
, or any of its derivatives with higher order arity (thanks for the vocab upgrade in my personal arsenal, Jon Skeet) inBaseEndpoint<TResponse>
orBaseEndpoint<TRequest, TResponse>
, we see the base type ultimately pointing to ASP.NET Core’sControllerBase
type, solving the mystery as to why we have access to all the ASP.NET Core attributes and types in endpoints
We have a single named route thanks to the [Route(Routes.WeatherUri])]
attribute, where I’ve defined Routes.cs
at the root of our Features
folder below:
Features/Routes.cs
namespace DecoupledControllersWithApiEndpoints.Features.Beers
{
public static class Routes
{
public const string WeatherUri = "api/weather";
}
}
While most likely unnecessary for our small demo application, I find it helpful to have a single place containing our API routes for reference in other parts of our apps, should we need them. We’ll add to this a bit later, but for now, this should suffice. I’ve also moved the WeatherForecast.cs
model into the Weather/Models
feature folder to keep with convention of grouping like things together.
Let’s spin up our application now using F5
, or hitting a dotnet run
in the terminal, and using Postman (or your favorite web request utility), let’s send a request to https://localhost:5001/api/weather
and examine the response:
[
{
"date": "2020-09-23T12:52:27.408507-07:00",
"temperatureC": 6,
"temperatureF": 42,
"summary": "Mild"
},
{
"date": "2020-09-24T12:52:27.408951-07:00",
"temperatureC": -19,
"temperatureF": -2,
"summary": "Freezing"
},
// ...and several other random forecasts
]
Thanks to the rng
we’ve built into our forecast generator, your response will look a bit different than mine, but let’s not gloss over the fact that we’ve just performed a complete request/response cycle within our API without (not technically, but kind of) using a controller! Pretty awesome, huh? While this is great and all, let’s CRUDify our application a little bit with something a bit more interesting than orders and products and universally loved (might need someone to fact check that) by all: beer.
Adding a new feature
Let’s kick things off by adding a new feature folder called Beers
underneath our existing Features
directory. We should have the following structure in place now:
\Features
\Beers
\Weather
\Endpoints
GetWeather.cs
\Models
WeatherForecast.cs
For our beers feature, we’ll define the following endpoints a user can interact with:
- A
GET
endpoint that returns all the beers in our database - Another
GET
endpoint that retrieves a beer given an ID - A
POST
endpoint that creates a beer within our database - A
DELETE
endpoint that removes a beer from our database given a valid ID
I’ve left out the PUT
operation due to laziness and also as an implementation exercise for the reader. To make things simple on us, I’ll be using Entity Framework Core with an in-memory database (NOTE: don’t do this in production). As we’re focusing on endpoints within the scope of this post, I’ll leave the details of how I implemented the database and the associated entities here, here, and here. Namely, I’ve added the following:
- Packages references to
Microsoft.EntityFrameworkCore
andMicrosoft.EntityFrameworkCore.InMemory
- An
Beer.cs
associated entity within aModels
folder underneath ourBeers
feature folder - An
ApplicationDbContext
class implementing EF Core’sDbContext
to manageBeer
entities within our database
I’ve also added some simple seeding code to our database on application startup you can take a look at here to get us off the ground and able to test right away.
Let’s add our first endpoint to retrieve all beers within our database that will be located as a GET
on the route /api/beers
. Let’s create an Endpoints
folder underneath our Beers
feature folder that will house all of our API endpoints. Within that folder, let’s create our first endpoint in GetBeers.cs
:
GetBeers.cs
using Ardalis.ApiEndpoints;
using DecoupledControllersWithApiEndpoints.Data;
using DecoupledControllersWithApiEndpoints.Features.Beers.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Swashbuckle.AspNetCore.Annotations;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace DecoupledControllersWithApiEndpoints.Features.Beers.Endpoints
{
[Route(Routes.BeerUri)]
public class GetBeers : BaseAsyncEndpoint<IEnumerable<Beer>>
{
private readonly ApplicationDbContext _context;
private readonly ILogger<GetBeers> _logger;
public GetBeers(ApplicationDbContext context, ILogger<GetBeers> logger) =>
(_context, _logger) = (context, logger);
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<Beer>), StatusCodes.Status200OK)]
[SwaggerOperation(
Summary = "Retrieves a list of beers",
Description = "Retrieves a list of beers from the database",
OperationId = nameof(GetBeers),
Tags = new[] { nameof(GetBeers) }
)]
public override async Task<ActionResult<IEnumerable<Beer>>> HandleAsync(CancellationToken cancellationToken = default)
{
_logger.LogInformation("Received request to retrieve all beers...");
return Ok(await _context.Beers.ToListAsync(cancellationToken));
}
}
}
Though seemingly a small amount of code with our associated endpoint HandleAsync()
method, let’s breakdown what we’ve added:
- We’re inheriting from the
async
version ofBaseEndpoint
withBaseAsyncEndpoint<TResponse>
that exposes an overridableTask<ActionResult<TResponse>> Handle()
method with adefault
CancellationToken
provided by ASP.NET Core. - We’ve injected only the services this endpoint needs with a logger and our managing database context
- We’re letting our application know this is a
GET
with[HttpGet]
As we can see, this endpoint is solely responsible for retrieving a list of all the beers within our database. While there’s not technical need for us to the the async
variants of BaseEndpoint
, I figured I’d demo some of the cool features it ships with, including exposing a CancellationToken
for us to utilize with EF Core. I’ve added a few additional metadata attributes with [ProducesResponseType()]
and [SwaggerOperation()]
that allow us to build documentation into our endpoints using Swagger and Open API. To utilize Swagger, we’ll need a few packages in Swashbuckle.AspNetCore.Annotations
and Swashbuckle.AspNetCore.SwaggerUI
(optional, but nice to have). Once you’ve added those via NuGet, let’s go ahead and add their services to Startup.cs
:
Startup.cs
using DecoupledControllersWithApiEndpoints.Data;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
namespace DecoupledControllersWithApiEndpoints
{
public class Startup
{
private readonly string _apiName = "Decoupled Controllers with ApiEndpoints";
private readonly string _apiVersion = "v1";
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Existing services...
services.(options =>
{
options.EnableAnnotations();
options.SwaggerDoc(_apiVersion, new OpenApiInfo
{
Title = _apiName,
Version = _apiVersion
});
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Existing middleware...
app.UseSwagger();
app.UseSwaggerUI(options => options.SwaggerEndpoint("/swagger/v1/swagger.json", $"{_apiName}, {_apiVersion}"));
// More middleware...
}
}
}
With the call to AddSwaggerGen()
within our ConfigureServices()
method, we’re configuring our Swagger doc generation with defaults and adding the ability to utilize annotations on our endpoints with the [SwaggerOperation()]
attribute. Next, we add the UseSwagger()
and UseSwaggerUI()
middlewares, with UseSwagger()
generating our swagger.json
file for our UseSwaggerUI()
middleware to consume and generate a fancy prebuilt HTML page. If we start our application and navigate to https://localhost:5001/swagger
, we should see something resembling the following:
Pretty cool! I’ve taken the image from what our final product will look like, as we’ve only added one documented endpoint for now. Swagger is a tool that assists with development teams in documenting their API contracts for consumers, making integration with consuming API teams much less of a headache and a tool I’d highly encourage using in your applications. I’ve also added a new route to our Routes.cs
class:
Routes.cs
namespace DecoupledControllersWithApiEndpoints.Features.Beers
{
public static class Routes
{
public const string BeerUri = "api/beers";
public const string WeatherUri = "api/weather";
}
}
With our application running, let’s navigate to our newly added endpoint at https://localhost:5001/api/beers
using Postman:
[
{
"id": 1,
"name": "Hexagenia",
"abv": 7.4,
"ibu": 120,
"style": 1,
"createdAt": "2020-09-23T17:32:40.3846416Z",
"updatedAt": "2020-09-23T17:32:40.384693Z"
}, {
"id": 2,
"name": "Hazy Little",
"abv": 6.8,
"ibu": 90,
"style": 2,
"createdAt": "2020-09-23T17:32:40.3847412Z",
"updatedAt": "2020-09-23T17:32:40.384742Z"
}, {
"id": 3,
"name": "Scrimshaw",
"abv": 5.4,
"ibu": 20,
"style": 0,
"createdAt": "2020-09-23T17:32:40.3847427Z",
"updatedAt": "2020-09-23T17:32:40.3847427Z"
}
]
With our first working endpoint now up and running, let’s spice things up a bit and add a retrieve endpoint to grab specific beers from the database. Let’s go ahead and add a RetrieveBeer.cs
file under our Endpoints
folder:
RetrieveBeer.cs
using System.Threading;
using System.Threading.Tasks;
using Ardalis.ApiEndpoints;
using DecoupledControllersWithApiEndpoints.Data;
using DecoupledControllersWithApiEndpoints.Features.Beers.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Swashbuckle.AspNetCore.Annotations;
namespace DecoupledControllersWithApiEndpoints.Features.Beers.Endpoints
{
[Route(Routes.BeerUri)]
public class RetrieveBeer : BaseAsyncEndpoint<int, Beer>
{
private readonly ApplicationDbContext _context;
private readonly ILogger<RetrieveBeer> _logger;
public RetrieveBeer(ApplicationDbContext context, ILogger<RetrieveBeer> logger) =>
(_context, _logger) = (context, logger);
[HttpGet("{id}")]
[ProducesResponseType(typeof(Beer), StatusCodes.Status200OK)]
[ProducesErrorResponseType(typeof(NotFoundResult))]
[SwaggerOperation(
Summary = "Retrieve a beer",
Description = "Retrieves a beer given a valid ID",
OperationId = nameof(RetrieveBeer),
Tags = new[] { nameof(RetrieveBeer) }
)]
public async override Task<ActionResult<Beer>> HandleAsync([FromRoute] int id, CancellationToken cancellationToken = default)
{
_logger.LogInformation($"Received request to retrieve beer with ID {id}");
// Grab a reference to the matching beer and invalidate the request if none is found
var beer = await _context.Beers.FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
if (beer is null)
{
return NotFound($"Beer with ID {id} was not found");
}
return Ok(beer);
}
}
}
While the logic in our HandleAsync()
might not be the most complicated piece of code you’ve ever seen, we want to focus on our implementation of a new derivative of BaseAsyncEndpoint
in BaseAsyncEndpoint<TRequest, TResponse>
, accepting a request object that allows us to pass in path and body-based HTTP request metadata.
Since we’ve inheriting from BaseAsyncEndpoint<int, Beer>
, we’re telling this endpoint to accept an int
in the path (thanks to the [FromRoute]
attribute in the HandleAsync()
method signature) and to hand us back a Beer
type wrapped in an ActionResult
. If we send a request to get an existing beer, i.e. https://localhost:5001/api/beers/2
, we should get the following:
{
"id": 2,
"name": "Hazy Little Thing",
"abv": 6.8,
"ibu": 90,
"style": 2,
"createdAt": "2020-09-23T17:32:40.3847412Z",
"updatedAt": "2020-09-23T17:32:40.384742Z"
}
Again, while we may only be utilizing a few services in each endpoint that all happen to be the same, the power in our individual endpoints is the ability to inject endpoint-dependent services into only the endpoints that require them - no more mega constructors!
With our GET
endpoints out of the way, let’s give consumers a way to create their favorite beers in our database. First, in our Models
folder, let’s add transport object that will hold user data for us to utilize within the body of the POST
request. Let’s add a CreateBeerDto.cs
class:
CreateBeerDto.cs
namespace DecoupledControllersWithApiEndpoints.Features.Beers.Models
{
public class CreateBeerDto
{
public string? Name { get; set; }
public string? Style { get; set; }
public decimal Abv { get; set; }
public int Ibu { get; set; }
}
}
With our transport object in place, let’s add an endpoint to our Endpoints
folder with CreateBeer.cs
:
CreateBeer.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Ardalis.ApiEndpoints;
using DecoupledControllersWithApiEndpoints.Data;
using DecoupledControllersWithApiEndpoints.Features.Beers.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Swashbuckle.AspNetCore.Annotations;
namespace DecoupledControllersWithApiEndpoints.Features.Beers.Endpoints
{
[Route(Routes.BeerUri)]
public class CreateBeer : BaseAsyncEndpoint<CreateBeerDto, Beer>
{
private readonly ApplicationDbContext _context;
private readonly ILogger<CreateBeer> _logger;
public CreateBeer(ApplicationDbContext dbContext, ILogger<CreateBeer> logger) =>
(_context, _logger) = (dbContext, logger);
[HttpPost]
[ProducesResponseType(typeof(Beer), StatusCodes.Status201Created)]
[SwaggerOperation(
Summary = "Creates a beers",
Description = "Creates a beer in the database using Entity Framework Core",
OperationId = nameof(CreateBeer),
Tags = new[] { nameof(CreateBeer) }
)]
public override async Task<ActionResult<Beer>> HandleAsync(CreateBeerDto request, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Received request to create beer...");
// Grab a reference to a new Beer entity instance
var beerToAdd = new Beer
{
Name = request.Name,
Style = Enum.TryParse(request.Style, true, out BeerStyle style) ? style : BeerStyle.Other,
Ibu = request.Ibu,
Abv = request.Abv,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
// Add the beer as a tracked entity and grab a reference to the managed entity returned to us
var beer = await _context.AddAsync(beerToAdd, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return Created(new Uri($"/{Routes.BeerUri}/{beer.Entity.Id}", UriKind.Relative), beer.Entity);
}
}
}
Again, we see that we’re utilizing BaseAsyncEndpoint<TRequest, TResponse>
along with the [FromBody]
attribute. We’re letting our application know that POST
s to /api/beers
should contain a CreateBeerDto
typed body that we’ll consume to create a new Beer
entity and insert into our database using our injected _context
from EF Core. In return, we’ll hand back a 201
created response with ASP.NET Core’s Created
wrapper, and let consumers know where to find the resource relative to our base URL with a Location
header. Let’s fire up our application and send the following POST
payload to https://localhost:5001/api/beers
:
{
"name": "Deschutes Fresh Squeezed",
"style": "Ipa",
"abv": 6.4,
"ibu": 60
}
We should receive the following response from our API:
{
"id": 4,
"name": "Deschutes Fresh Squeezed",
"abv": 6.4,
"ibu": 60,
"style": 1,
"createdAt": "2020-09-23T18:41:17.6007042Z",
"updatedAt": "2020-09-23T18:41:17.6007045Z"
}
Feel free to substitute with your favorite beverage of choice. Finally, let’s go ahead and implement a DELETE
endpoint to round out (almost, the PUT
operation I’ll leave out for the extra motivated folks) our individualized endpoints by adding a DeleteBeer.cs
class to our Beers/Endpoints
feature folder:
DeleteBeer.cs
using Ardalis.ApiEndpoints;
using DecoupledControllersWithApiEndpoints.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace DecoupledControllersWithApiEndpoints.Features.Beers.Endpoints
{
[Route(Routes.BeerUri)]
public class DeleteBeer : BaseAsyncEndpoint<int, NoContentResult>
{
private readonly ApplicationDbContext _context;
private readonly ILogger<DeleteBeer> _logger;
public DeleteBeer(ApplicationDbContext context, ILogger<DeleteBeer> logger) =>
(_context, _logger) = (context, logger);
[HttpDelete("${id}")]
public override async Task<ActionResult<NoContentResult>> HandleAsync([FromRoute] int id, CancellationToken cancellationToken = default)
{
_logger.LogInformation($"Received request to delete beer with ID {id}...");
// Grab a reference to the beer to delete from the database
var beer = await _context.Beers.FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
// Invalidate the request if no beer is found
if (beer is null)
{
return NotFound($"Beer with ID {id} was not found");
}
// Remove the beer from tracked state and update the database
_context.Beers.Remove(beer);
await _context.SaveChangesAsync(cancellationToken);
return NoContent();
}
}
}
Thanks to ApiEndpoints and the flexibility it provides, we’re able to return ASP.NET Core types as well, utilizing the NoContentResult
that will return to users a status code of 204
signaling a successful deletion. With our application fired up, go ahead and send a DELETE
request to `https://localhost:5001/api/beers/2', and retrieve the list of beers once you’ve done so. Not too bad for a simple API in 20 minutes, if I do say so myself!
Wrapping up
We’ve explored some of the capability that ApiEndpoints allows us and the architecture it encourages in feature-based vertical slices within the API layers of our applications by rethinking how we write controllers. We’ve fundamentally shifted from controllers housing our action methods and discussed a bit about what a bloated controller could look like in terms of application code and why it might be a good idea to keep our controllers thin, or better yet, substitute them for endpoints that act as individual units of work.
By decoupling our controllers from a plethora of injected services and unrelated action methods, we’ve gained the benefit of avoiding the mega constructor by only injecting the exact services an endpoint might need, allowing us to flexibly add and remove services without unintended side effects in other API routes.
While there’s a lot of improvement to be made in our simple example application here, it’s easy to see the power ApiEndpoints brings to the table. While a more DDD-style application may be driven by a library like MediatR, I’ve been enjoying my time with ApiEndpoints to quickly spin up and prototype applications in single web API projects.
Feel free to checkout the code for this post here, and for the inclined, tackle an issue or two for ApiEndpoints here.
Until next, amigos!