Loading content...
In the world of Domain-Driven Design, we strive to build rich domain models—entities that encapsulate both data and behavior, value objects that express domain concepts with clarity. The mantra is clear: "Put the logic where the data lives."
But there's an uncomfortable truth every experienced developer eventually encounters: not all business logic belongs to a single entity.
Consider this scenario: A customer wants to transfer money from one bank account to another. Where does this logic live? The source account? The destination account? Neither? This simple question exposes a fundamental gap in our object-oriented toolkit—operations that involve multiple domain objects without naturally belonging to any single one.
By the end of this page, you will understand when business logic genuinely cannot reside within entities or value objects, recognize the telltale signs of misplaced logic, and appreciate why Domain Services exist as a first-class DDD building block—not as a refuge for lazy design.
Object-oriented principles teach us that objects should encapsulate behavior. This is excellent advice—but taken to an extreme, it leads developers into awkward contortions when business operations genuinely span multiple objects.
The money transfer revisited:
Let's examine what happens when we try to force the transfer logic into an entity:
1234567891011121314151617181920
// Attempt 1: Logic in source accountclass BankAccount { private balance: Money; private id: AccountId; // This feels wrong - why does BankAccount know about other accounts? transferTo(destination: BankAccount, amount: Money): void { if (this.balance.isLessThan(amount)) { throw new InsufficientFundsError(this.id, amount); } // Direct manipulation of another object's state this.balance = this.balance.subtract(amount); destination.credit(amount); // Calling method on foreign object } credit(amount: Money): void { this.balance = this.balance.add(amount); }}What's wrong with this approach?
At first glance, this code works. But it violates several core principles:
BankAccount now knows about other BankAccount instances. This creates coupling that makes testing, refactoring, and reasoning about the domain harder.When you keep adding methods to entities to 'keep the logic close to the data,' you eventually create God Objects—entities with dozens of methods, many of which have little to do with the entity's core identity. This is the opposite of good design.
Through years of DDD practice, patterns have emerged that identify when logic genuinely doesn't belong to entities. Understanding these categories helps you recognize domain service candidates in your own domains.
Category 1: Operations Spanning Multiple Aggregates
When business logic requires coordination between multiple aggregates (not just entities), no single aggregate can own the operation without violating aggregate boundaries.
| Operation | Aggregates Involved | Why No Single Owner |
|---|---|---|
| Money Transfer | Source Account, Destination Account | Neither account has authority over the other; transfer is a symmetric operation |
| Order Fulfillment | Order, Inventory, Shipment | Fulfillment orchestrates multiple bounded concepts |
| Trade Execution | Buy Order, Sell Order, Market | Matching happens between orders, not within either |
| Meeting Scheduling | Multiple Calendars, Room Resource | Availability must be checked across all participants |
Category 2: Stateless Business Calculations
Some business logic is pure calculation—it takes inputs, applies domain rules, and produces outputs without modifying any existing state. These calculations often don't belong to any entity because they operate on data from multiple sources or represent standalone domain operations.
123456789101112131415161718192021222324
// Pure domain calculation - no natural entity ownerinterface PricingResult { basePrice: Money; discounts: DiscountDetail[]; taxes: TaxDetail[]; finalPrice: Money;} // This calculates price using data from multiple sources:// - Product (base price, category)// - Customer (membership tier, loyalty points)// - Promotions (current campaigns)// - Tax jurisdiction (location-based rules) // No single entity owns this calculationfunction calculateOrderPrice( products: OrderLine[], customer: Customer, activePromotions: Promotion[], taxJurisdiction: TaxJurisdiction): PricingResult { // Complex business calculation involving multiple domain objects // but modifying none of them}Category 3: External System Integration with Domain Rules
When your domain needs to interact with external systems while still enforcing domain invariants, this logic often can't live in entities because:
Category 4: Domain Process Coordination
Some business processes involve a sequence of domain operations that must be coordinated according to business rules. While an Application Service might orchestrate the overall workflow, the domain rules governing that coordination belong in a Domain Service.
When you discover business logic and find yourself asking 'But where does this live?' while mentally cycling through your entities and finding none that fit—you've likely found a Domain Service candidate. Trust your instinct; the domain is telling you something.
Before we fully embrace Domain Services, we must acknowledge a significant danger: the Anemic Domain Model anti-pattern.
In an anemic model, entities become mere data containers with getters and setters, while all business logic migrates to services. This is the opposite of DDD's intent.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// ❌ ANEMIC MODEL - Logic extracted to services incorrectlyclass Order { id: OrderId; lines: OrderLine[]; status: OrderStatus; customerId: CustomerId; // Just getters and setters - no behavior setStatus(status: OrderStatus) { this.status = status; } setLines(lines: OrderLine[]) { this.lines = lines; }} class OrderService { // ALL logic lives here - even logic that belongs to Order confirmOrder(order: Order): void { if (order.lines.length === 0) { throw new Error("Cannot confirm empty order"); } if (order.status !== OrderStatus.DRAFT) { throw new Error("Only draft orders can be confirmed"); } order.setStatus(OrderStatus.CONFIRMED); // Bypasses invariants! }} // ✅ RICH MODEL - Entity owns its behaviorclass Order { private readonly id: OrderId; private lines: OrderLine[]; private status: OrderStatus; private readonly customerId: CustomerId; // Entity enforces its own rules confirm(): void { if (this.lines.length === 0) { throw new EmptyOrderError(this.id); } if (this.status !== OrderStatus.DRAFT) { throw new InvalidStateTransitionError(this.status, OrderStatus.CONFIRMED); } this.status = OrderStatus.CONFIRMED; // Possibly raise OrderConfirmed domain event } addLine(product: Product, quantity: number): void { this.ensureModifiable(); // Entity validates and adds the line } private ensureModifiable(): void { if (this.status !== OrderStatus.DRAFT) { throw new OrderNotModifiableError(this.id); } }}Domain Services are NOT a dumping ground for logic you're too lazy to put in entities. If an operation naturally belongs to a single entity and only manipulates that entity's state, it MUST live in the entity. Domain Services exist for logic that genuinely has no single entity home.
How do you distinguish between logic that should be in an entity and logic that genuinely needs a Domain Service? Here's a systematic evaluation framework:
| Scenario | Where It Belongs | Reasoning |
|---|---|---|
| Updating customer's email address | Customer Entity | Single entity, its own data |
| Calculating order total from order lines | Order Entity | Derived from entity's own state |
| Transferring money between accounts | Domain Service | Spans multiple aggregates |
| Validating unique email across customers | Domain Service | Requires access to collection, not single entity |
| Matching buy/sell orders in trading | Domain Service | Operates on multiple aggregates of same type |
| Sending confirmation email | Application Service | Infrastructure concern, not domain logic |
| Logging user activity | Application Service or Infra | Cross-cutting concern |
| Calculating insurance premium | Domain Service | Complex calculation using multiple inputs |
Domain experts often reveal domain services through their language. When they say 'The system transfers funds' rather than 'The account transfers funds,' they're telling you the operation is a first-class domain concept that exists independent of any single entity.
Let's examine concrete examples from various domains to solidify your understanding of when logic doesn't belong to entities.
Example 1: E-Commerce — Inventory Reservation
When a customer adds items to their cart and proceeds to checkout, inventory must be reserved. But where does this logic live?
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// The problem: Inventory reservation involves:// - Order (who wants the items)// - InventoryItem (what's being reserved)// - Warehouse (where items are located)// - Reservation rules (how long reservations last, priority) // ❌ Attempt to put in Order - awkwardclass Order { reserveInventory(warehouse: Warehouse): void { // Order shouldn't know about warehouses }} // ❌ Attempt to put in InventoryItem - also awkwardclass InventoryItem { reserveFor(order: Order): void { // InventoryItem shouldn't know about orders }} // ✅ Domain Service - natural homeinterface InventoryReservationService { /** * Attempts to reserve inventory for an order. * Applies domain rules for reservation priority, duration, and availability. */ reserveForOrder( order: Order, reservationDuration: Duration ): ReservationResult; /** * Releases reservations when order is cancelled or expires. */ releaseReservation(reservationId: ReservationId): void;} // The service encapsulates:// - Which warehouses to check (nearest, fastest shipping)// - Reservation priority rules (premium customers first)// - Split-shipment decisions// - Rollback logic if partial reservation failsclass InventoryReservationServiceImpl implements InventoryReservationService { constructor( private inventoryRepository: InventoryRepository, private warehouseLocator: WarehouseLocator, private reservationPolicyProvider: ReservationPolicyProvider ) {} reserveForOrder(order: Order, duration: Duration): ReservationResult { const policy = this.reservationPolicyProvider.getPolicyFor(order); const warehouses = this.warehouseLocator.findOptimalFor(order); // Complex domain logic coordinating multiple aggregates // This BELONGS in a domain service }}Example 2: Insurance — Premium Calculation
Insurance premium calculation is a classic domain service candidate:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Premium calculation requires:// - Policy details (coverage, limits)// - Customer risk profile (age, health, history)// - Actuarial tables (statistical risk data)// - Regulatory adjustments (state-specific rules)// - Promotional adjustments (discounts, offers) interface PremiumCalculationService { calculatePremium( policyApplication: PolicyApplication, customerProfile: CustomerRiskProfile, effectiveDate: Date ): PremiumQuote;} // Why this is a Domain Service:// 1. It's a recognized domain concept ("calculating the premium")// 2. It's stateless (same inputs → same outputs)// 3. It involves multiple domain objects (policy, customer, tables)// 4. Neither Policy nor Customer owns this calculation// 5. It's pure domain logic, not infrastructure class PremiumCalculationServiceImpl implements PremiumCalculationService { constructor( private actuarialTableProvider: ActuarialTableProvider, private regulatoryRuleEngine: RegulatoryRuleEngine, private promotionEvaluator: PromotionEvaluator ) {} calculatePremium( application: PolicyApplication, profile: CustomerRiskProfile, effectiveDate: Date ): PremiumQuote { // 1. Get base rate from actuarial tables const baseRate = this.actuarialTableProvider .getRateFor(application.coverageType, profile.riskClass); // 2. Apply risk adjustments based on customer profile const riskAdjustedRate = this.applyRiskAdjustments(baseRate, profile); // 3. Apply regulatory requirements const regulatoryRate = this.regulatoryRuleEngine .adjustForJurisdiction(riskAdjustedRate, application.state); // 4. Apply promotional discounts const finalRate = this.promotionEvaluator .applyApplicableOffers(regulatoryRate, application); return new PremiumQuote(finalRate, this.breakdownComponents()); }}Example 3: Fintech — Trade Matching
In a trading system, matching buy and sell orders is a core domain operation:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// Trade matching clearly cannot belong to an individual Order:// - Neither buy nor sell order 'owns' the matching// - The matching algorithm is a domain concept itself// - Multiple orders may participate in producing trades interface TradeMatchingService { /** * Matches incoming order against existing orders in the book. * Returns executed trades and any unmatched remainder. */ matchOrder( incomingOrder: Order, orderBook: OrderBook ): MatchingResult;} interface MatchingResult { executedTrades: Trade[]; remainingOrder: Order | null; // null if fully matched matchingEvents: MatchingEvent[];} class PriceTimeMatchingService implements TradeMatchingService { // Implements price-time priority matching algorithm // This is pure domain logic about HOW orders match matchOrder(incoming: Order, book: OrderBook): MatchingResult { const trades: Trade[] = []; let remainingQuantity = incoming.quantity; const oppositeSide = incoming.side === 'BUY' ? book.getSellOrders() : book.getBuyOrders(); for (const restingOrder of oppositeSide) { if (!this.priceMatches(incoming, restingOrder)) break; const fillQuantity = Math.min(remainingQuantity, restingOrder.quantity); trades.push(Trade.execute(incoming, restingOrder, fillQuantity)); remainingQuantity -= fillQuantity; if (remainingQuantity === 0) break; } return { executedTrades: trades, remainingOrder: remainingQuantity > 0 ? incoming.withReducedQuantity(remainingQuantity) : null, matchingEvents: this.generateEvents(trades) }; }}Notice how in each example, the Domain Service has a name that's a noun or verb phrase from the Ubiquitous Language: 'Inventory Reservation,' 'Premium Calculation,' 'Trade Matching.' If you can't name your service using domain terminology, reconsider whether it's truly a domain service.
We've explored the fundamental tension that gives rise to Domain Services: the gap between object-oriented design's entity-centric approach and business operations that genuinely span multiple domain objects.
Key takeaways:
What's next:
Now that we understand when logic doesn't belong to entities, we need to understand what makes a Domain Service a Domain Service. The next page explores the essential characteristics that distinguish Domain Services from other service types and ensure they remain true to DDD principles.
You now understand the problem that Domain Services solve—business logic that genuinely has no single entity home. Next, we'll examine the defining characteristics of well-designed Domain Services.