DDD Tactical: Domain Services — Tasks¶
What? Eight exercises that walk you from spotting the need for a Domain Service to building one and separating the Application Service that drives it. Each exercise has a brief, the deliverable signatures, and a validation table you can use to check your work. The last exercise is a worked solution you can use to calibrate the others. How? Implement each exercise in a small sandbox project. Compile, write a unit test that exercises only the Domain Service with stub ports, then write a second test that drives it through an Application Service. If a test cannot be written without a Spring container, you have violated the contract and must refactor.
Exercise 1 — Design TransferService¶
Brief. Two Account aggregates, possibly in the same currency. Implement TransferService.transfer(Account from, Account to, Money amount). Throw InsufficientFundsException if from cannot cover the amount. Throw CurrencyMismatchException if the two accounts have different currencies (a separate InternationalTransferService will handle the FX case in Exercise 2).
Required signatures.
public final class TransferService {
public void transfer(Account from, Account to, Money amount);
}
Validation.
| Check | Pass criterion |
|---|---|
| Class has no instance fields | All declared fields are final and ports |
transfer throws on insufficient funds | InsufficientFundsException raised |
transfer throws on currency mismatch | CurrencyMismatchException raised |
After success, from.balance reduced, to.balance increased | Invariants hold |
| Unit test runs without Spring | Pure JUnit + manual new |
Exercise 2 — Design InternationalTransferService with FX¶
Brief. Now the two accounts may have different currencies. Inject an ExchangeRatePolicy domain port. The amount debited from from is in from's currency; the amount credited to to is the converted amount in to's currency. Round the converted amount to the smallest unit of the target currency using banker's rounding (HALF_EVEN).
Required signatures.
public interface ExchangeRatePolicy {
Money convert(Money amount, Currency target);
}
public final class InternationalTransferService {
public InternationalTransferService(ExchangeRatePolicy rates);
public void transfer(Account from, Account to, Money amount);
}
Validation.
| Check | Pass criterion |
|---|---|
Constructor injects ExchangeRatePolicy | private final ExchangeRatePolicy |
Stub ExchangeRatePolicy returns fixed rates in tests | Test uses (amount, target) -> ... lambda |
| Rounding is HALF_EVEN | Test asserts banker's rounding on 0.005 |
| Service has no other state | All fields final and constructor-injected |
Exercise 3 — Design FXRatesService (the policy, not the adapter)¶
Brief. Implement an FXRatesService Domain Service that, given a set of currency pairs, returns a coherent table of rates such that converting via any intermediate currency yields the same result up to rounding. The service depends on a RatesProvider port; the implementation of RatesProvider is infrastructure (out of scope for this exercise).
Required signatures.
public interface RatesProvider {
BigDecimal directRate(Currency from, Currency to);
}
public final class FXRatesService {
public FXRatesService(RatesProvider provider);
public RateTable tableFor(Set<Currency> currencies, Currency base);
}
Validation.
| Check | Pass criterion |
|---|---|
RateTable is an immutable VO | All fields final; no setters |
Triangulation: EUR→USD→GBP ≈ EUR→GBP | Test with stub provider |
| Service is stateless | No mutable fields |
tableFor(emptySet, base) returns empty table, doesn't throw | Edge-case test |
Exercise 4 — PricingPolicy as a polymorphic Domain Service¶
Brief. Introduce a PricingPolicy interface with two implementations: RetailPricingPolicy and WholesalePricingPolicy. Each computes a Money total for a Basket and a Customer. The application service chooses the policy based on customer.segment().
Required signatures.
public interface PricingPolicy {
Money price(Basket basket, Customer customer);
}
public final class RetailPricingPolicy implements PricingPolicy { ... }
public final class WholesalePricingPolicy implements PricingPolicy { ... }
Validation.
| Check | Pass criterion |
|---|---|
| Both implementations are stateless | All fields final |
| Choice happens outside the policy | App service picks the policy implementation |
| Wholesale prices ≤ retail prices for the same basket | Property-based test |
| New segment can be added by writing a third implementation | No edits to existing two classes (OCP) |
Exercise 5 — Separate the Application Service for "Place Order"¶
Brief. You have Order (aggregate), Customer (aggregate), PricingPolicy (Domain Service from Exercise 4), InventoryReservationService (Domain Service that asks an InventoryGateway to reserve stock), and OrderRepository. Write PlaceOrderUseCase — the Application Service — that orchestrates the use case end-to-end with a single @Transactional boundary.
Required behaviour, in order:
- Load the customer.
- Build the order from the command.
- Price the order using the appropriate
PricingPolicy. - Reserve inventory via the Domain Service.
- Persist the order.
- Publish an
OrderPlacedevent.
Required signatures.
public record PlaceOrderCommand(CustomerId customerId, List<LineRequest> lines) {}
public final class PlaceOrderUseCase {
public PlaceOrderUseCase(CustomerRepository customers,
OrderRepository orders,
PricingPolicy pricingPolicy,
InventoryReservationService reservations,
ApplicationEventPublisher events);
@Transactional
public OrderId execute(PlaceOrderCommand cmd);
}
Validation.
| Check | Pass criterion |
|---|---|
@Transactional only on execute | Not on Domain Services |
| Domain Services receive already-loaded aggregates | No repository in Domain Service |
Event published only after orders.save | Order of statements verified by test |
| Use case is unit-testable with stubs | No @SpringBootTest required |
Exercise 6 — Idempotent FundsCaptureService¶
Brief. A payment service called by external webhooks. The same PaymentIntent can arrive twice (broker redelivery). Implement FundsCaptureService.capture(PaymentIntent intent) such that calling it twice with the same intent.idempotencyKey() charges the PSP only once and returns the same CaptureResult.
Required signatures.
public final class FundsCaptureService {
public FundsCaptureService(PaymentRepository payments, PspGateway psp);
public CaptureResult capture(PaymentIntent intent);
}
Validation.
| Check | Pass criterion |
|---|---|
Calling capture(intent) twice charges PSP once | Test verifies stub PSP receives one call |
| Second call returns same result object's data | Equality on CaptureResult |
| Service has no mutable state | All fields final |
Exercise 7 — Refactor a TransferManager god class¶
Brief. You are given the broken TransferManager from middle.md Section 6 (JDBC + SMTP + business rules in one class). Refactor it into:
- A Domain Service
TransferService. - An Application Service
TransferUseCasewith@Transactional. - An Infrastructure adapter
SmtpNotificationAdapterimplementing a domainNotificationPort. - A
JpaAccountRepositoryimplementingAccountRepository.
Validation.
| Check | Pass criterion |
|---|---|
TransferService has zero framework imports | grep "import org.springframework" empty |
TransferUseCase has @Transactional and orchestrates the flow | Sequence: load → transfer → save → notify |
| Each layer is independently unit-testable | Vanilla JUnit for domain & application |
Exercise 8 — Worked solution: RoutingService¶
A complete worked example for calibration.
Brief. Given a graph of Nodes and Edges, find the cheapest route from from to to using a CostPolicy that combines distance and toll.
// Value objects
public record Node(NodeId id, String name) {}
public record Edge(Node from, Node to, Distance distance, Toll toll) {}
public record Route(List<Edge> edges, Money totalCost) {}
// Domain Service (the policy variant)
public interface CostPolicy {
Money cost(Edge edge);
}
public final class DistancePlusTollPolicy implements CostPolicy {
private final Money perKilometer;
public DistancePlusTollPolicy(Money perKilometer) { this.perKilometer = perKilometer; }
@Override
public Money cost(Edge edge) {
return perKilometer.times(edge.distance().kilometers()).plus(edge.toll().amount());
}
}
// Domain Service (the capability)
public final class RoutingService {
private final CostPolicy costs;
public RoutingService(CostPolicy costs) {
this.costs = Objects.requireNonNull(costs);
}
public Route shortestRoute(Graph g, Node from, Node to) {
// Dijkstra over the graph using costs.cost(edge).
Map<Node, Money> best = new HashMap<>();
Map<Node, Edge> prev = new HashMap<>();
PriorityQueue<Map.Entry<Node, Money>> queue = new PriorityQueue<>(
Comparator.comparing(Map.Entry::getValue));
best.put(from, Money.zero(to.id().currency()));
queue.add(Map.entry(from, best.get(from)));
while (!queue.isEmpty()) {
Map.Entry<Node, Money> top = queue.poll();
Node u = top.getKey();
if (u.equals(to)) break;
for (Edge e : g.outgoing(u)) {
Money alt = best.get(u).plus(costs.cost(e));
if (!best.containsKey(e.to()) || alt.lessThan(best.get(e.to()))) {
best.put(e.to(), alt);
prev.put(e.to(), e);
queue.add(Map.entry(e.to(), alt));
}
}
}
if (!best.containsKey(to)) throw new NoRouteException(from, to);
// Reconstruct path
List<Edge> path = new ArrayList<>();
for (Node cur = to; !cur.equals(from); cur = prev.get(cur).from()) {
path.add(prev.get(cur));
}
Collections.reverse(path);
return new Route(path, best.get(to));
}
}
// Application Service that drives it
public final class FindRouteUseCase {
private final GraphRepository graphs;
private final RoutingService router;
public FindRouteUseCase(GraphRepository graphs, RoutingService router) {
this.graphs = graphs;
this.router = router;
}
public Route execute(GraphId graphId, NodeId fromId, NodeId toId) {
Graph g = graphs.findById(graphId).orElseThrow();
Node from = g.node(fromId);
Node to = g.node(toId);
return router.shortestRoute(g, from, to);
}
}
Why this is a valid Domain Service:
- All fields
final; class isfinal; no mutable state. - Name expresses the verb-shaped capability.
- Signatures contain only domain types (
Graph,Node,Route). - No persistence (the graph is passed in, not loaded).
- No transport (no HTTP, no annotations beyond constructor injection).
Unit test:
@Test
void findsCheapestRoute() {
CostPolicy fixed = e -> Money.of(e.distance().kilometers(), USD);
RoutingService svc = new RoutingService(fixed);
Graph g = ...; // hand-built test graph
Route r = svc.shortestRoute(g, nodeA, nodeC);
assertEquals(Money.of(15, USD), r.totalCost());
}
No Spring, no DB, runs in single-digit milliseconds.
Submission checklist (any exercise).
- Domain Services have only
finalfields. - Signatures contain only domain types.
- No
@Transactional, no JDBC, no HTTP in Domain Services. - Application Services orchestrate; Domain Services compute.
- Tests run without Spring containers.
- Names express verbs/capabilities, not data.
Related: ../02-entities/tasks.md, ../03-aggregates/tasks.md, ../04-repository-concept/tasks.md.