Thin Handlers
Entry points dispatch, they don't decide.
Every system has a boundary: HTTP, queue, CLI, websocket, Server Action. The handler at that boundary parses input, dispatches one call, returns a response. Logic lives one ring in.
Opinion
I've watched the same anti-pattern repeat across every framework I've worked with. “Skinny controller, fat model” was the 2006 Rails frame; the same shape became fat Server Actions in Next.js, fat resolvers in GraphQL, fat handlers in NestJS, fat handle_event callbacks in Phoenix LiveView. The slogan keeps changing because the framework keeps changing; the underlying smell is identical. A function the framework calls has reached into the database, validated business rules, sent an email, and returned a response in eighty lines that nobody can test without booting the framework.1“Skinny Controller, Fat Model”, weblog.jamisbuck.org, 18 Oct 2006. The Rails-community frame that put the rule into mainstream programming vocabulary; the slogan that survived even after the framework around it changed.
The architectural argument is older than any of the frameworks. Cockburn's Hexagonal Architecture (2005), Palermo's Onion (2008), Martin's Clean (2017) all converge on the same rule: the transport layer holds no business logic.2Hexagonal Architecture, HaT Technical Report 2005.02. Ports and Adapters: the application should be drivable by users, programs, automated tests, or batch scripts — the brief of a thin handler. Whether the trigger is HTTP, GraphQL, RPC, a CLI invocation, a queue message, a websocket event, or a UI button, the entry point reads input, calls a domain function, returns output. Every decision worth keeping lives one level in. The principle is universal because the constraint is universal: every system has some external boundary, and every system pays the same cost when domain logic leaks into the code that handles that boundary.
The lever to reach for is the test. A thin handler is testable by passing a parsed input and asserting on a single function call. A fat handler is one whose tests need a full HTTP server, a database container, and a mocked outbound queue. The test shape is the contract; the contract is what reveals whether the rule is being followed. Pair this with F1 Single Responsibility: a fat handler is an SRP failure at the framework boundary, no different to a fat function inside it.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Entry points dispatch, they don't decide. The handler at the framework boundary parses input, calls a single domain function, and shapes the response. Business rules, persistence, branching all belong in functions one level in. When the transport changes, the domain doesn't move — only the handler does. /tenet/thin-handlers/A5
AI eyes only
Rule: entry points dispatch; they do not decide.
Reject: business logic, validation, persistence or response formatting inside route handlers, server actions, or controllers. Reject: handlers longer than ten lines.
Generate: route handler that parses the request, calls a single domain function, and shapes the response. The domain function is HTTP-agnostic and independently testable.
Diagnostic: if the handler tests cannot be written without HTTP fixtures, the handler is too fat. Move the work into a domain function and call it from the handler.
Why?
- The framework becomes a detail. When the transport changes — HTTP becomes a job, REST becomes RPC, a route becomes a Server Action — the domain functions the handlers call don't move. The application outlives the runtime.
- Domain functions get unit tests that don't need an HTTP server, a database container, or a mocked outbound queue. The test runs in milliseconds; the feedback loop is unrecognisable from one that boots a framework.
- Same shape across Next.js Route Handlers, Server Actions, NestJS, FastAPI, Hono, tRPC, Phoenix LiveView, Symfony Messenger, Lambda handlers. Engineers move between stacks without relearning the boundary rule.
- Pairs cleanly with F1 Single Responsibility: a fat handler is an SRP failure at the framework boundary. The same rule that bans fat functions bans fat handlers, just at the entry point of the system.
- Surfaces the bounded context the request belongs to. The function the handler calls lives in that context's folder, not at the transport edge — which is exactly A6 Bounded Contexts in disguise.
- The same domain function can be invoked from a route, a queue worker, a CLI command, and a test fixture without modification. Reusability comes for free because the function doesn't know what called it.
- Coding agents fatten handlers by default — the training data is loud with tutorial repos that put everything inside the route file. The thin-handler rule forces the agent to find the domain folder before it can land the work.
Origins
The Rails-flavoured form of the rule entered mainstream programming vocabulary through Jamis Buck's 2006 post “Skinny Controller, Fat Model”.1“Skinny Controller, Fat Model”, weblog.jamisbuck.org, 18 Oct 2006. The Rails-community frame that put the rule into mainstream programming vocabulary; the slogan that survived even after the framework around it changed. Buck dissected the Fat Controller anti-pattern and walked the reader through pushing logic out of *_controller.rb into ActiveRecord models. thoughtbot codified the same advice in their style guide; by the late 2010s the Rails canon was “keep controllers focused, but don't do extraction surgery on them just to satisfy a testing dogma.”3“Test-induced design damage”, dhh.dk, 23 Apr 2014. The strongest steelman against mechanical extraction: more controllers, each fully RESTful, beats fewer controllers that delegate to a service blob.
The architectural form is older. Alistair Cockburn's Hexagonal Architecture (HaT Technical Report 2005.02) introduced Ports and Adapters: an application has ports (purposeful conversations) and adapters that translate the conversations of an external technology into the application's own language.2Hexagonal Architecture, HaT Technical Report 2005.02. Ports and Adapters: the application should be drivable by users, programs, automated tests, or batch scripts — the brief of a thin handler. Cockburn's explicit goal: the application should “equally be driven by users, programs, automated tests, or batch scripts.” That is precisely the brief of a thin handler. Jeffrey Palermo reformulated the same idea as Onion Architecture in 2008, with the same rule: all coupling is toward the centre.7“The Onion Architecture: Part 1”, jeffreypalermo.com, 29 Jul 2008. Domain at the centre, application services around it, delivery mechanisms only ever in the outer ring. All coupling is toward the centre.
Robert C. Martin consolidated both into the Dependency Rule in Clean Architecture (2017): source code dependencies must point only inward. The web is “just a delivery mechanism”; controllers belong in the Interface Adapters ring, not in the use-cases ring or the entities ring.8Clean Architecture (Pearson, 2017). The Dependency Rule: source code dependencies must point only inward. The web is just a delivery mechanism; controllers belong in the Interface Adapters ring. Martin Fowler's Humble Object pattern (originally for testing) sharpens the implication: when a piece of code is hard to test because it's coupled to a framework or device, push the logic into a sibling that knows nothing about the framework, and leave a humble delegate the framework calls. A handler is a humble object.9Humble Object (bliki). When code is hard to test because it's coupled to a framework or device, push the logic into a sibling that knows nothing about the framework, and leave a humble delegate the framework calls. A handler is a humble object.
Cosmic Python (Percival & Gregory, O'Reilly 2020) is the modern worked example. Chapter 4 demonstrates that with a service layer in place, Flask and Django entrypoints become almost identical thin wrappers — proof that the principle outlives any single framework.6Architecture Patterns with Python (Cosmic Python), O'Reilly 2020, Ch. 4 “Our First Use Case: Flask API and Service Layer”. Demonstrates Flask and Django reducing to almost identical thin entrypoints — proof the principle outlives any single framework. The same shape repeats across every modern stack: Next.js Route Handlers and Server Actions, Remix loaders and actions, NestJS controllers, FastAPI APIRouter, Hono's explicit warning against Rails-like controllers, tRPC procedures, Phoenix LiveView handle_event, GraphQL resolvers, Symfony Messenger handlers. Same rule, different syntax.
Quotes
Allow an application to equally be driven by users, programs, automated tests, or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.
Strip a class down to little more than a delegation, leaving the actual logic in a class that doesn't depend on the difficult-to-test environment.
The damage caused by speculative test-driven design is real. The mismatch between what you're testing and what you're shipping eventually catches up with you.
Don't use Ruby on Rails-like Controllers.
Evidence
Twenty external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01The Ports and Adapters paper. Goal stated explicitly: the application should be drivable by users, programs, automated tests, or batch scripts — the brief of a thin handler.
- 02“The Onion Architecture: Part 1”SupportsReformulation of Cockburn with rings: domain at the centre, application services around it, delivery mechanisms (UI, web, persistence) only ever in the outer ring. The fundamental rule: all coupling is toward the centre.
- 03“The Clean Architecture”SupportsThe blog post that consolidated Cockburn and Palermo into the Dependency Rule: source code dependencies must point only inward. The web is just a delivery mechanism; controllers belong in the Interface Adapters ring.
- 04Humble Object (bliki)SupportsOriginally a testing pattern. When code is hard to test because it's coupled to a framework or device, push the logic into a sibling that knows nothing about the framework, and leave a humble delegate the framework calls. A handler is a humble object.
- 05Service Layer (PoEAA)SupportsThe application-side framing of the same separation. A Service Layer encapsulates the application's business logic; the entrypoint becomes a thin wrapper. The pattern that makes Cosmic Python's Flask/Django reduction possible.
Sixteen sources, three stances. The supporters cluster on the architectural primaries: Cockburn's Hexagonal, Palermo's Onion, Martin's Clean, plus Fowler's Humble Object and Service Layer. The qualifiers further down carry the harder reading: thin handlers can be a shibboleth that produces anaemic models or test-induced design damage if the extraction is purely mechanical. The opposers carry the steelman: sometimes the right answer is many small handlers each doing one thing end-to-end, not one handler dispatching to a service.
Examples
// Before: 80-line POST that parses, queries, decides, mails, returns.export async function POST(request: Request) { const body = await request.json(); if (!body.lat || !body.lng) return Response.json({ error: "missing coords" }, { status: 400 }); const priorCount = await db.query(`SELECT count(*) FROM sightings WHERE area = $1`, [body.area]); if (priorCount === 0) { await mailer.send("wardens-list", "First sighting in " + body.area); } await db.query(`INSERT INTO sightings ...`, [body.lat, body.lng, body.area]); return Response.json({ ok: true }, { status: 201 });}
// After: parse, dispatch, return. The rule lives in the domain function.export async function POST(request: Request) { const input = sightingSchema.parse(await request.json()); const result = await recordHedgehogSighting(input); return Response.json(result, { status: 201 });}
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| max-lines-per-function | ESLint core | route handlers, server actions, and tRPC procedures that have grown past parse-call-respond — the size cap is the structural argument that domain work has leaked into the handler. |
| complexity | ESLint core | branching logic in handlers — a complexity cap of 3–5 forces a thin handler to delegate a decision to the domain function it calls. |
| no-restricted-imports | ESLint core | ORM clients, mailers, and queue libraries imported directly into route files — the import shape that says a handler is doing persistence or messaging itself. |
| import/no-restricted-paths | eslint-plugin-import | route files reaching into persistence or email folders — the path-zone version of the same rule, useful when the persistence library is not a single npm import. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
export default tseslint.config({
files: ['src/app/**/route.ts', 'src/app/**/page.tsx', 'src/app/**/actions.ts'],
plugins: { import: importPlugin },
rules: {
'max-lines-per-function': ['error', { max: 15, skipBlankLines: true }],
'complexity': ['error', 5],
'import/no-restricted-paths': ['error', {
zones: [
{
target: './src/app',
from: ['./src/lib/product/persistence', './src/lib/product/email'],
message: 'route handlers must not import persistence or email directly — call a domain function.',
},
],
}],
'no-restricted-imports': ['error', {
patterns: [
{ group: ['drizzle-orm', 'prisma', 'mongodb', 'pg', 'mysql2', 'nodemailer'],
message: 'transport-layer file is reaching for a domain dependency — extract to a domain function.',
},
],
}],
}
});AI rules
.cursor/rules/a5-thin-handlers.mdc---
description: Prickles A5 — Thin Handlers
globs: "**/*.{ts,tsx,js,jsx,py,java,php}"
alwaysApply: false
---
## Prickles A5 — Thin Handlers
Every system has a boundary — HTTP, queue, CLI, websocket, Server Action. The handler at that boundary parses input, dispatches one call, returns a response. Nothing else.
Business rules, branching, persistence, side-effects belong one ring inward, in functions that don't import the framework. The handler imports the function; the function does not import the handler.
When the transport changes (HTTP becomes a job, REST becomes RPC), the domain function does not move. Only the handler does.
Refuse to add an `if` in a handler that decides anything beyond input shape. If you would, push the decision into the function the handler calls.Repo layout, CI, and ESLint wiring for these paths live on /implementation — not repeated on every tenet.
Counter-argument
The strongest steelman is DHH's.3“Test-induced design damage”, dhh.dk, 23 Apr 2014. The strongest steelman against mechanical extraction: more controllers, each fully RESTful, beats fewer controllers that delegate to a service blob. Wholesale extraction of Service Objects and Command Objects, motivated by a desire for fast unit tests, often produces worse code than a slightly heavier controller. The extracted layer becomes a translation tax with no semantic content; the test harness gets cleaner; the production code gets harder to follow. DHH's preferred remedy is more controllers, each fully RESTful, not fewer controllers that delegate to a service blob. The point generalises beyond Rails: in any framework, an extracted “service layer” that exists only because the handler should not do work is a smell, not because the principle is wrong, but because the extraction was mechanical instead of semantically-driven.
Counter-argument retort
DHH is right that test-induced extraction produces translation-tax code, and the rule should not be read as an instruction to invent a service layer for every route.3“Test-induced design damage”, dhh.dk, 23 Apr 2014. The strongest steelman against mechanical extraction: more controllers, each fully RESTful, beats fewer controllers that delegate to a service blob. The reply is that the extracted function should already have a name in the domain — createOrder, chargeInvoice, scheduleDelivery — and live where that domain lives. If the only name you can think of for the extracted function is handleCreatePostController, the extraction is the problem, not the rule. The handler is thin because the domain has a real verb to call; the verb is the deliverable, the handler is the delivery mechanism. Avdi Grimm's domain-events frame is the cleanest restatement: orchestrate side-effects through events that the domain emits, not through service classes the handler choreographs.4“Slim down hefty Rails controllers AND models, using domain model events”, RubyTapas, 11 Apr 2017. The cleaner restatement: orchestrate side-effects through domain events, not through service classes the handler choreographs.
The Transaction Script counter — that some operations are genuinely a script of database calls and don't need a domain function at all — is a real category, and Fowler names it as such in PoEAA.5Transaction Script (PoEAA, 2002). Some operations are genuinely a script of database calls and don't need a domain function. Even then, the script lives in a function the handler calls, not in the handler's body. The reply is that even a Transaction Script is a function the handler calls, not the handler's body. The script can live in orders/transactions/ as a one-screen function with a clear name; the handler still validates input, calls the script, and shapes the response. Thin Handlers is the rule about the boundary; what lives one ring in can be a service, an aggregate, a transaction script, or a workflow — the rule doesn't prescribe which.
The novelty challenge — that “skinny controller, fat model” already covers this and the new name is just rebranding — misses the cross-stack point. The Rails slogan was tied to a specific architecture (controllers + ActiveRecord). Thin Handlers is the rule expressed in vocabulary that survives the architecture: the same rule applies to a Next.js Route Handler, a Server Action, a tRPC procedure, a GraphQL resolver, a Lambda handler, a queue worker, and a CLI command. Cosmic Python's worked example shows it most clearly — Flask and Django reduce to almost identical thin entrypoints when the service layer is in place.6Architecture Patterns with Python (Cosmic Python), O'Reilly 2020, Ch. 4 “Our First Use Case: Flask API and Service Layer”. Demonstrates Flask and Django reducing to almost identical thin entrypoints — proof the principle outlives any single framework.
The genuine residue is the “thin” vs “anaemic” line. A handler that delegates to a domain function with rich behaviour is thin; a handler that delegates to a domain function which itself is just a getter-and-setter shell is anaemic. The fix is the domain layer, not the handler — pair this with F1 Single Responsibility and A6 Bounded Contexts to make sure the function the handler is calling has a real job to do.
Notes
- [1]Jamis Buck — “Skinny Controller, Fat Model”, weblog.jamisbuck.org, 18 Oct 2006. The Rails-community frame that put the rule into mainstream programming vocabulary; the slogan that survived even after the framework around it changed.
- [2]Alistair Cockburn — Hexagonal Architecture, HaT Technical Report 2005.02. Ports and Adapters: the application should be drivable by users, programs, automated tests, or batch scripts — the brief of a thin handler.
- [3]DHH (David Heinemeier Hansson) — “Test-induced design damage”, dhh.dk, 23 Apr 2014. The strongest steelman against mechanical extraction: more controllers, each fully RESTful, beats fewer controllers that delegate to a service blob.