libmodulor |> Controllers : The Other Billion Dollar Mistake
↕️
libmodulor
Docs
Introduction
Concepts
Philosophy
Architecture
Dependency Injection (DI)
Examples
Basic
Playground
Standalone
MyRunningMap
Guides
Create a project
Create an app
Create a use case
Create a product
Create a target
Create a custom target
Create a data type
Create a policy
Test an app
Translate an app
Bundle a target
References
Data Types
Policies
Targets
Articles
Current version :<br>0.32.0
Made in 🇫🇷<br>GitHub<br>NPM
Controllers : The Other Billion Dollar Mistake
Author(s) : Chafik H'nini<br>Last update : 2026-07-19
Controllers correspond to the C in the MVC (Model–View–Controller) pattern. Popularized by frameworks like Ruby on Rails, they have become a de facto standard in the industry, for better or worse.
First, if you are a seasoned engineer, you might have spotted the obvious analogy with Tony Hoare's Null References : The Billion Dollar Mistake.
In this article, we will see that like "Null References", "Controllers" are probably as bad because they are boilerplate , they spread logic in multiple places and last but not least, they make testing very cumbersome .
In the MVC pattern, the controller's responsibilities are usually limited to :
Reading request data (params, query, headers, body)
Calling one or more services
Translating service results into HTTP responses
Mapping domain errors to HTTP status codes
A typical example
Let's imagine a business app where we want to manage users : list, create and delete.
Most code snippets are voluntarily in JavaScript instead of TypeScript to make them more concise.
Although it is not in the "MVC" pattern per sé, the service layer is often used to define business logic, especially when it touches multiple models.<br>Let's define a basic service implementing our business features :
[🔖 services/UserService.ts]
export class UserService {<br>constructor(userModel) {<br>this.userModel = userModel;
async list() {<br>return this.userModel.findAll();
async create(data) {<br>return this.userModel.create(data);
async delete(id) {<br>return this.userModel.delete(id);<br>To be able to use these features, we need to expose these methods in the controller (using hono) :
[🔖 controllers/UserController.ts]
import { Context } from 'hono';
export class UserController {<br>constructor(userService) {<br>this.userService = userService;
async list(c) {<br>const users = await this.userService.list();<br>return c.json({<br>users,<br>});
async create(c) {<br>const data = await c.req.json();<br>const user = await this.userService.create(data);<br>return c.json({<br>user,<br>});
async delete(c) {<br>const id = c.req.param('id');<br>const deleted = await this.userService.delete(id);<br>return c.json({<br>deleted,<br>});<br>We can already see a pattern here. We have a 1:1 relationship between the service layer and the controller layer .
And we are not done. Just like the service methods need to be exposed, so do the controller methods. For them, we need a router (still using hono) :
[🔖 router.ts]
const app = new Hono();
const controller = new UserController(new UserModel());
app.get("/users", (c) => controller.getUsers(c));<br>app.post("/users", (c) => controller.createUser(c));<br>app.delete("/users/:id", (c) => controller.deleteUser(c));<br>Once again, we repeat our business features in another layer : the routing, which can be considered as part of the controller layer.
To summarize, at this point, for a given business feature, we have to define :
a method in the model
a method in the service
a method in the controller
a declaration in the router
What do the "DRY" gurus think about that ?
Another annoying thing is that we put all of them in the same "bag" while they have no relationship between them, besides acting on the same model.<br>It is no wonder that in some codebases, some services and controllers contain thousands of lines and are totally unmaintainable.
Adding authentication
Right now, our business features are "open bar" : anyone can list, create and delete.<br>We want to limit each one like so :
list : everyone authenticated
create : anonymous
delete : admin only
Where should we define this ? In the router as a middleware ?
[🔖 router.ts]
-app.get("/users", (c) => controller.getUsers(c));<br>+app.get("/users", requireAuthenticated(c), (c) => controller.getUsers(c));<br>-app.post("/users", (c) => controller.createUser(c));<br>+app.post("/users", requireAnonymous(c), (c) => controller.createUser(c));<br>-app.delete("/users/:id", (c) => controller.deleteUser(c));<br>+app.delete("/users/:id", requireAdmin(c), (c) => controller.deleteUser(c));<br>In the controller ?
[🔖 controllers/UserController.ts]
export class UserController {<br>constructor(userService) {<br>this.userService = userService;
async list(c) {<br>+ if (!c.req.auth) {<br>+ return c.json({ error: 'Unauthorized' }, 401);<br>+ }<br>const users = this.userService.list();<br>return c.json({<br>users,<br>});
async create(c) {<br>+ if (c.req.auth) {<br>+ return c.json({ error: 'Forbidden' }, 403);<br>+ }<br>const...