Designing business rules that don't leak into controllers

APIs are the engine of modern applications. Your product belongs to any domain, either medical, banking, or IoT, APIs are most probable bricks in it. Good, maintainable, and reusable code promises a functional system. While a bad one makes maintenance and testing tedious. There is so much to care about in your application. In today's post, I will dig into writing a clean controller that fulfils the Single Responsibility Principle. We will see why exposing any business logic can harm the application and what exactly a controller should include.

Designing business rules that don't leak into controllers

Example with Fat Controller (bad design)

Consider the following ASP.NET Core controller implementation:

using System;
using Microsoft.AspNetCore.Mvc;

namespace EcCommerce.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly ApplicationDbContext _context;

    public OrdersController(ApplicationDbContext context)
    {
        _context = context;
    }
        
    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
        var user = await _context.Users
            .Include(u => u.Orders)
            .FirstOrDefaultAsync(u => u.Id == request.UserId);
    
        if (user == null)
            return NotFound("User not found");
    
        if (!user.IsActive)
            return BadRequest("User is not active");
    
        if (request.TotalAmount <= 0)
            return BadRequest("Invalid order amount");
    
        var todayOrdersCount = user.Orders
            .Count(o => o.CreatedAt.Date == DateTime.UtcNow.Date);
    
        if (todayOrdersCount >= 5)
            return BadRequest("Daily order limit exceeded");
    
        var order = new Order
        {
            UserId = user.Id,
            TotalAmount = request.TotalAmount,
            CreatedAt = DateTime.UtcNow
        };
    
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
    
        return Ok(order);
    }
}

The first thing in the code is that it is piercing to the eyes. Also, the controller knows too much of business logic and is really fat. If any business logic changes are required, we need to update the controller. Let's say we have to increase the daily order limit from 5 to 50, then we will need to update it here. Testing is difficult too, any test, even for business logic, will be done on the controller. All of that violates the DRY (Don't Repeat Yourself) and Single Responsibility Principles, as the controller is not inherently dedicated to these tasks. The example may look like I have added too much logic here, which people usually avoid, but you still need to be clear about exactly what the controllers should have. Many developers get confused and write a few lines of business logic where they shouldn't. The controllers should handle HTTP requests, validate the mapping, and return responses.

Business Rules Don't Leak Into Controllers

So, correcting the faulty controller here.

Step 1: Create a Domain Service

public interface IOrderService
{
    Task<Order> CreateOrderAsync(int userId, decimal totalAmount);
}

A very good way to encapsulate business logic is to introduce a service layer with an interface and its implementation, and inject them into the controller.

Step 2: Implementation of the domain service

public class OrderService : IOrderService
{
    private readonly ApplicationDbContext _context;

    public OrderService(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Order> CreateOrderAsync(int userId, decimal totalAmount)
    {
        var user = await _context.Users
            .Include(u => u.Orders)
            .FirstOrDefaultAsync(u => u.Id == userId);

        if (user == null)
            throw new Exception("User not found");

        if (!user.IsActive)
            throw new Exception("User is not active");

        if (totalAmount <= 0)
            throw new Exception("Invalid order amount");

        var todayOrdersCount = user.Orders
            .Count(o => o.CreatedAt.Date == DateTime.UtcNow.Date);

        if (todayOrdersCount >= 5)
            throw new Exception("Daily order limit exceeded");

        var order = new Order
        {
            UserId = user.Id,
            TotalAmount = totalAmount,
            CreatedAt = DateTime.UtcNow
        };

        _context.Orders.Add(order);
        await _context.SaveChangesAsync();

        return order;
    }
}

Sometimes, you can add a repository layer below the services and inject it instead of using ApplicationDbContext directly.

Step 3: Thin Controller

using System;
using Microsoft.AspNetCore.Mvc;

namespace EcCommerce.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;

    public OrdersController(IOrderService orderService)
    {
        _orderService = orderService;
    }
        
    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
    {
      
        var order = await _orderService
            .CreateOrderAsync(request.UserId, request.TotalAmount);
    
        return Ok(order);
    }
}

Now, we have a soothing code. Not to forget dependency injection in your Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Other injections

builder.Services.AddScoped<IOrderService, OrderService>();

Clean Architecture Style (Domain-Driven)

One more way you can deal is to define the logic in the domain itself.

User Model

public class User
{
    public bool IsActive { get; private set; }
    public List<Order> Orders { get; private set; } = new();

    public void CanPlaceOrder(decimal totalAmount)
    {
        if (!IsActive)
            throw new Exception("User is not active");

        if (totalAmount <= 0)
            throw new Exception("Invalid order amount");

        var todayOrders = Orders
            .Count(o => o.CreatedAt.Date == DateTime.UtcNow.Date);

        if (todayOrders >= 5)
            throw new Exception("Daily limit exceeded");
    }
}

Service

public class OrderService : IOrderService
{
    private readonly ApplicationDbContext _context;

    public OrderService(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public async Task<Order> CreateOrderAsync(int userId, decimal totalAmount)
    {
        var user = await _context.Users
            .Include(u => u.Orders)
            .FirstOrDefaultAsync(u => u.Id == userId);
    
        if (user == null)
            throw new Exception("User not found");
    
        user.CanPlaceOrder(totalAmount);
    
        var order = new Order(userId, totalAmount);
    
        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
    
        return order;
    }
}

Dependency injection

var builder = WebApplication.CreateBuilder(args);

// Other injections

builder.Services.AddScoped<IOrderService, OrderService>();

Now the service becomes cleaner. In our corrected versions, we addressed all issues seen in the first code. If a new requirement asks to change the daily limit, we will go to the service or the domain model in a later example, and the controller will remain unaffected in both cases. Testing units are also made easy with the exposed method in the service. If any endpoint requires similar CreateOrder operations, then we can simply use it from OrderService, complying with the DRY principle. Our controller is now clean, and it adheres to the Single Responsibility Principle. The controller can only focus on validating requests and generating responses, while other tasks are handled in the underlying layers.

Conclusion

API controllers are responsible for exposing endpoints that your clients extensively rely on. They should validate the request, call the underlying layer like the service layer, and return the data. Keeping things in their place can help a lot with testing and maintenance, especially if your code is considerably large. One overlooked aspect is understanding exactly what a controller should contain. People often mistakenly add logic inside the controller method and fail to identify where to draw the line. I made it easy with this article and showed what potential problems can arise if business logic leaks inside the controller.

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website