Reference Guide · Architecture

Business Logic Doesn't Belong in Controllers

Why business rules belong in the domain and not in the controller — a principle that applies to every modern backend, not to a particular framework. For CTOs, engineering managers, architects and senior developers.

What is this? · Reference Guide

A solid guide to an engineering question — with trade-offs, costs and the case in which we decide differently. Not an opinion piece, but a reference text. Go to overview

Author
Batunet Engineering
Reading time
16 min
Level
Advanced
Status
Approved
Last reviewed
21 July 2026
Updated
21 July 2026
On this page

It is one of the oldest and most expensive mistakes in backend development, and at first it looks like the opposite of a mistake: you write the business logic where the request arrives — in the controller. It is the shortest path to a working feature, every tutorial demonstrates it, and nobody notices the price until it has grown over years.

This text is not a framework question. Whether it's a Laravel controller, a Symfony controller, an ASP.NET controller, a Spring @RestController or a Rails controller — the role is the same everywhere, and so is the mistake of housing business rules there. The principle is framework-independent because its core has nothing to do with technology, but with a boundary: the controller belongs at the edge of the system, with the protocol. The business rules belong at its core, in a domain that knows no framework. Blur that boundary and you couple the most valuable part of your system — the rules that make up the business — to the most replaceable: the framework.


Why controllers grow too large

Nobody decides to bloat a controller. It grows because nothing stops it. The controller is where the request arrives, and therefore the path of least resistance: the request is here, so you validate here; this is the entry point, so you load the data here, check the rule here, save here and respond here. Every new requirement adds "just a few lines." No single decision makes the controller fat — the accumulation does.

Two forces reinforce this. First, the pull of the frameworks themselves: scaffolds and examples put logic in controllers and models because that is the shortest path to a running demo — and the demo is what most people learn from. Second, there is no natural counterpressure: as long as it works, there is no reason to extract the rule. So the controller grows until a change hurts — and by then it has long been the place where everything converges.

The pull has a second convenience: the data model. Because the ORM object holds the data anyway, it's tempting to write the rules right into it — and suddenly the same object carries data, persistence and business logic all at once. The fat controller thus often becomes a fat model without anything changing about the underlying problem: the rule is still glued to infrastructure, just to the database instead of HTTP. Both times, it's the same short path that later becomes a long one.

The actual responsibility of a controller

A controller has a single job: translating. It mediates between the outside world — a protocol like HTTP — and the inside of the system. It reads the request, checks its shape, hands a clean call to the application, receives the result and shapes a response from it: status code, format, headers. Nothing more. A good controller reads like an interpreter, not a decision-maker. It makes no business decision; it passes it on.

The litmus test is simple: when you read the controller, you should see that a rule is applied, but not which one. The rule itself lives elsewhere. Remove the protocol — no HTTP, no request object, no status code — and ideally nothing business-related remains in the controller that you'd miss.

The name sometimes obscures how universal this is. What is called a "controller" here appears in every ecosystem: as a controller method, an action, a route handler, an endpoint. And the layer behind it carries different names depending on tradition — use case, interactor, application service, command handler. The terms are interchangeable; the roles are not: there is a place that translates, one that orchestrates and one that decides. Separate the three cleanly and you've built the same thing — regardless of the name and the framework.

The recommendation: limit controllers to protocol translation and push every decision inward. The price is an additional call and an additional layer — the rule no longer sits directly where the request arrives, but one level deeper. We decide differently for a very small application whose "business logic" is pure create, read, update and delete without real rules; there, the lean path through the controller is the appropriate simplicity, and a domain layer would be ceremony without return.

Business rules vs. orchestration

Not everything that doesn't belong to the protocol is the same thing. It pays to separate two things that often merge in the controller.

Business rules are the decisions and invariants that make up the business — true no matter how the request comes in. An order must not be shipped before it is paid. A discount must not exceed a limit. Two bookings must not overlap. These rules belong in the domain: in objects that model the business domain, without a database, without HTTP, without a framework.

Orchestration is the sequence of steps that fulfills an operation: check the input, load the entity, let the domain decide, save the result, trigger a notification, respond. That doesn't belong in the controller either — but it isn't the domain. It lives in a thin application layer: a use case, a service, a handler, named differently depending on the language. The controller calls the use case; the use case orchestrates; the domain decides.

The distribution can be read as a map of responsibilities:

TaskBelongs inDoes not belong in
Choose HTTP status and response formatControllerDomain
Check the shape of the request (types, required fields)Controller / edgeDomain
Check business rule and invariantDomainController
Order of the steps of an operationUse caseController
Set the transaction boundaryUse caseDomain / controller
Load and save dataInfrastructure (repository)Domain
Email, message, external callInfrastructureDomain
Controller-centric Controller HTTP Business rules Persistence Email Responsibilities separated Controller · translate Use case · orchestrate Domain · decide Infrastructure · DB, mail

Diagram: the same tasks — once mixed together in one controller, once distributed across layers with one responsibility each.

The recommendation: separate rule and orchestration — rules into the domain, flow into a use case, translation into the controller. The price is more structure: more files, more named places, some mapping effort between the layers. We decide differently when an operation really consists only of "load, minimal check, save"; then a dedicated orchestration layer is superfluous, and the direct path is enough until real rules emerge.

Symptoms of controller-centric systems

You recognize the pattern by recurring signs — diagnostic, not moral:

  • A controller method is hundreds of lines long and reads like an entire business process.
  • The same rule appears in several controllers — copied and drifting apart over time.
  • A business rule is only reachable via HTTP; a background job or command-line command rebuilds it instead of calling it.
  • To test a simple calculation, the entire framework has to boot and an HTTP request has to be simulated.
  • A business change means editing controllers — and nobody is sure what else will break.
  • The ORM model carries three things at once: data, rules and persistence.
  • The question "where is the rule that says X?" has no clear answer.

These symptoms are independent of the framework. The "fat controller" and its twin, the "fat model" — business rules that grow into the active record object together with persistence — occur equally in every language and every stack.

Long-term maintenance costs

The price of mixing is invisible on day one and grows steadily afterwards. Duplicated rules drift apart: two controllers check the same thing slightly differently, and the system behaves differently depending on the entry point — a bug that is hard to find because the one rule no longer exists. Coupling to the framework makes every major upgrade, let alone a switch, dangerous, because it touches the business logic that should actually remain untouched. New team members need a long time because the rules are scattered and nowhere written down as a whole. And every change risks an unintended side effect, because in the controller, protocol, flow, rule and persistence sit side by side.

None of these costs is noticeable at the start — they add up, like all the costs that determine longevity. That is exactly what makes them dangerous: today's cheap shortcut is the expensive change of the day after tomorrow.

The same difference, measured by its consequences:

DimensionRules in the controllerRules in the domain
Testabilityonly with framework and HTTPas a pure unit test
Reachabilityonly via HTTPfrom HTTP, CLI, queue, job, test
Framework couplinghighnone at the core
Location of the rulescattered, often duplicatedin one place
Cost of changerising, riskylocal
Readabilitymixed with protocolrule on its own

The recommendation: pay the small structural price early to avoid the growing one later — rules in one place, independent of the entry point. The price is real up-front work without immediately visible benefit: the separated structure costs more as long as the system is small. We decide differently for a short-lived system — a prototype, a campaign page, a tool with a foreseeable end; there, the shortcut never gets its bill, and structure would be wasted.

Impact on testing

Nothing makes the difference as tangible as testing. A pure business rule — an object without a database, without HTTP, without a framework — is cheap, fast and stable to test: exactly the kind of test with the highest ROI. You call the rule with inputs and check the result, in milliseconds, without setup.

If the same rule sits in the controller, the economics flip. To test it, you have to start the framework, simulate a request, often provision a database — for a calculation that at its core needs none of that. Such tests are slow and brittle, so they get written less often, and the expensive core stays untested. Separation reverses this: it makes exactly the places where a bug is expensive cheap to test.

The effect shows up in everyday work. A suite of pure rule tests runs in seconds and is therefore run often — before every commit, in every pipeline. A suite that boots the framework for every rule runs for minutes and is therefore avoided. Fast tests get used, slow ones get bypassed; so separation decides not only whether a test is possible, but whether it would have run at all before the bug hit production.

The recommendation: put business rules where they can be tested without a framework as a pure unit test. The price is that you have to map the transition between layers and secure that mapping as well. We decide differently for logic that is inseparably intertwined with infrastructure — for example, a query whose core is the database behavior itself; there, an integration test is the more honest litmus test than an isolated rule test.

Framework independence

Framework independence is easily misunderstood. It doesn't mean avoiding the framework or hiding it everywhere behind your own abstractions — that would be premature abstraction, expensive and without return. You use the framework to the full where it is strong: routing, HTTP, validating the shape of requests, the ORM for persistence. Only the core stays independent — the business rules don't depend on the framework.

The practical benefit is concrete, not ideological. The same rule has to be reachable from several entry points: from an HTTP request, a command-line command, a queue worker, a scheduled job, a test. If it sits in the controller, only HTTP reaches it; if it sits in the domain, all of them do. And a framework upgrade no longer threatens the rules, because they don't know the framework. Measured this way, Laravel, Symfony, ASP.NET, Spring and Rails are all the same: infrastructure at the edge, more replaceable than the rules they serve.

An example makes this tangible. Suppose there is a rule that an order may only be completed if payment, stock and delivery address match up. As long as it sits in the controller, only the path through the web form reaches it. If a command-line import is added later, a retry from a queue or a partner via its own API, each of these paths has to rebuild the rule — and from then on, the copies drift apart. If the same rule sits in the domain, all paths call the same single check, and there is only one truth about when an order may be completed.

Infrastructure · framework, HTTP, ORM Use cases · orchestration Domain Dependencies point inward — the domain doesn't know the outer layers

Diagram: the outer layers depend on the domain, never the other way around — which is why the core stays free of the framework.

The recommendation: keep dependencies pointing inward — the domain depends on nothing, the framework depends on it. The price is mapping work at the boundary: framework objects and domain objects have to be translated into each other. We decide differently when a part of the system is, at its core, nothing but framework functionality — a pure pass-through, a thin view; there, coupling to the framework isn't a sin but the appropriate simplicity.

Migration strategy

You don't straighten out a controller-centric system with a big "clean architecture rewrite" — that would be the same mistake as any big-bang overhaul. You extract the logic step by step, one operation at a time.

The path is mechanical once you know it. You take a fat controller, pick a business rule in it and pull it into a simple domain object with its own tests. If the behavior is unclear, you pin it down first with characterization tests before moving it. Afterwards, the controller only calls the extracted rule. The rest stays untouched until its turn comes. That way the logic migrates inward piece by piece, every step is small and reversible, and the system keeps running the whole time.

Where to start? Ideally with the rule that is duplicated most often or does the most damage when it breaks. That is where the gain from extraction is greatest, and that is where the first, disproportionately expensive step that establishes the new structure in the first place pays off — every further operation then finds it already in place.

The recommendation: migrate by incrementally extracting individual rules, secured by tests, rather than through a big overhaul. The price is a transition period in which both styles coexist — some rules already in the domain, others still in the controller. We decide differently for a very small system whose overhaul in one go is cheaper than the transition phase; there you may straighten things out in a single step, because the risk stays manageable.

Common mistakes

The recurring patterns around this topic:

  • Business rules in the controller — the rule is chained to the protocol and only reachable via HTTP.
  • The fat ORM model — rules that grow into persistence so that you can't change one without the other.
  • The anemic domain as an overcorrection — objects that only hold data while the logic ends up in controllers and services after all; rules belong with the data they govern.
  • The same rule duplicated across several entry points until the copies drift apart.
  • Abstracting the framework away everywhere — an interface for every detail, expensive and without return.
  • Too many layers for trivial code — ceremony where there is no rule to protect.
  • Orchestration in the controller, so that flow and translation merge.
  • HTTP concepts in the domain — status codes, request objects — that bind the core to the protocol.

Decision checklist

Questions a team can ask before writing or cleaning up a controller. They are diagnostic questions, not verdicts.

  • Could this rule be reached from a command-line command or background job without duplicating it? If not, it sits too far out.
  • Can I test this business rule without starting the framework or simulating HTTP? If not, it is coupled to the protocol.
  • Does the controller make a business decision — or does it only translate? Only translation belongs in it.
  • Is the same rule defined in exactly one place? Several places mean drift.
  • Do HTTP concepts — status code, request, response — show up inside the domain? They shouldn't.
  • Would the business rules change if we swapped the framework? They shouldn't.
  • Is the number of layers proportionate to the problem — or ceremony on top of trivial code? Structure follows complexity, not principle.

FAQ

Isn't this over-engineering for small applications? For an application without real rules — pure create, read, update, delete — yes. There, the direct path through the controller is the appropriate simplicity. Separation pays off as soon as there are business rules you want to test, reuse and change over years. The question isn't one of principle, but of proportionality.

Does validation belong in the controller or the domain? Both, but two different kinds of validation. The shape of the request — are the fields there, are the types right — is checked at the edge, in the controller. The business rule — is this state transition allowed, does this invariant hold — belongs in the domain. The difference: one protects against nonsensical input, the other is the business rule itself.

What about ActiveRecord or ORM models that carry logic? The fat model is the same mistake as the fat controller, one layer further in: business rules grow into persistence. You can keep rules in rich domain objects and handle persistence separately. Pragmatically, a small system may stay closer to the ORM — the point is not to chain rules to storage, not to enforce a particular pattern.

Isn't an anemic domain bad too? Yes, that is the other extreme: objects that only hold data while the logic lives elsewhere. Rules should belong with the data they govern wherever possible. The goal is not to banish logic from objects, but to get it out of the controller — and a domain that decides is better than one that merely carries.

Does that mean not using the framework? On the contrary. You use the framework to the full where it is strong — routing, HTTP, ORM, shape validation. Only the core stays independent. Framework independence isn't turning away from the framework, but a boundary within the system.

Where does the transaction boundary belong? In the orchestration, that is, the use case — not in the domain, which shouldn't know anything about persistence, and not as a decision in the controller. The use case wraps the steps of an operation; that is where the transaction is opened and closed.

Further reading

It is grounded in the Batunet Engineering Method: business logic in the domain, the framework as infrastructure, boundaries drawn deliberately and in small steps.


A framework is a tool for a decade, a business rule an asset for longer. Mix the two and you tie what endures to what is replaceable — and only notice when the replaceable part needs to be swapped out.

Referenced entities

A concrete project in this field?

Reference Guides show how we think. To discuss your system, talk directly to our management.