Learn how to reduce complexity and increase maintainability by using MediatR and Minimal APIs in .NET.
In software development, it’s often challenging to manage the complexity of an application as it grows. As more features are added, the codebase becomes more difficult to maintain, and changes can introduce bugs in unexpected places. Fortunately, there are several tools and techniques that can help simplify complexity and make our applications more manageable.
In this blog post, we’ll explore two of these tools: MediatR and Minimal APIs.
MediatR is a library that implements the mediator pattern, which helps in decoupling the components of an application.
With MediatR, we can separate the business logic of our application from the presentation layer, while Minimal APIs help in separating the endpoint definitions from the business logic. This approach can help in keeping our code clean and organized, reduce the amount of boilerplate code required, and increase the testability of our application.
Minimal APIs are a new feature in .NET 6 and further improved in .NET 7, which provides a simplified way to create APIs without using traditional controllers or actions.
Project example
As the title of this blog post says, the main goal for me is to show you how to simplify complexity when you have various services and other implementations inside the API controller. For example, I created a .NET Core Web API project for creating and reading blogs by id. Inside that project, we have an API controller that looks like this:
[ApiController]
[Route("[controller]")]
public class BlogController : ControllerBase
{
private readonly ILogger<BlogController> _logger;
private readonly IMapper _mapper;
private readonly IBlogRepository _repository;
private readonly IMemoryCache _cache;
private readonly IEmailService _emailService;
public BlogController(
ILogger<BlogController> logger,
IMemoryCache memoryCache,
IMapper mapper,
IBlogRepository blogRepository,
IEmailService emailService)
{
_logger = logger;
_repository = blogRepository;
_mapper = mapper;
_emailService = emailService;
_cache = memoryCache;
}
I made 2 endpoints for this example.
[HttpPost]
public async Task<ActionResult<Blog>> CreateBlog(CreateBlogRequest request)
{
var blogEntity = _mapper.Map<Blog>(request);
var newBlog = await _repository.Create(blogEntity);
_logger.LogInformation("Blog {Id} is successfully created", newBlog.Id);
try
{
var email = new Email()
{
To = "author_email@gmail.com",
Body = $"Your blog {newBlog.Title} was created.",
Subject = "Blog was created!"
};
await _emailService.SendEmail(email);
}
catch (Exception ex)
{
_logger.LogError("Blog {Id} failed due to an error with the mail service: {ExMessage}",
newBlog.Id, ex.Message);
}
return CreatedAtRoute("BlogById", new { id = newBlog.Id }, newBlog);
}
[HttpGet("{id:guid}", Name = "BlogById")]
public async Task<ActionResult> GetById(Guid id)
{
if (!_cache.TryGetValue($"blogId-{id}", out BlogResponse? blogsDto))
{
var blogs = await _repository.GetById(id);
if (blogs == null)
{
_logger.LogInformation("BlogId {Id} is not found!", id);
return NotFound();
}
blogsDto = _mapper.Map<BlogResponse>(blogs);
_cache.Set($"blogId-{id}", blogsDto, TimeSpan.FromSeconds(60));
}
return Ok(blogsDto);
}
I understand that this example is not particularly busy or complex. In real-life scenarios, things often become much more crowded. I intentionally simplified it for clarity.
Additionally, I’d like to mention that if you examine the code available on GitHub, you’ll notice that I didn’t pay attention to validation in this example. However, it would be advisable to include email address validation since we will be sending that email to an email service later on. Nevertheless, these minor issues do not pose significant obstacles in this case.
You can see in this example that logging and caching controls, mapping operations, and service calls in each API method in the controller turn into blocks of code that are difficult to read and understand as time passes. In most cases, these controllers become large and fat, which is one of the main anti-patterns to avoid.
Although these endpoints are not too complex, we still need to refactor them. In complex examples, you’ve probably heard a bunch of times “if it ain’t broke, don’t fix it,” however, there are many different approaches to simplify and solve that.
The one I want to focus on is applying the mediator and CQRS patterns together. This approach simplifies our code while also ensuring compliance with the SOLID principles.
Let’s see how we can achieve that with MediatR.
MediatR
MediatR is a very popular open-source library for C#. It follows the CQRS and mediator pattern, which is a behavioral design pattern that allows objects to communicate with each other without being tightly coupled. It provides several different types of mediators, including request/response, notification, and pipeline mediators.
In MediatR, every request that is sent is accompanied by a handler. The handler is responsible for processing the request and returning a response.
To help illustrate how MediatR works, imagine that you have a service that needs to send a request to another component of your application. You can send the request to MediatR instead of directly to the component. This makes it easy to implement different types of interactions between components in the application and also makes it easier to test, maintain, and extend our code.
Let’s see how to implement it.
We’ll start by installing MediatR via Package Manager Console or through the NuGet library.
Install-Package MediatR
After that, we need to open Program.cs and register that service.
builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
We have to provide a default configuration to the constructor.
Now MediatR is configured and ready to go.
MediatR requests
MediatR requests are very simple request-response style messages, where a single request is synchronously handled by a single handler (synchronous from the request point of view, not C# internal async/await). Good use cases here would be returning something from a database or updating a database. So basically we have only 2 types of requests, command and query requests. This architectural design is called Command-Query Separation.
So next step is to create 2 new folders in our project:
1) Commands
2) Queries
Since we only have two endpoints, we can keep the logic for the GetBlogById
endpoint in the Queries folder and the CreateBlog endpoint in the Commands folder. For larger projects with multiple entities, it’s beneficial to create separate folders within these folders for better project structure.
Let’s start with command record creation inside the Commands folder:
public record CreateBlogCommand(CreateBlogRequest BlogRequest) : IRequest<BlogResponse>;
Our command has a single parameter sent from the client, and it inherits from IRequest<BlogResponse>
. Our request has to return BlogResponse
(data transfer object) because we will need it, in our action, to create a valid route in the return statement. If you have a situation where you don’t need to return any value, then your record or class should inherit just from IRequest
without parameters.
When a request is created, you will need a handler to solve the request. Each request type has its own handler interface as well as some helper base classes/interfaces. They depend on two parameters, request and response.
For this purpose, CreateBlogHandler
needs to be created which will handle that request.
Logging and mapping mechanisms should be injected into that handler alongside the email service for now. Basically, every handler handles a single command or query, and with that, the Single Responsibility Principle is achieved, which is one of the benefits MediatR gives us. Additionally, it is possible to define new mediator objects without making modifications to existing mediator handlers. This approach allows us to achieve the Open/Closed Principle.
public class CreateBlogHandler : IRequestHandler<CreateBlogCommand, BlogResponse>
{
private readonly IBlogRepository _blogRepository;
private readonly IMapper _mapper;
private readonly ILogger<CreateBlogCommand> _logger;
private readonly IEmailService _emailService;
public CreateBlogHandler(
IBlogRepository blogRepository,
IMapper mapper,
ILogger<CreateBlogCommand> logger,
IEmailService emailService)
{
_blogRepository = blogRepository;
_mapper = mapper;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_emailService = emailService;
}
public async Task<BlogResponse> Handle(CreateBlogCommand request, CancellationToken cancellationToken)
{
var blogEntity = _mapper.Map<Blog>(request.BlogRequest);
var newBlog = await _blogRepository.Create(blogEntity);
_logger.LogInformation("Blog {Id} is successfully created", newBlog.Id);
try
{
var email = new Email()
{
To = "author_email@gmail.com",
Body = $"Your blog {newBlog.Title} was created.",
Subject = "Blog was created!"
};
await _emailService.SendEmail(email);
}
catch (Exception ex)
{
_logger.LogError("Blog {Id} failed due to an error with the mail service: {ExMessage}",
newBlog.Id, ex.Message);
}
var blogsResponse = _mapper.Map<BlogResponse>(newBlog);
return blogsResponse;
}
}
And that’s it, now we can do the same thing, but for GetBlogsById
.
You can see how I can handle HTTP GET by defining a GetBlogByIdQuery
as a MediatR request (message)
public record GetBlogByIdQuery(Guid Id) : IRequest<BlogResponse>;
public class GetBlogByIdHandler : IRequestHandler<GetBlogByIdQuery, BlogResponse?>
{
private readonly IBlogRepository _blogRepository;
private readonly IMapper _mapper;
private readonly ILogger<GetBlogByIdQuery> _logger;
private readonly IMemoryCache _cache;
public GetBlogByIdHandler(IBlogRepository blogRepository, IMapper mapper, ILogger<GetBlogByIdQuery> logger,
IMemoryCache cache)
{
_blogRepository = blogRepository;
_mapper = mapper;
_logger = logger;
_cache = cache;
}
public async Task<BlogResponse?> Handle(GetBlogByIdQuery request, CancellationToken cancellationToken)
{
if (!_cache.TryGetValue($"blogId-{request.Id}", out BlogResponse? blogsDto))
{
var blogs = await _blogRepository.GetById(request.Id);
if (blogs == null)
{
_logger.LogInformation("BlogId {Id} is not found!", request.Id);
return default;
}
blogsDto = _mapper.Map<BlogResponse>(blogs);
_cache.Set($"blogId-{request.Id}", blogsDto, TimeSpan.FromSeconds(60));
}
return blogsDto;
}
}
After reaching this point, the only task remaining will be forwarding the request to the appropriate API method through the mediator object in the controller. By applying this refactoring to each API method in the controller, we can achieve a remarkably lightweight and efficient API controller. We just need to eliminate all dependencies within the controller and inject the IMediator
interface.
[ApiController]
[Route("[controller]")]
public class BlogController : ControllerBase
{
private readonly IMediator _mediator;
public BlogController(IMediator mediator)
{
_mediator = mediator;
}
We use the Send method of the IMediator
interface to send a request to our handler and pass the GetBlogByIdQuery
as a parameter. So, what happens behind the scenes is that when we invoke the Send method with the request as GetBlogByIdQuery
, the mediator will invoke the handler which accepts this request. In our case, it will go and call the GetBlogByIdHandler
class and return the response as BlogResponse.
[HttpGet("{id:guid}", Name = "BlogById")]
public async Task<ActionResult> GetById(Guid id)
{
var blog = await _mediator.Send(new GetBlogByIdQuery(id));
return blog != null ? Ok(blog) : NotFound();
}
The same thing we can do for the CreateBlog endpoint.
[HttpPost]
public async Task<ActionResult<Blog>> CreateBlog(CreateBlogRequest request)
{
var newBlog = await _mediator.Send(new CreateBlogCommand(request));
return CreatedAtRoute("BlogById", new { id = newBlog.Id }, newBlog);
}
MediatR notifications
So far, we have only observed a single request being handled by a single handler. However, what if we need to handle a single request with multiple handlers? That’s where notifications come into play.
Notifications enable us to broadcast information or events to multiple handlers without expecting a response. This functionality proves valuable in scenarios where you need to notify various components of your application about an occurrence or trigger specific behaviors based on events.
Notifications are particularly useful in microservices architecture as they facilitate decoupled communication, event broadcasting, handling cross-cutting concerns, and asynchronous processing. They promote loose coupling between components, allow for broadcasting events to multiple subscribers, handle cross-cutting concerns efficiently, and trigger asynchronous tasks.
In the previous section, I mentioned that the EmailService
is currently injected into the CreateBlogHandler
. Now, it is time to transfer that service from the handler to the INotificationHandler
. This approach guarantees application responsiveness and optimal performance by sending email notifications in the background without affecting the primary functionality of the CreateBlogHandler
. However, before proceeding, it is considered good practice to create a new folder called Notification and include the class BlogCreatedNotification within it.
public record BlogCreatedNotification(Blog Blog) : INotification;
The notification has to inherit from the INotification
interface. This is the equivalent of the IRequest
but for Notifications. Now the next step is to create an EmailHandler
.
public class EmailHandler : INotificationHandler<BlogCreatedNotification>
{
private readonly IEmailService _emailService;
private readonly ILogger<BlogCreatedNotification> _logger;
public EmailHandler(IEmailService emailService, ILogger<BlogCreatedNotification> logger)
{
_emailService = emailService;
_logger = logger;
}
public async Task Handle(BlogCreatedNotification notification, CancellationToken cancellationToken)
{
try
{
var email = new Email()
{
To = notification.Blog.CreatorEmailAddress,
Subject = $"Blog {notification.Blog.BlogTitle} was created!",
Body = "Hey buddy, your blog is created successfully!"
};
await _emailService.SendEmail(email);
}
catch (Exception ex)
{
_logger.LogError("Blog {Id} failed due to an error with the mail service: {ExMessage}",
notification.Blog.Id, ex.Message);
}
}
}
After we implement that, we just need to remove the dependency to IEmailService
inside our CreateBlogHandler
, add IMediator
instead, and then publish the BlogCreatedNotification
event inside the Handle method.
public async Task<BlogResponse> Handle(CreateBlogCommand request, CancellationToken cancellationToken)
{
var blogEntity = _mapper.Map<Blog>(request.BlogRequest);
var newBlog = await _blogRepository.Create(blogEntity);
await _mediator.Publish(new BlogCreatedNotification(newBlog), cancellationToken);
_logger.LogInformation("Blog {Id} is successfully created", newBlog.Id);
var blogsResponse = _mapper.Map<BlogResponse>(newBlog);
return blogsResponse;
}
Replace Controllers with Minimal APIs
Controllers are a common architectural component in web development, but Minimal APIs offer a sleek alternative. If you come from the .NET world you probably heard that minimal APIs are the most popular feature from .NET 6. The core idea behind Minimal APIs is to remove some of the ceremonies of creating simple APIs.
It introduces a concept that is not really a new thing, we already have something similar in Golang and Node.js.
When you create a new MVC solution out of the box we get a lot of boilerplate code. Minimal APIs have no need for controllers, and you can create endpoints as easily as:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello Qumans!");
app.Run();
This call specifies a route (e.g., “/”) and a callback to execute once a request that matches the route and verb matched. The method MapGet is specifically to map an HTTP GET to the callback function. Much of the magic is in the type of inference that’s happening. When we return a string (like in this example), it’s wrapped in a 200 (e.g., OK) return result and that’s it.
OK, now with a few lines of code inside Program.cs, we can replace our Blog controller.
app.MapGet("/blogs/{id:guid}", async (IMediator mediator, Guid id) =>
await mediator.Send(new GetBlogByIdQuery(id)) is var blog
? Results.Ok(blog)
: Results.NotFound())
.WithName("BlogById");
app.MapPost("/blogs", async (IMediator mediator, CreateBlogRequest request) =>
{
var newBlog = await mediator.Send(new CreateBlogCommand(request));
return Results.CreatedAtRoute("BlogById", new { id = newBlog.Id }, newBlog);
});
We do not however want to overload the Program.cs file with endless endpoints in real-life scenarios, the easiest way is just to simply create an extension method to register the middleware and the handlers you need for your endpoints. But for this purpose, I think we can live with it.
Conclusion
In this blog, I tried to show you that MediatR and Minimal APIs can offer a powerful combination for simplifying complexity in your projects.
MediatR provides a great starting point for an application that needs to evolve from a simple monolith into a more mature application, by allowing us to separate read and write concerns, and minimizing the dependencies between code, while Minimal API is a great feature that reduces code complexity by providing a streamlined approach to building applications, complementing the benefits of MediatR for separating concerns and minimizing dependencies.
Code available at GitHub Repo.