Domain Service vs Application Service: Defining DDD Boundaries via Salary Calculation
Using a real‑world salary‑calculation module, the article shows how to separate Domain Services, Application Services, and Infrastructure in DDD, explains the responsibilities of each layer, provides concrete code refactorings, and offers practical questions to decide where new logic belongs.
Why the confusion between Domain Service and Application Service?
In a previous post I explained Aggregate Roots. A reader asked why the terms "Domain Service" and "Application Service" are often mixed up in projects. This is the most common problem after Aggregate Roots, and it is also the easiest to get wrong.
Last year I helped a team refactor a salary‑calculation module. Their SalaryCalculationService class had more than four hundred lines, mixing HTTP calls to an attendance system, JPA persistence, and the actual overtime‑pay rules. The class was a classic "junk drawer" – it did everything.
When I asked the team whether a particular method was business logic, the answer was always "yes". Consequently every piece of code was considered business logic and packed into a single class, defeating DDD at this layer.
This article draws a clear boundary line: what belongs in a Domain Service, what belongs in an Application Service, and what should never appear in the service layer at all.
Typical "junk‑drawer" code
The salary‑calculation scenario is roughly:
Fetch the employee's aggregate ( Payee)
Call the attendance bounded context for the month’s summary
Apply company rules to compute base salary, overtime, deductions, social insurance, and individual tax
Assemble a PaySlip aggregate
Persist and publish domain events
First version of the code (anti‑pattern):
// anti‑example: one class does everything
@Service
public class SalaryCalculationService {
@Autowired private PayeeJpaRepository payeeJpaRepository;
@Autowired private PaySlipJpaRepository paySlipJpaRepository;
@Autowired private RestTemplate restTemplate;
@Autowired private ApplicationEventPublisher eventPublisher;
@Autowired private RedisTemplate<String, String> redisTemplate;
@Transactional
public PaySlip calculate(String employeeId, String cycleValue) {
// direct JPA repository usage
PayeePO payeePO = payeeJpaRepository.findByEmployeeId(employeeId);
// hard‑coded HTTP call
String url = "http://attendance-service/api/summary?empId=" + employeeId + "&cycle=" + cycleValue;
AttendanceDTO attendance = restTemplate.getForObject(url, AttendanceDTO.class);
// overtime calculation
BigDecimal overtimePay = BigDecimal.ZERO;
if (attendance.getWeekdayOvertimeHours() > 0) {
overtimePay = payeePO.getHourlySalary()
.multiply(BigDecimal.valueOf(1.5))
.multiply(BigDecimal.valueOf(attendance.getWeekdayOvertimeHours()));
}
if (attendance.getWeekendOvertimeHours() > 0) {
overtimePay = overtimePay.add(payeePO.getHourlySalary()
.multiply(BigDecimal.valueOf(2.0))
.multiply(BigDecimal.valueOf(attendance.getWeekendOvertimeHours())));
}
// absence deduction
BigDecimal deduction = payeePO.getDailySalary()
.multiply(BigDecimal.valueOf(attendance.getAbsentDays()));
// tax – hard‑coded brackets
BigDecimal gross = payeePO.getBaseSalary().add(overtimePay).subtract(deduction);
BigDecimal tax;
if (gross.compareTo(BigDecimal.valueOf(5000)) <= 0) {
tax = BigDecimal.ZERO;
} else if (gross.compareTo(BigDecimal.valueOf(8000)) <= 0) {
tax = gross.subtract(BigDecimal.valueOf(5000))
.multiply(BigDecimal.valueOf(0.03));
} else {
tax = gross.subtract(BigDecimal.valueOf(5000))
.multiply(BigDecimal.valueOf(0.10))
.subtract(BigDecimal.valueOf(210));
}
// assemble PO and persist
PaySlipPO paySlipPO = new PaySlipPO();
paySlipPO.setEmployeeId(employeeId);
paySlipPO.setGrossAmount(gross);
paySlipPO.setTaxAmount(tax);
paySlipPO.setNetAmount(gross.subtract(tax));
paySlipJpaRepository.save(paySlipPO);
// cache the result
redisTemplate.opsForValue().set("payslip:" + employeeId + ":" + cycleValue, paySlipPO.toString());
// publish event
eventPublisher.publishEvent(new PaySlipCalculated(...));
return convertToDomain(paySlipPO);
}
}This code runs, but it is impossible to change because business rules, workflow orchestration, and technical details are tangled together.
Changing overtime rules requires digging into SalaryCalculationService while being careful not to break the HTTP call.
Switching the attendance system protocol also forces a change in the same class.
Moving to a sharded database still means editing this class.
Writing unit tests forces you to mock JPA, RestTemplate, Redis, and the event publisher – the test cost exceeds the change cost.
Three boundary lines – what each layer "guards"
Domain Service : only performs business‑rule calculations and decisions. It knows nothing about where data comes from, where results go, or whether it runs inside a Spring transaction.
Application Service : responsible for workflow orchestration. It decides the order of steps, defines transaction boundaries, and wires everything together, but never contains business rules.
Infrastructure : handles all dirty work – database access, HTTP calls, message queues, caches, clocks – i.e., any interaction with the outside world.
To decide where a piece of code belongs, I use a three‑question self‑check:
If you change the database or RPC protocol, does the code need to change? → Infrastructure
Do finance or HR people argue about the rule? → Domain Service
Is the code merely deciding "first fetch data, then calculate, then store"? → Application Service
Applying the checklist to the anti‑pattern:
Overtime‑pay formula is defined by finance → Domain Service.
The hard‑coded HTTP URL belongs to Infrastructure.
The "fetch → calculate → store" sequence belongs to Application Service.
Domain Service – only the rules, no outside concerns
A common misconception is that every piece of business logic must be a Domain Service. If a behavior can live inside an aggregate root, it should stay there. For example, PaySlip.addLineItem(...) belongs to the PaySlip aggregate.
Domain Services are the home for rules that cannot fit into a single aggregate, typically because they involve multiple aggregates or are stateless algorithms.
Rule that needs several aggregates (e.g., overtime calculation needs both Payee and attendance summary).
Stateless algorithm (e.g., individual tax calculation that takes gross income and returns tax).
Refactored overtime‑pay calculator:
// Overtime pay: multi‑aggregate business algorithm
@Component
public class OvertimePayCalculator {
/**
* Calculate overtime pay for a given Payee and AttendanceSummary.
* No @Autowired repositories or RestTemplate here.
*/
public Money calculate(Payee payee, AttendanceSummary attendance) {
Money weekdayPay = payee.getHourlySalary()
.multiply(BigDecimal.valueOf(1.5))
.multiply(attendance.getWeekdayOvertimeHours());
Money weekendPay = payee.getHourlySalary()
.multiply(BigDecimal.valueOf(2.0))
.multiply(attendance.getWeekendOvertimeHours());
return weekdayPay.add(weekendPay);
}
}Refactored individual‑tax calculator:
// Tax rule: a stateless algorithm
@Component
public class IndividualTaxCalculator {
private static final Money THRESHOLD = Money.ofYuan("5000");
private static final List<TaxBracket> BRACKETS = List.of(
new TaxBracket(Money.ofYuan("3000"), new BigDecimal("0.03"), Money.ZERO),
new TaxBracket(Money.ofYuan("12000"), new BigDecimal("0.10"), Money.ofYuan("210")),
new TaxBracket(Money.ofYuan("25000"), new BigDecimal("0.20"), Money.ofYuan("1410"))
// ...
);
public Money calculate(Money taxableIncome) {
if (taxableIncome.compareTo(THRESHOLD) <= 0) {
return Money.ZERO;
}
Money base = taxableIncome.subtract(THRESHOLD);
TaxBracket bracket = matchBracket(base);
return base.multiply(bracket.getRate()).subtract(bracket.getQuickDeduction());
}
private TaxBracket matchBracket(Money base) {
return BRACKETS.stream()
.filter(b -> base.compareTo(b.getCeiling()) <= 0)
.findFirst()
.orElse(BRACKETS.get(BRACKETS.size() - 1));
}
}Core salary‑calculation domain service that composes the two rules:
// Core business rules for salary calculation
@Component
public class SalaryCalculationDomainService {
private final OvertimePayCalculator overtimePayCalculator;
private final IndividualTaxCalculator taxCalculator;
public SalaryCalculationDomainService(OvertimePayCalculator overtimePayCalculator,
IndividualTaxCalculator taxCalculator) {
this.overtimePayCalculator = overtimePayCalculator;
this.taxCalculator = taxCalculator;
}
/**
* Produce a PaySlip draft given a Payee, a PayCycle and an AttendanceSummary.
* No repository, no HTTP, no event publishing – pure domain logic.
*/
public PaySlip calculate(Payee payee, PayCycle cycle, AttendanceSummary attendance) {
PaySlip paySlip = PaySlip.createDraft(payee.getEmployeeId(), cycle);
paySlip.addLineItem(LineItemType.BASE_SALARY, payee.getBaseSalary());
Money overtimePay = overtimePayCalculator.calculate(payee, attendance);
if (overtimePay.isPositive()) {
paySlip.addLineItem(LineItemType.OVERTIME_PAY, overtimePay);
}
Money deduction = payee.getDailySalary().multiply(attendance.getAbsentDays());
if (deduction.isPositive()) {
paySlip.addLineItem(LineItemType.ATTENDANCE_DEDUCTION, deduction.negate());
}
Money socialInsurance = payee.getSocialInsuranceDeduction();
paySlip.addLineItem(LineItemType.SOCIAL_INSURANCE, socialInsurance.negate());
Money taxableIncome = paySlip.getTotalAmount();
Money tax = taxCalculator.calculate(taxableIncome);
paySlip.addLineItem(LineItemType.INDIVIDUAL_TAX, tax.negate());
paySlip.markAsCalculated();
return paySlip;
}
}Benefits of this separation:
Testing becomes cheap : a unit test only needs to construct a Payee and an AttendanceSummary, then instantiate new SalaryCalculationDomainService(...). Changing the overtime multiplier from 1.5 to 2.0 is a single line change and can be verified without any Spring context, database, or external service.
Rules and technical details are fully decoupled : finance changes the overtime rule inside the Domain Service; switching the attendance system from HTTP to gRPC only requires a new adapter, leaving the Domain Service untouched.
Application Service – orchestrating the use case and defining the transaction
The Application Service receives the Payee and AttendanceSummary prepared by the infrastructure, delegates the calculation to the Domain Service, and then persists the result and publishes events.
@Service
public class SalaryCalculationAppService {
private final PayeeRepository payeeRepository;
private final PaySlipRepository paySlipRepository;
private final AttendanceFacade attendanceFacade;
private final SalaryCalculationDomainService domainService;
public SalaryCalculationAppService(PayeeRepository payeeRepository,
PaySlipRepository paySlipRepository,
AttendanceFacade attendanceFacade,
SalaryCalculationDomainService domainService) {
this.payeeRepository = payeeRepository;
this.paySlipRepository = paySlipRepository;
this.attendanceFacade = attendanceFacade;
this.domainService = domainService;
}
/**
* One complete use case: calculate a monthly payslip.
* Transaction boundary is drawn here.
*/
@Transactional
public PaySlipId calculateMonthlyPaySlip(EmployeeId employeeId, PayCycle cycle) {
// 1. fetch data
Payee payee = payeeRepository.findByEmployeeId(employeeId)
.orElseThrow(() -> new PayeeNotFoundException(employeeId));
AttendanceSummary attendance = attendanceFacade.queryMonthlySummary(employeeId, cycle);
// 2. delegate to domain service
PaySlip paySlip = domainService.calculate(payee, cycle, attendance);
// 3. persist (event handling is inside the aggregate)
paySlipRepository.save(paySlip);
return paySlip.getId();
}
}Key differences compared with the anti‑pattern:
Dependency list is halved and consists only of interfaces ( PayeeRepository, PaySlipRepository, AttendanceFacade, SalaryCalculationDomainService).
No direct use of RestTemplate, RedisTemplate, or @Transactional inside the Domain Service.
Transaction is declared only on the Application Service method – "one use case = one application‑service method = one transaction".
Method body consists of three clear steps: fetch → calculate → store.
Infrastructure – technical adapters
All interfaces defined in the domain layer are implemented in the infrastructure layer.
// Domain layer: only defines the contract
public interface PayeeRepository {
Optional<Payee> findByEmployeeId(EmployeeId employeeId);
void save(Payee payee);
} // Infrastructure: JPA implementation
@Repository
public class PayeeRepositoryImpl implements PayeeRepository {
private final PayeeJpaRepository jpaRepository;
public PayeeRepositoryImpl(PayeeJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public Optional<Payee> findByEmployeeId(EmployeeId employeeId) {
return jpaRepository.findByEmployeeId(employeeId.getValue())
.map(PayeePO::toDomain);
}
@Override
public void save(Payee payee) {
jpaRepository.save(PayeePO.from(payee));
}
} // Attendance HTTP adapter (infrastructure)
@Component
public class AttendanceHttpAdapter implements AttendanceFacade {
private final AttendanceHttpClient httpClient;
@Override
public AttendanceSummary queryMonthlySummary(EmployeeId employeeId, PayCycle cycle) {
AttendanceSummaryDTO dto = httpClient.get(employeeId.getValue(), cycle.getValue());
return AttendanceSummary.builder()
.weekdayOvertimeHours(dto.getWeekdayOvertimeHours())
.weekendOvertimeHours(dto.getWeekendOvertimeHours())
.absentDays(dto.getAbsentDays())
.build();
}
}This demonstrates the Dependency Inversion Principle: the domain layer declares "I need a PayeeRepository and an AttendanceFacade", while the infrastructure layer supplies concrete JPA and HTTP adapters. Changing the storage technology or the communication protocol only requires a new adapter; the domain logic stays untouched.
End‑to‑end flow (textual sequence diagram)
Controller
|
| HTTP: POST /paySlips/calculate { employeeId, cycle }
v
[Application] SalaryCalculationAppService.calculateMonthlyPaySlip
|
|-- 1. payeeRepository.findByEmployeeId(...)
| v
| [Infra] PayeeRepositoryImpl → JPA → DB → Payee domain object
|
|-- 2. attendanceFacade.queryMonthlySummary(...)
| v
| [Infra] AttendanceHttpAdapter → HTTP → Attendance BC → AttendanceSummary
|
|-- 3. domainService.calculate(payee, cycle, attendance)
| v
| [Domain] SalaryCalculationDomainService
| |-- OvertimePayCalculator.calculate(...)
| |-- IndividualTaxCalculator.calculate(...)
| +-- assembles PaySlip aggregate, raises PaySlipCalculated event
|
|-- 4. paySlipRepository.save(paySlip)
| v
| [Infra] PaySlipRepositoryImpl → JPA → DB (writes event to outbox table)
v
Return PaySlipIdEach layer has a clear responsibility, making independent testing straightforward: Domain Services are pure POJOs, Infrastructure components are verified with integration tests, and Application Services can be unit‑tested with mocked repositories and facades.
Practical checklist for placing new code
Method 1 – imagine a technology swap : If changing the DB, RPC, or cache forces a change, the code belongs to Infrastructure; otherwise it belongs to Domain or Application.
Method 2 – ask the business owner : If finance/HR can discuss the rule, it is Domain; if they are indifferent, it is Application or Infrastructure.
Method 3 – look at @Transactional : Transaction annotations should appear only on Application Services. If you find them on Domain Services, the logic is actually orchestration.
Method 4 – examine if statements :
Process‑flow if (e.g., parameter validation) → Application Service.
Business‑rule if (e.g., tax thresholds) → Domain Service.
Technical if (e.g., HTTP response code handling) → Infrastructure.
When a single method mixes all three kinds of if, you have a classic "junk drawer" situation.
Conclusion
Clearly separating Domain Services, Application Services, and Infrastructure eliminates the "mixed‑service" smell, dramatically improves maintainability, and makes testing cheap. After refactoring, the original 400‑line class becomes three tidy domain classes (≈30 lines each), one concise application service (≈50 lines), and two focused infrastructure adapters (≈30 lines each). The next article will dive into the Repository role and how to design its interface properly.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tinker Programmer
Solving problems with code, sharing practical tech insights, and leveling up together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
