Skip to content
ioob.dev
Go back

DDD Part 6 — Domain Service and Application Service

· 8 min read
DDD Series (6/7)
  1. DDD Part 1 — Why Domain-Centric Design Matters
  2. DDD Part 2 — Ubiquitous Language and Bounded Context
  3. DDD Part 3 — Context Mapping
  4. DDD Part 4 — Entity and Value Object
  5. DDD Part 5 — Aggregate and Repository
  6. DDD Part 6 — Domain Service and Application Service
  7. DDD Part 7 — Domain Event and Anti-Corruption Layer
Table of contents

Table of contents

The Habit of Putting All Logic in Services

There’s a structure you see constantly in Spring Boot projects: Controller -> Service -> Repository. And all business logic sits inside the Service class. Entities are empty shells with nothing but getters and setters.

This structure is called the Anemic Domain Model. While Martin Fowler flagged it as an anti-pattern, a surprising number of projects look exactly like this. Why? Because it’s the easiest approach. Putting logic in entities requires thinking about object design, but procedurally listing everything in a Service works for the time being.

The problems appear as the Service becomes bloated. When OrderService handles order creation, order cancellation, order retrieval, discount calculation, inventory checking, payment requests, and notification dispatching, you get methods hundreds of lines long. With logic concentrated in one place, the code becomes hard to understand, hard to test, and hard to modify.

DDD provides a clear answer to this problem: place logic in the appropriate location. Give entities the work they can handle, put domain logic that feels awkward in an entity into a Domain Service, and separate use case orchestration into an Application Service.

Three Options for Placing Logic

In DDD, there are broadly three places to put business logic:

  1. Entity / Value Object: Logic closely tied to a specific object’s state
  2. Domain Service: Domain logic that feels awkward belonging to a single entity
  3. Application Service: Use case orchestration, infrastructure calls, and transaction management — not domain logic

The flow for deciding where to place logic can be expressed as a diagram:

flowchart TD
    Q1{"Does this logic<br/>change a specific<br/>Entity's state?"}
    Q2{"Does it require<br/>multiple Aggregates or<br/>external information?"}
    Q3{"Is it a domain rule<br/>or technical<br/>orchestration?"}

    E["Place in Entity / Value Object"]
    DS["Place in Domain Service"]
    AS["Place in Application Service"]

    Q1 -->|"Yes"| E
    Q1 -->|"No"| Q2
    Q2 -->|"Yes"| Q3
    Q2 -->|"No"| DS
    Q3 -->|"Domain rule"| DS
    Q3 -->|"Technical orchestration"| AS

    style E fill:#5ca45c
    style DS fill:#d4943a
    style AS fill:#4a90d9

These criteria aren’t absolute rules. There are many ambiguous boundary cases, and standards may differ between teams. What matters is whether you can explain why it's placed here.

When to Put Logic in Entities

The most ideal placement is inside the entity. Having an object manage its own state is also a fundamental principle of object-oriented programming.

The Order Aggregate Root from Part 5 is a good example.

class Order(
    val id: Long,
    private val _items: MutableList<OrderItem>,
    private var _status: OrderStatus
) {
    fun place() {
        require(_items.isNotEmpty()) { "Cannot place an order with no items" }
        _status = OrderStatus.PLACED
    }

    fun cancel() {
        require(_status == OrderStatus.PLACED) {
            "Only placed orders can be cancelled"
        }
        _status = OrderStatus.CANCELLED
    }

    fun totalAmount(): Money {
        return _items.fold(Money(BigDecimal.ZERO)) { acc, item ->
            acc + item.totalPrice()
        }
    }
}

place(), cancel(), totalAmount() — these methods depend only on Order’s state. They don’t call external services or reference other aggregates; Order decides and transitions its own state. This kind of logic naturally belongs in the entity.

When logic lives in the entity, tests become concise too.

@Test
fun `empty orders cannot be placed`() {
    val order = Order.create(id = 1L, customerId = 100L)

    assertThrows<IllegalArgumentException> {
        order.place()
    }
}

No Repository, no Spring Context needed. A pure unit test.

Domain Service — Logic That Feels Awkward in an Entity

Not all domain logic fits neatly into a single entity. When two or more aggregates are involved, or when external information that the entity shouldn’t know about is needed, a Domain Service is the right fit.

Characteristic 1: Stateless

A Domain Service is a stateless collection of pure behavior. It holds no instance fields of its own and produces results by combining the domain objects passed to it. While registered with Spring’s @Service, it differs from typical service classes in that it must have no instance variables.

Characteristic 2: Named in Domain Language

A Domain Service’s name should use terminology that domain experts can understand. Not OrderProcessingHelper, but names like FundTransferService or ExchangeRateConverter.

Example: Currency Conversion

Currency conversion doesn’t belong to any specific entity. There’s no reason for the Money Value Object to know about exchange rates, and it would be awkward for Order to call an exchange rate API. This kind of logic is a classic candidate for a Domain Service.

class ExchangeRateService(
    private val exchangeRateProvider: ExchangeRateProvider
) {
    fun convert(money: Money, targetCurrency: String): Money {
        if (money.currency == targetCurrency) return money

        val rate = exchangeRateProvider.getRate(
            from = money.currency,
            to = targetCurrency
        )
        return Money(
            amount = money.amount * rate,
            currency = targetCurrency
        )
    }
}

ExchangeRateProvider is defined as an interface, and the actual external API call is handled by an implementation in the infrastructure layer. The Domain Service itself resides in the domain layer and doesn’t depend directly on infrastructure.

Example: Duplicate Check

Consider email duplicate checking during user registration. Can a Member entity determine on its own whether my email is duplicated? It can’t. Checking for duplicates requires examining other Members’ emails, which is beyond the scope of an individual entity’s responsibility.

class MemberRegistrationService(
    private val memberRepository: MemberRepository
) {
    fun ensureEmailNotDuplicated(email: String) {
        val existing = memberRepository.findByEmail(email)
        if (existing != null) {
            throw DuplicateEmailException("Email already in use: $email")
        }
    }
}

This service implements a domain rule (emails must be unique) while holding no state of its own. It simply checks existing data through the repository and throws an exception if the rule is violated.

Signals That You Need a Domain Service

Consider a Domain Service if any of the following apply:

Application Service — The Use Case Orchestrator

An Application Service is the layer that orchestrates use cases. It doesn’t perform domain logic directly but delegates to domain objects and Domain Services. It manages the flow of starting transactions, retrieving domain objects from repositories, invoking the necessary operations, and persisting the results.

Implementing the place order use case as an Application Service looks like this:

@Service
@Transactional
class PlaceOrderService(
    private val orderRepository: OrderRepository,
    private val exchangeRateService: ExchangeRateService,
    private val eventPublisher: ApplicationEventPublisher
) {
    fun execute(command: PlaceOrderCommand): PlaceOrderResult {
        // 1. Retrieve the Aggregate
        val order = orderRepository.findById(command.orderId)
            ?: throw OrderNotFoundException(command.orderId)

        // 2. Execute domain logic (delegate to Order)
        order.place()

        // 3. Call Domain Service (if needed)
        val totalInKRW = exchangeRateService.convert(
            order.totalAmount(), "KRW"
        )

        // 4. Persist
        orderRepository.save(order)

        // 5. Publish event
        eventPublisher.publishEvent(
            OrderPlacedEvent(orderId = order.id, amount = totalInKRW)
        )

        return PlaceOrderResult(orderId = order.id, totalAmount = totalInKRW)
    }
}

Looking at what the Application Service does in this code, there’s no domain logic at all:

The role of the Application Service becomes even clearer in a sequence diagram:

sequenceDiagram
    participant C as Controller
    participant AS as PlaceOrderService
    participant R as OrderRepository
    participant O as Order
    participant DS as ExchangeRateService
    participant EP as EventPublisher

    C->>AS: execute(command)
    AS->>R: findById(orderId)
    R-->>AS: Order
    AS->>O: place()
    O-->>AS: State transition complete
    AS->>DS: convert(totalAmount, "KRW")
    DS-->>AS: Money(KRW)
    AS->>R: save(order)
    AS->>EP: publishEvent(OrderPlacedEvent)
    AS-->>C: PlaceOrderResult

An Application Service is like an orchestra conductor. It doesn’t know how each instrument (domain objects, Domain Service) plays, but it knows when each instrument should come in.

Application Service vs Domain Service

Here’s a summary of the differences between the two services:

CriterionDomain ServiceApplication Service
Logic containedDomain rulesUse case orchestration
StateNoneNone
Transaction managementDoesn’t handleResponsible for
Infrastructure dependencyMinimized (depends on interfaces)Can use directly
Layer affiliationDomainApplication
Testing approachUnit testsIntegration tests
Name examplesExchangeRateServicePlaceOrderService

The most fundamental difference comes down to the question: “If I remove this code, does a domain rule disappear?” Removing a Domain Service would lose the currency conversion rule. Removing an Application Service would lose the place order use case flow, but individual domain rules would remain in the entities and Domain Services.

Common Boundary Issues in Practice

Boundary Issue 1: Where Does Notification Go?

There’s a requirement to send the customer an email when an order is placed. Is email sending a domain rule?

No. Email sending is not a domain concept but a technical side effect. An order is placed is a domain rule; when an order is placed, send an email is a policy. Such side effects are best handled by publishing events from the Application Service and having event handlers process them.

Boundary Issue 2: Where Does Discount Calculation Go?

A single order’s discount rate calculation can go in the Order entity. But what happens when multiple conditions interact — VIP gets an extra 10% off, coupon applied, promotional period discount?

In this case, creating a Domain Service called DiscountPolicy is appropriate. Logic that combines multiple discount rules isn’t the responsibility of any specific entity.

class DiscountPolicy(
    private val memberRepository: MemberRepository
) {
    fun calculateDiscount(order: Order, memberId: Long): Money {
        val member = memberRepository.findById(memberId)
            ?: return Money(BigDecimal.ZERO)

        val gradeDiscount = when (member.grade) {
            Grade.VIP -> order.totalAmount().amount * BigDecimal("0.10")
            Grade.GOLD -> order.totalAmount().amount * BigDecimal("0.05")
            else -> BigDecimal.ZERO
        }

        return Money(gradeDiscount, order.totalAmount().currency)
    }
}

Boundary Issue 3: When the Application Service Becomes Bloated

Application Services can become bloated too. Cramming ten use cases into a single service repeats the same problem as the anemic domain model.

The solution is to split Application Services by use case. Instead of putting createOrder(), cancelOrder(), updateOrder(), getOrderDetail(), etc. all into OrderService, split them into PlaceOrderService, CancelOrderService, GetOrderDetailService.

// Separate Application Service for each use case
@Service class PlaceOrderService(...)
@Service class CancelOrderService(...)
@Service class GetOrderDetailService(...)

One class, one use case. This minimizes each class’s dependencies and narrows the scope of change impact. It also connects naturally with the CQRS (Command Query Responsibility Segregation) pattern.

Dependency Direction Between Layers

Let’s organize the layer relationships between Entity, Domain Service, and Application Service covered so far.

Controller -> Application Service -> Domain Service -> Entity / Value Object
                                  -> Repository (interface)

The dependency direction always points inward (toward the domain). The Application Service uses Domain Services and Repositories; the Domain Service uses Entities and Value Objects. If there’s code depending in the opposite direction, the design needs re-examination.

In this structure, Entities and Value Objects at the innermost layer depend on nothing. Since they contain only pure domain logic, they’re easy to test and unaffected when frameworks change.

Key Takeaways

Here’s a summary of what this article covered.


The next article covers Domain Event and Anti-Corruption Layer. We’ll wrap up the series by examining how eventual consistency between aggregates is implemented through events and how to protect the domain from external systems.

-> Part 7: Domain Event and Anti-Corruption Layer


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD Part 5 — Aggregate and Repository
Next Post
DDD Part 7 — Domain Event and Anti-Corruption Layer