Jun 16, 2025·7 min read

Application services vs domain services: one simple rule

Application services vs domain services confuses many teams. Use one clear rule to place orchestration and business behavior without guesswork.

Application services vs domain services: one simple rule

Why this split gets confusing

This boundary gets messy because both layers often touch the same objects. An application service might load an Order, call a method on it, save it, and send a message. A domain service might work with that same Order and some of the same supporting objects. On the surface, the code can look almost identical.

That is why names do not help much. Calling something "application" or "domain" does not settle anything, and class size tells you even less. One tiny method can hold the rule that decides whether money leaves an account. Ten short methods can do nothing more than fetch data, call other code, and wrap the work in a transaction.

The real cost shows up later. When business rules land in application services, they start to spread. A web request approves an order one way, an admin script does it another way, and a background job skips one condition entirely. Tests get heavier too, because you need mocks, storage, and setup just to check a rule that should live close to the business model.

The opposite mistake causes different pain. If domain services start handling email, retries, transactions, or API calls, the business model gets harder to read and harder to reuse. A simple rule ends up buried inside process code.

A better test is much simpler: ask what kind of decision the code makes. If it decides business behavior, it belongs in the domain. If it coordinates steps around that behavior, it belongs in the application layer.

That one question clears up most arguments fast.

The one rule to use

Use one rule: if the code decides a business outcome, put it in the domain. If it only coordinates steps, keep it in the application layer.

That sounds small, but it resolves most debates. A method that decides whether an order can ship, whether a refund is allowed, or whether a customer gets a discount is making a business decision. That logic belongs close to the model, where other parts of the system can reuse it safely.

Application code has a different job. It loads data, starts transactions, calls domain objects, saves changes, and triggers side effects like emails or events. It runs the flow. It should not decide what "approved," "valid," or "allowed" means.

A good way to spot the difference is to read the test names you want to write. If the test sounds like a business rule, the logic belongs in the domain. If the test sounds like a workflow, it belongs in the application layer.

These usually point to domain logic:

  • "Reject refund when the purchase is older than 30 days"
  • "Allow upgrade only for active subscriptions"
  • "Block payout when account verification failed"

These usually point to application logic:

  • "Load account, call policy, save result, publish event"
  • "Start transaction, create invoice, send receipt"
  • "Retry the payment call once, then record failure"

One more check helps when code sits in the middle. Ask what would still matter if you changed the database, queue, or web framework. If the rule stays the same, it is probably domain behavior. If the code mostly changes because the process or integration changes, it is probably application orchestration.

This rule also keeps code easier to move and test. Business rules stay plain. The application layer stays thin. When a service turns into a long method full of if statements about pricing, approval, limits, or eligibility, that is usually the signal to move those decisions closer to the domain.

What application services should do

Application services coordinate work. They take a request from the outside world, move data into the domain, and send results back out. They are the code that says, "start this process now."

That process usually begins with a command, an API call, or a UI action. The application service reads the input, checks that it is complete enough to continue, and loads the domain objects involved. If the request says "approve order 123," the application service fetches that order and any other data the domain method needs.

From there, the job is mostly sequence and plumbing: accept the request, load aggregates or supporting records, call domain methods in the right order, save changes, and trigger side effects.

That last part matters. Sending an email, publishing an event, calling a payment provider, or writing an audit log often belongs here. These actions are part of the process, but they are not the business behavior itself.

A good application service stays thin. It can do simple checks such as "does this record exist?" or "does this user have access to start this action?" It can also handle transaction boundaries and retries. What it should not do is make policy decisions in business language.

If you see logic like "if premium customer and order total is above limit and account age is under 30 days," the code is drifting out of place. That reads like a business rule. Put it in the domain, then let the application service call it.

A useful test is to read the method out loud. If most verbs sound like process verbs - load, call, save, publish - it is probably in the right place. If the method starts speaking in business terms - approve, qualify, waive, forbid, reserve - the domain should own more of that behavior.

What domain services should do

Domain services hold rules that answer business questions no single entity can answer alone. They decide what can happen, what must not happen, and which outcome fits the rules.

A good domain service speaks in business terms. It works with orders, invoices, balances, limits, dates, and policies. It should not know anything about HTTP requests, form fields, database rows, message queues, or which screen called it.

Business rules that span more than one object

Some rules fit neatly inside one entity. Others need facts from several entities or value objects. That is where a domain service makes sense.

Take a refund rule. The logic may need to check the order status, payment state, refund window, and the customer's contract terms before it decides whether a refund is allowed. No controller or repository should make that call. The rule belongs with the business.

This is also where you protect invariants that stretch across objects. If your model says an account transfer cannot leave one account below its minimum reserve, and the receiving account cannot break its own limits, the domain service can enforce both rules together.

Results should sound like the business

The return value matters. A domain service should return something the business can read without translation: "Approved," "Rejected," "Refund not allowed after 30 days," or "Transfer exceeds daily limit."

Raw status codes, SQL errors, and UI messages do not belong here. Application code can take the domain result and decide how to present it, log it, or store it. The business decision stays clean.

A quick test helps here too. If you can run the service in a unit test with plain objects and no database, web framework, or API client, you probably placed it well. If the service needs request headers or table names to do its job, it is doing plumbing work, not domain work.

A simple example with order approval

Audit a Tricky Workflow
Bring one approval refund or suspension flow and sort it out fast

Picture an order approval flow for a wholesale store. A sales rep submits an order for 40 units. The system needs to answer two business questions before it can approve the order: do we have enough stock, and does the customer still have enough credit?

The application service coordinates the work. It loads the order, asks inventory for current stock, gets the customer's credit data, and passes those facts into the domain.

In plain language, that flow is "fetch data, call business rule, save result." If you keep the application service at that level, it stays easy to read.

The approval rule itself belongs in the domain model or in a domain service. The rule might say: approve the order only when available stock covers the quantity and the customer's remaining credit covers the order total. If either check fails, reject the order and record the reason.

If the rule fits naturally on the Order entity, put it there. If it needs logic that spans Order, customer credit, and inventory policy, a domain service is often cleaner. The point stays the same: the domain decides what "approved" means.

Payment and email stay outside the domain. They are side effects, not business meaning. After the domain says "approved," the application service can ask a payment provider to authorize the charge and ask a mailer to send a confirmation. If approval fails, it can skip payment and send a different message.

This split keeps tests small. You can test approval rules with plain objects and a few numbers:

  • stock: 50
  • credit left: $1,200
  • order total: $900
  • result: approved

Then write another test where stock is 12 or credit is too low, and expect rejection.

The application service gets a different kind of test. You check that it loads data, calls the domain once, saves the order, and triggers payment or email only after the domain makes a decision.

How to place new logic step by step

Most service-layer confusion starts with one bad habit: people write code before they can say the rule in plain English.

Start with a sentence a non-developer could read. For example: "A refund is allowed only if the payment cleared, the refund window is still open, and the item was not marked final sale."

That sentence already points in the right direction. Words like "allowed," "must," "only if," and "cannot" usually signal a business decision. That part belongs in the domain. Code that loads records, calls other systems, writes logs, or sends messages does not make the decision. It only helps the decision happen.

A practical sequence looks like this:

  • Write the rule as one short business sentence.
  • Mark the exact words that decide yes or no.
  • Ask which objects know the facts behind that decision.
  • Put the rule close to those objects, or in a domain service if several objects share it.
  • Keep database calls, retries, logging, and message sending in the application layer.

That third step clears up most messy cases. If one object has the needed facts, put the behavior there. If several objects each hold part of the truth, a domain service is often the better home. In the refund example, a RefundPolicy might need facts from the payment, the order, and the product rules. The application service can fetch those objects and hand them over, but it should not decide whether the refund passes.

A quick smell test helps. Remove the database, queue, and logger from the method in your head. If the rule still makes sense, you are looking at domain logic. If the method falls apart because all it does is call APIs, save data, and publish events, it belongs in the application layer.

Teams that follow this habit usually end up with smaller services, clearer tests, and fewer weird helper classes.

Mistakes that push logic to the wrong place

Map Rules Before You Build
Check where new business decisions should live before writing code

Most bad service-layer design starts with a small shortcut. A team puts one decision in an application service because it feels faster, then adds another, then another. A month later, the application service does the real business work, and the domain objects are little more than data bags.

That happens because application services are easy to reach. They already talk to repositories, send emails, call APIs, and start jobs. Once business decisions move there too, the code turns into a long script. It still works, but the rules become hard to spot and harder to trust.

The opposite mistake is quieter. Teams create a domain service for methods that only pass data through to an entity or call one method and return the result. That adds another class, another name, and no real meaning. If a rule fits cleanly inside one entity or value object, keep it there.

Another common problem starts when repositories or external APIs leak into business rules. If the rule says, "approve the order only if the customer has no overdue balance," the rule itself should stay in the domain. Loading the customer record is application work. Asking a payment gateway whether the rule is true mixes policy with I/O and makes tests messy fast.

Teams also hide decisions in event handlers and background jobs. A job should do delayed work, not smuggle in policy. If the handler decides whether an order counts as approved, nobody knows where the real rule lives. You read the application service, then the event, then the worker, and still miss half the behavior.

A bigger mess appears when one rule gets split across three classes. The application service checks status, a domain service checks limits, and a repository adapter checks a special flag. Each part looks small, but the rule only makes sense when you read all three together.

Warning signs show up early:

  • The application service has many if statements about business policy.
  • A domain service mostly forwards calls without making a decision.
  • A rule needs database or API code to run.
  • You cannot point to one place and say, "this is where approval is decided."

A good rule should have one home. Let the application service coordinate steps. Let the domain model or a real domain service make the decision. Keep storage, queues, and HTTP calls outside that decision.

A quick checklist before you commit

Fix Bloated Application Services
Pull policy logic out of workflow code and give it one home

Before you commit a new service, stop for a minute and pressure-test the code.

Can you explain the rule without talking about a controller, queue, cron job, or API endpoint? If yes, the rule probably belongs in the domain.

Would the rule stay the same if you switched storage tomorrow? A rule like "orders above $5,000 need manual approval" does not change because you moved from PostgreSQL to another database.

Does this code choose an outcome, or does it just run steps in order? Choosing usually means business behavior. Running steps usually means application orchestration.

Can you test the rule with plain objects and almost no mocks? If the test needs email, payments, logging, or a message broker just to prove the decision, the logic sits in the wrong place.

If you remove emails, payments, and logs, does the decision still make sense? It should. Those actions react to the result. They should not define it.

A small example makes this easier. Suppose an order can be approved, rejected, or sent for review based on amount, customer status, and internal policy. That decision is domain behavior. Sending a confirmation email after approval is not. Charging a card is not. Writing an audit log is not. Those actions happen around the decision, not inside it.

This checklist also helps when code looks tidy but feels wrong. A service method can be short and still hold business logic in the wrong layer. If one method says, in effect, "load data, ask the domain to decide, save the result, then notify other systems," the split is usually clean.

If you cannot answer these questions clearly, do not commit yet. Rename the method, strip out side effects, and see what rule remains.

What to do next in your codebase

Start with one flow that already causes debate. Pick something small but real, like order approval, refund handling, or account suspension. Then mark each step in that flow as either a decision or coordination. If a step answers a business question, it belongs in the domain. If it loads data, saves changes, calls other systems, or sends messages, it belongs in the application layer.

This simple pass usually shows the problem fast. Teams often mix both kinds of work inside one service method, and then every change feels risky.

Before moving anything, add tests around business outcomes. Test the rule, not the method shape. Check that an order with unpaid invoices cannot be approved, or that a customer with the right status can receive a discount. Those tests give you cover while you move logic into a domain service or back into an entity.

Keep the refactor small. Move one rule at a time. Keep method names boring and clear. Leave coordination code in place until the rule is stable. Run the tests after each move.

A full rewrite sounds neat, but it usually creates fresh confusion. One rule moved well is better than twenty moved in a rush. After a few small refactors, the pattern gets easier to see. You start to notice which classes mostly coordinate work and which ones actually decide business behavior.

Use the same question in code reviews: "Is this deciding the business, or coordinating the system?" It keeps reviews short and makes service-layer design more consistent across the team.

If your team keeps getting stuck on boundaries, an outside review can help. Oleg Sotnikov at oleg.is works as a Fractional CTO and advisor, and this kind of architecture cleanup is exactly the sort of practical design problem where a focused second opinion can save weeks of churn.