0% прочитано

Where Should a Business Rule Live: Controller, Service, or Domain Model?

A business rule is easy to leave in a controller or application service at first. Once several use cases depend on it, the rule often reveals itself as a domain invariant. This article shows how I separate request validation, use-case coordination, and domain rules.

13 жовтня 2026 р. 11 хв читанняLaravel

In the article about the point where plain CRUD stops being enough I wrote about the point where simple create/update/delete operations stop being enough because rules start growing around the data.

The next practical question is almost immediate: where should those rules live?

At first, the easiest place is the controller. Later the controller becomes too large, so the rule moves into a service. But when the same rule is needed from HTTP, a command, a queue, or another use case, it becomes clear that the problem is not the entry point.

The rule belongs to the domain itself.

I separate three concerns: input validation, use-case coordination, and domain rules. They may sit close together in simple code, but they have different responsibilities.

A Business Rule Often Starts in the Controller

It works while there is one use case and the rule is small
public function activate(    Product $product,): RedirectResponse {    if ($product->status !== 'draft') {        abort(422);    }     if (        blank($product->code)        || blank($product->name)    ) {        abort(422);    }     $product->update([        'status' => 'active',

There is nothing unusual about this code. For a first version it may be completely sufficient.

The problem starts when I look at the conditions and realize that the controller is no longer making HTTP decisions.

It knows from which state the object may be activated and which data is required.

That is domain knowledge.

What I Keep Near the Controller

HTTP request shape stays at the application boundary
final class ActivateProductRequest extends FormRequest{    public function rules(): array    {        return [            'reason' => [                'nullable',                'string',                'max:500',            ],        ];    }}

Input-shape validation does not automatically belong in the domain model.

Whether a field is a string, whether it exceeds a request limit, or whether the HTTP request is well formed belongs at the application boundary.

The controller may also shape an HTTP response, redirect, or return the required status code.

I do not move everything into the domain merely to make the controller look small.

The Next Step Often Looks Right: Move the Rule into a Service

The controller became smaller, but the rule still lives outside the model
final class ActivateProductService{    public function handle(        Product $product,    ): void {        if ($product->status !== 'draft') {            throw new LogicException(                'Product cannot be activated.',            );        }         if (            blank($product->code)            || blank($product->name)        ) {            throw new LogicException(

This is already better than a large controller: the use case is isolated, easier to test, and easier to reuse.

But I ask one more question: what is this service actually doing?

If most of its code answers "is this object allowed to move into this state?", it is no longer only coordinating a use case.

The service now owns a rule that should remain the same regardless of how the action is triggered.

Reuse Quickly Reveals That the Rule Lives in the Wrong Place

Several entry points, but one rule
HTTP controller       |       vactivate product  Console command       |       vactivate product  Queue job       |       vactivate product

This is usually where my view of the code changes.

With one controller, the rule is easy to treat as part of that use case.

When a second entry point appears, copying conditions becomes dangerous, and relying on every caller to use one particular service becomes fragile.

If the rule describes valid state for the entity itself, the safest design is for the entity to make the rule difficult to bypass.

If the Rule Protects Entity State, I Move It into the Domain Model

The model protects its own valid transition
final class Product{    public function activate(): void    {        if ($this->status !== ProductStatus::DRAFT) {            throw new DomainException(                'Product cannot be activated from this state.',            );        }         if (! $this->isComplete()) {            throw new DomainException(                'Product is not ready for activation.',            );        } 

External code no longer knows the details of the rule.

It does not inspect status, check a list of fields, or assign the new value directly.

It asks the model to perform a meaningful action: activate().

If the state is invalid, the model rejects the action consistently from HTTP, a command, a queue, or a test.

My Main Criterion: Is the Rule a Domain Invariant?

An invariant does not depend on the entry point
Invariant: An active productmust be complete.  It must remain trueafter every valid operation. Not only afterone controller action.

If a rule must remain true after every valid operation, that is a strong sign that it belongs to the domain.

For example, "an active product must be complete" is not a rule of one form or one route.

It describes valid system state.

I try to place such rules as close as possible to the model whose state they protect.

What Is Left for the Application Service?

The service coordinates the use case while the model makes the domain decision
final class ActivateProduct{    public function handle(        ProductId $productId,    ): void {        DB::transaction(function () use (            $productId,        ) {            $product = $this->products                ->getForUpdate($productId);             $product->activate();             $this->products->save(                $product,            );

The application service remains very useful.

It can open a transaction, load the aggregate, invoke the action, persist the result, and coordinate infrastructure concerns.

But it should not restate the conditions under which activate() is valid.

Its job is to coordinate the use case. The model protects the rule.

The Word "Service" Is Too Ambiguous by Itself

Two kinds of services can have very different responsibilities
Application service coordinates a use case:- load- transaction- call domain- save  Domain service contains a domain operationthat does not naturally belongto one entity or value object

When I see a class named SomethingService, I no longer treat that as an answer to "where does the rule live?"

First I need to know what kind of service it is.

An application service coordinates a use case. A domain service may hold a domain operation when that rule does not naturally belong to one entity.

So "move it into a service" is not an architecture decision by itself.

Not Every Business Rule Belongs to One Entity

If the rule genuinely spans several domain concepts, a domain service may be more natural
final class ActivationEligibility{    public function canActivate(        Product $product,        Policy $policy,    ): bool {        return $product->isReady()            && $policy->allowsActivation();    }}

I do not force every rule into an entity at any cost.

If a rule genuinely depends on several domain concepts and none naturally owns the decision, a domain service may be a cleaner choice.

It should still express a domain rule rather than HTTP, SQL, or queue mechanics.

Authorization and Domain Rules Are Not Always the Same Thing

The two checks may happen close together, but they answer different questions
Authorization asks: "May this userperform this action?"  Domain rule asks: "Is this action validfor the modelin its current state?"

For example, an administrator may be authorized to request activation while the product itself is not ready to activate.

The first check is about user permission. The second is about valid domain state.

I try not to collapse these different reasons for rejection into one large if.

A Critical Rule Can Also Be Reinforced in the Database

The domain model is where I want the meaning of the rule to be visible.

But if part of an invariant can also be reinforced by a reliable technical constraint, I use that protection too.

I covered that second layer separately in an article about database constraints as the last line of defense.

The code explains the rule. The database can prevent clearly invalid state even if application code makes a mistake.

The Aggregate Helps Identify Who Should Protect the Rule

If I cannot decide where a rule belongs, the deeper problem is often unclear state ownership.

The question from the article about aggregates and consistency boundaries helps: which aggregate must guarantee this invariant?

Once state ownership is clear, rule placement usually becomes much easier.

Tests Reveal Whether a Rule Is Truly Domain Logic

The rule can be tested without HTTP or a database
public function test_incomplete_product_cannot_be_activated(): void{    $product = Product::draft(        code: null,        name: 'Example',    );     $this->expectException(        DomainException::class,    );     $product->activate();}

If a rule can be naturally stated and tested without a route, request, or database, that is often a good sign.

The test reads like a business sentence: an incomplete product cannot be activated.

That is how I want important rules to look: separate from the mechanism that delivers the command into the system.

My Simple Guide for Placing a Rule

Not an absolute law, but a useful practical guide
Question 1 Is it about request shape,format, HTTP, or response? -> Controller / Request layer  Question 2 Is it coordinating a use case,transaction, repository,or infrastructure? -> Application service 

Rule-Placement Mistakes I Notice Faster Now

A few signals stand out to me:

  • The same condition appears in several controllers.
  • A service consists mostly of state checks for one entity.
  • Code can assign status directly and bypass the method that contains the rule.
  • A business-rule test needs an HTTP request even though the rule has nothing to do with HTTP.
  • A service has a very generic name because it both coordinates the use case and decides domain rules.
  • One large if mixes user permission, request shape, and valid model state.

In Center, Rule Ownership Became Clear Gradually

While working on Center, I did not always recognize immediately that a check had become an invariant.

It often became obvious only when a second or third use case needed the same behavior.

This connects closely to the article about domain boundaries in a growing system: the domain gradually reveals not only its boundaries, but also the owners of its rules.

I therefore do not treat moving a rule from a service into the model as proof that the first version was wrong. It is often a normal result of learning more about the system.

Questions I Ask Before Leaving a Rule in a Service

I check:

  • Is this rule about HTTP or about valid domain state?
  • Must it behave the same from several entry points?
  • Can direct model updates bypass the rule?
  • Does one entity naturally own the decision?
  • Is the service coordinating the use case or deciding what the business allows?
  • Can the rule be tested directly without HTTP?
  • Is it an invariant that must remain true after every valid operation?

I Put the Rule Where It Is Hardest to Bypass

For me, the answer to "Controller, Service, or Domain Model?" no longer starts with the class type.

I first identify what kind of rule it is.

If it is about request shape, it stays at the application boundary. If it coordinates a use case, it belongs in an application service. If it defines valid model state regardless of entry point, it belongs in the domain.

The more important the invariant, the closer it should live to the state it protects.

When a rule must behave the same from a controller, command, and queue, that is a strong sign that it belongs to the domain rather than to the entry point.