When we write software, our goal is not just to solve a specific business problem. We need to do it in a way that won’t make it hard to solve other problems in the future. This is why it’s very important that the code that we write is maintainable.
There are many practices that make our code maintainable. One such practice is known as the thin controller principle. It’s not as widely known as other practices, such as SOLID principles or design patterns. Also, many code samples that teach you how to build APIs for web applications violate this principle and encourage programmers to develop bad habits.
Unfortunately, the name is a bit outdated, as it doesn’t accurately reflect what this principle is used for. It was initially thought out for applications that use the Model View Controller (MVC) software design pattern, hence the controller part in its name. However, it applies to absolutely any API endpoints and any protocols, not just MVC controllers.
If you develop web applications, the thin controller principle is a very useful practice to know. So, let’s learn what it is and why it’s so useful.
What’s the thin controller principle
When you build any kind of web API, whether it’s the standard REST API, gRPC, or any kind of bespoke protocol, you would have some endpoint function or a method that maps to an external address, such as a path in a URL. When a request is sent to this address, the logic inside this method or a function is triggered.
A common practice is to put your business logic right inside your endpoint. After all, that’s what most online tutorials tell you to do. For example, in this e-commerce REST API written in C#, we have two endpoint methods with all the business logic right inside of them:
[ApiController] [Route("api/orders")] public class OrderController : ControllerBase { private readonly ApplicationDbContext _context; public OrderController(ApplicationDbContext context) { _context = context; } [HttpPost] public async Task<IActionResult> CreateOrder([FromBody] OrderDto orderDto) { if (orderDto == null || orderDto.Items.Count == 0) { return BadRequest("Invalid order data."); } var order = new Order { CustomerId = orderDto.CustomerId, OrderDate = DateTime.UtcNow, TotalAmount = orderDto.Items.Sum( item => item.Price * item.Quantity) }; _context.Orders.Add(order); await _context.SaveChangesAsync(); return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order); } [HttpGet("{id}")] public async Task<IActionResult> GetOrder(int id) { var order = await _context.Orders.FindAsync(id); if (order == null) { return NotFound(); } return Ok(order); } }
We ave the CreateOrder()
endpoint method that takes the order details from the request payload, validates it and adds it to the database. We also have the GetOrder()
endpoint method, which retrieves a specific order from the database based on its id.
This is a relatively simple example, but the API controller is doing quite a lot. In fact, it manages the entire end-to-end flow between the application endpoint and the database. For example, for the POST endpoint, we take the data from a data transfer object that represents the payload of the request. We then do some basic validation on this data. Then, assuming the validation has passed, we convert the data transfer object into another object that represents an entity stored in the database. We then insert the entity into the database, save the changes, and return the response.
If we refactor it to apply the thin controller principle, the responsibility of the endpoint method will be limited to accepting a request from an external client and sending the response back to the client. The business logic and the database interactions will be done by a separate service, which we will inject into the controller class. This is what our controller will look like once we apply all these changes:
[ApiController] [Route("api/orders")] public class OrderController : ControllerBase { private readonly IOrderService _orderService; public OrderController(IOrderService orderService) { _orderService = orderService; } [HttpPost] public async Task<IActionResult> CreateOrder([FromBody] OrderDto orderDto) { var result = await _orderService.CreateOrderAsync(orderDto); if (!result.Success) { return BadRequest(result.Message); } return CreatedAtAction(nameof(GetOrder), new { id = result.OrderId }, result.Order); } [HttpGet("{id}")] public async Task<IActionResult> GetOrder(int id) { var order = await _orderService.GetOrderByIdAsync(id); if (order == null) { return NotFound(); } return Ok(order); } }
With this setup, the controller doesn’t care at all about database interactions. The interaction with the database is happening inside the IOrderService
implementation. The controller doesn’t even know what type of database we use, whether it’s SQL Server, MongoDB, or just an Excel spreadsheet!
The benefits of the thin controller principle
If you aren’t familiar with this pattern, you may not initially understand its significance. After all, doesn’t an additional level of abstraction make the application more complex and harder to read? Well, the answer is no. Here are the benefits you would gain by implementing this pattern:
Code reusability
While the API is one of the ways to create an order, there may be other ways too. For example, what if a customer of the e-commerce store subscribed to auto-renewal, which is done via a service running in the background? In this case, IOrderService
can be injected as a dependency into both the API controller and the background scheduler service. The order-creation code will not be duplicated.
Separation of concerns between business logic and adaptors
This is related to the previous point. Your application may have different types of clients and different types of APIs. For example, a web page may have a direct interaction with the back-end via the MVC pattern. A mobile app may interact with the back end via gRPC. Another type of client may use REST API. An older version of a client may use SOAP protocol.
But for your business logic, none of this matters. Each type of supported communication mechanisms will have its own adapter with its own bespoke validation rules, request processing mechanism, etc, while the business logic will stay the same.
Easy to replace implementation
We may be in a situation where we found that it would be better to migrate to a different database provider or even an entirely different database type. We may find that our business logic has to be completely rewritten to enable this.
But from the adapter’s perspective, none of this matters. We are just injecting the interface into the adapter. Each adapter only cares about the signature of the methods and not the implementation details inside them. Therefore, by creating another implementation the IOrderService
interface, we won’t have to update any code where we inject this interface.
This will save us a lot of time. This also showcases the benefits of the separation of concerns.
Better unit testability
If we have a class or a pure function that encapsulates the business logic, we can write the tests against it directly. We will only have one set of tests for our business logic instead of having many sets of unit tests specific to each adaptor.
If you prefer to develop applications by utilizing test-driven development, you will still be able to do so. The same applies if you prefer to test per unit of behavior rather than the unit of implementation. The service you inject into the adaptors will act as the entry point into your business logic and that would be the level you write your tests at.
In fact, you shouldn’t write unit tests directly against adaptor methods at all. Let’s see why.
Why you wouldn’t unit-test endpoint methods
There are several conflicting definitions of what unit tests are, but everyone tends to agree on this one thing. Unit tests are where you are invoking the methods or functions of the objects you are testing directly.
So, why not directly invoke the endpoint methods? For example, what would stop you from doing this?
OrderController controller = new(); OrderDto order = new(); var response = controller.CreateOrder(order);
Well, technically, we can do it. The code will still compile and we will get the appropriate code coverage. Only that our test will be completely useless.
You see, in a real-life situation, our endpoint methods are never invoked directly by our code. They are designed to be invoked by the framework. This is why we have to apply specific annotations to them and make sure we adhere to other rules. For example, the data that we pass in a REST API endpoint must be JSON-serializable.
Directly invoked methods don’t have these restrictions. Therefore, we may end up in a situation where our tests are passing just fine, but our actual endpoint doesn’t work at all.
The solution to this problem is twofold:
- You would have unit tests with direct invocations for the business logic component. This will allow you to test all possible permutations of the behavior within the business logic. You can have many test cases.
- You would have integration tests at the adapter level that involve making real requests to the endpoints. You will have only a handful of test cases for each endpoint.
This is an example of an integration test in C#, but all other languages and frameworks also have this:
public class OrderControllerTests : IClassFixture<WebApplicationFactory<Startup>> { private readonly HttpClient _client; public OrderControllerTests(WebApplicationFactory<Startup> factory) { _client = factory.CreateClient(); } [Fact] public async Task CreateOrder_ReturnsCreated() { var newOrder = new { CustomerId = 1, Items = new[] { new { ProductId = 1, Quantity = 2, Price = 10.0 }, new { ProductId = 2, Quantity = 1, Price = 20.0 } } }; var response = await _client.PostAsJsonAsync("/api/orders", newOrder); response.EnsureSuccessStatusCode(); var order = await response.Content.ReadFromJsonAsync<Order>(); Assert.NotNull(order); Assert.Equal(40.0, order.TotalAmount); } }
In this setup, you will have a detailed test coverage of your business logic in one place. You will also have sufficiently good coverage of each of your endpoints.
Even if you only ever plan to have one endpoint for each piece of behavior in the business logic, it still makes sense to have this setup. You probably don’t want to put all possible test cases at the adapter level. Because integration tests rely on application startup middleware and real requests, they are significantly slower than unit tests. This is why the bulk of your test automation should be done at the unit test level.
How it applies to any endpoints
All the examples we looked at so far apply object-oriented principles. We have a controller class that represents a collection of endpoints. The controller class has a constructor. We inject the business logic service into the constructor.
However, if you prefer a functional programming paradigm, the same principle will apply too. It will just be implemented differently.
The following example shows how this can be applied in the ASP.NET Core Minimal APIs framework, which utilizes a functional programming approach instead of relying on controller objects:
var app = builder.Build(); app.MapPost("/orders", async (OrderDto orderDto, IOrderService orderService) => { var result = await orderService.CreateOrderAsync(orderDto); return result.Success ? Results.Created($"/orders/{result.OrderId}", result.Order) : Results.BadRequest(result.Message); }); app.MapGet("/orders/{id}", async (int id, IOrderService orderService) => { var order = await orderService.GetOrderByIdAsync(id); return order is not null ? Results.Ok(order) : Results.NotFound(); }); app.Run();
We don’t have a class and we don’t have a constructor. In this case, we just inject the dependency into the function itself as its parameter. The underlying framework will take care of it.
Wrapping up
The thin controller principle is all about separating your adaptor endpoints from the business logic. The name may not be accurate, as it came about when everyone was using the MVC pattern and relying on controllers. However, it doesn’t apply only to controllers. It applies to absolutely any endpoint type.
However, since the industry started moving away from using the controller, I haven’t heard anyone referring to this principle by any other name. Perhaps, we should start calling it thin endpoint principle to reflect more accurately on what it’s used for.