Skip to content
Preptima
mediumConceptDesignMidSeniorStaff

How do you decide whether a piece of behaviour belongs on the domain object or in a service?

Behaviour that needs only the object's own state, and any rule the object must never violate, belongs on the object. Services own orchestration across objects, anything requiring I/O, and the transaction boundary - which is why an entity should never hold a repository.

6 min readUpdated 2026-07-29Target archetype: Big Tech, Enterprise Captive, Product Startup
Practice answering out loud

What the interviewer is scoring

  • Whether the decision is argued from where the data and the invariant live rather than from layer convention
  • Does the candidate keep repository access out of the entity, and describe how the rule still gets its data
  • That the legitimate uses of a data-only model are named instead of treating it as always wrong
  • Whether value objects are used to absorb rules rather than adding more service-level validation
  • Does the candidate recognise a service method that only reads and writes one object's fields

Answer

Put the behaviour where the data is

The first test is mechanical. If a method needs nothing but the object's own state to reach its decision, it belongs on that object. A service that reads five fields from an order, applies a rule, and writes two of them back has taken a decision the order was capable of taking itself, and it has taken it in a place where nothing stops the next service from taking it differently.

The second test is stronger and is the one that decides genuinely awkward cases. If there is a rule the object must never violate — an order cannot be cancelled after dispatch, a booking cannot end before it starts, a balance cannot go below its overdraft limit — then that rule has to be enforced inside the object, because the alternative is that every present and future caller is responsible for remembering it. Encapsulation is not about hiding fields, it is about there being exactly one place where an invariant can be broken and therefore exactly one place to guard.

// Behaviour on the object. Everything it needs to decide, it already has.
public List<DomainEvent> cancel(Clock clock, CancellationReason reason) {
    if (status == DISPATCHED) throw new IllegalStateException("dispatched orders are returned, not cancelled");
    this.status = CANCELLED;
    this.cancelledAt = clock.instant();
    // Whether money moves is not this object's decision, so it is reported.
    return List.of(new OrderCancelled(id, reason, refundDue()));
}

Notice that the method decides and records, and does not act on the outside world. That separation is what makes the rule testable without a database and is the boundary the rest of this answer turns on.

What services legitimately own

Three things belong in a service and putting them on an entity is the more common mistake among people who have just discovered rich domain models.

Orchestration across objects. Anything that has to load two aggregates, coordinate them and save both is not the behaviour of either one. PlaceOrder reserving stock, creating an order and scheduling a payment is a process, and processes get their own class.

Anything that needs I/O. The moment a rule requires a repository query, an HTTP call or the clock, an entity that performs it has acquired a dependency on infrastructure, and with it the inability to be constructed in a test or reasoned about in isolation. This is why the rule "entities do not hold repositories" is worth stating as an absolute even though it sounds dogmatic: an entity with a repository reference can lazily trigger queries from a getter, which is how a loop over a hundred orders becomes a hundred round trips that nobody can see in the code.

The transaction boundary. Someone has to decide what commits together, and it is not the entity, which has no idea whether it is being used alone or as part of something larger.

When a rule needs external data, you have two honest options. Fetch it in the service and pass it in as an argument or a small policy object, so the entity method stays pure — order.applyDiscount(customerTierPolicy) rather than order.applyDiscount() reaching for a lookup. Or, when the rule spans several aggregates and belongs to none, put it in a domain service: a stateless object named for the rule, holding no data, that takes the participants as arguments. Both keep I/O outside the entity; the choice is about where the rule reads most naturally.

When a data-only model is the right answer

The anaemic model — entities as field bags with getters and setters, all logic in services — is a real cost and not always a defect, and treating it as universally wrong is a weaker answer than knowing when it applies.

Its cost is precise. Because state can be assigned from outside, invalid combinations are representable, so nothing prevents an object existing with a cancellation date and an active status. And because rules live in services, the same rule gets written twice by two teams who each thought theirs was the only caller, which is how two subtly different definitions of "eligible" end up in one codebase.

Where there are no invariants, none of that applies. A row in a reporting read model, a payload crossing a service boundary, a configuration record, a CRUD screen over a lookup table — these are data, and wrapping them in behaviour they do not have adds indirection for nothing. The distinction to hold on to is that a data structure and an object are different tools: a data structure exposes its fields and has no rules, an object hides its state because it has rules to keep. Trouble comes from types that are trying to be both.

Let value objects absorb the small rules

Much of what accumulates in service-level validation is not domain logic at all, it is the absence of types. Once Money, EmailAddress and DateRange exist and validate in their own constructors, an entire category of checks disappears from every service that handles them, because an invalid instance cannot be constructed in the first place.

DateRange is the clearest illustration. If a start and an end are two fields on an entity, then every method that touches them, and every service that sets them, has to remember that the end must not precede the start. Make them one immutable type that checks the relation on construction, and the rule is enforced once, everywhere, including in code written next year by someone who never read the original ticket. This is also where a great deal of duplicated defensive code in service layers comes from, so it is worth checking for before adding another validator.

Reconstitution and the framework's demands

The practical objection is that persistence frameworks want a no-argument constructor and want to set fields directly, which appears to force public setters and an anaemic shape. It does not, and knowing the way out is a useful signal.

Keep the validating constructor or a static factory as the only public way to create an object, and give the framework a separate non-public path — a protected or package-private no-argument constructor, with field access rather than setters. Creation and reconstitution are genuinely different operations: creation must validate because the input is new, whereas reconstitution is loading a state that was valid when it was written. Being explicit about that distinction is better than pretending it does not exist, and it is why validation belongs in the creation path rather than in a lifecycle callback that also runs on load.

One last signal, on naming. A service called OrderManager or OrderHelper names no process, so it becomes the place anything about orders is put, and it grows until nobody can say what it does. A service named for the operation — PlaceOrder, CancelBooking, RefundPayment — has an obvious boundary and an obvious reason to reject unrelated code. When you cannot name a service for a process, that is usually the sign that the behaviour belonged on the object all along.

Ask what data the decision needs and what rule must never break. Behaviour that needs only the object's own state, and every invariant that object owns, goes on the object; orchestration, I/O and the transaction boundary go in a service — and an entity that can reach a repository has already lost the distinction.

Likely follow-ups

  • The rule needs data from a second aggregate. What are your options, and what does each one cost you?
  • Your ORM demands a no-argument constructor and field access. How do you keep the invariant intact?
  • When is a class named FooManager evidence of a missing concept, and when is it genuinely fine?
  • Two services compute the same eligibility rule slightly differently. How did that happen, and where does the rule go now?

Related questions

domain-modelanaemic-modelencapsulationvalue-objectsservice-layer