Controllers: The Other Billion Dollar Mistake

pmdfgy2 pts1 comments

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...

controller create users userservice delete return

Related Articles