Course outline

Spring Boot / Basic: composition and HTTP contracts / Lesson 6

Application services and domain boundaries

Keep a use case explicit across transport, domain decisions, state changes, and response mapping instead of treating a service annotation as an atomic business operation.

About 5 min readContent reviewed
On this page

Lesson goals

  • Separate a decision from a persisted transition.
  • Define service input, output, and rejection contracts.
  • Test domain behavior without claiming framework or database evidence.

Name the business operation and its boundaries

An application service coordinates one use case: trusted caller scope, validated command, domain decision, persistence boundary, and result. @Service makes a stereotype available for discovery; it does not supply authorization, retries, or transactions by itself. Decide which layer owns each responsibility so a second caller, scheduled task, or import cannot bypass essential rules merely by avoiding the HTTP controller.

Keep domain values and results explicit. A price needs currency and units; a reservation needs product, quantity, and operation identity. A service returning a raw Map or magic negative number can hide which outcomes are success, rejection, or infrastructure failure. Choose a stable result contract and map it to HTTP separately rather than mixing response status into every domain calculation.

Make a provisional policy result unmistakable

The Spring fragment evaluates stock eligibility from a supplied snapshot. It returns a proposed remainder but performs no reservation or persistence. A negative request is rejected as an input/domain violation; insufficient stock is a defined decision result. The fragment requires managed context integration and therefore is not executed. Its @Service annotation does not turn the snapshot into a locked current value.

A caller must not report โ€œreservedโ€ simply because this policy says eligible. Another request may change stock before a later write. A guarded database operation and the complete transaction contract must protect the state transition. Separating the pure decision helps reasoning, but correctness still belongs to the operation that commits shared state under concurrency.

java Illustrative policy with no persisted reservation
package training.catalog;

import org.springframework.stereotype.Service;

record StockDecision(String code, int proposedRemaining) {}

@Service
class StockPolicy {
    StockDecision evaluate(int available, int requested) {
        if (available < 0) throw new IllegalStateException("invalid stock snapshot");
        if (requested <= 0) throw new IllegalArgumentException("quantity");
        return available >= requested
            ? new StockDecision("eligible", available - requested)
            : new StockDecision("insufficient", available);
    }
}

The result is a decision over a snapshot only. Bean discovery and any eventual persistence transition are outside this fragment.

Test the calculation and its limited claim

The complete JDK-only model checks one snapshot of five units against a request for two. It reports eligible and a proposed remainder of three, while the original snapshot remains five. This is actual local calculation evidence and deliberately proves no shared inventory changed. It does not exercise Spring discovery, transaction advice, repository code, or HTTP behavior.

Test zero, negative, exactly available, insufficient, and invalid snapshot values. For a rejected operation, verify required state remains unchanged at the actual mutation boundary too. A domain test cannot establish rollback merely because the method threw. Keep the scope of each test clear and combine it with persistence and framework tests when those boundaries are introduced.

java Dependency-free provisional-decision model
public class Main {
record Decision(String code,int proposedRemaining) {}
static Decision decide(int available,int requested) {
 if(available<0) throw new IllegalStateException("snapshot");
 if(requested<=0) throw new IllegalArgumentException("quantity");
 return available>=requested?new Decision("eligible",available-requested):new Decision("insufficient",available);
}
 public static void main(String[] args) throws Exception {
int available=5;
Decision answer=decide(available,2);
System.out.println(answer.code()+" "+answer.proposedRemaining());
System.out.println("snapshot "+available);
 }
}
Expected output
eligible 3
snapshot 5

The proposed remainder is3 and the supplied snapshot remains5. This is not a persisted reservation.

Distinguish domain rejection from failed infrastructure

A missing product, insufficient stock, forbidden account, and unavailable repository need different application policies. Do not return an empty result or zero price for every exception. Preserve a trusted causal chain in restricted logs and expose a stable public outcome. The transaction owner must decide rollback, and retry must consider whether the complete operation is safe to repeat.

Constructor collaborators should describe capabilities, with adapters handling actual database, clock, or external transport details. An in-memory fake is useful for deterministic tests but may not reproduce query semantics or concurrency. Document whether an adapter returns a snapshot, owned mutable data, or a live entity, and avoid leaking managed persistence state across the transport boundary by accident.

Review the whole use case before adding annotations

Write the sequence and identify every boundary where failure can occur. If stock is changed and saving the order fails, the service must not leave an unexplained partial operation. Database transactions address related database work, while external notifications need a separate coordination design. No local Java exception can recall an already delivered message or prove a remote action never happened.

A useful use-case test set covers accepted input, each expected rejection, failure between effects, replay identity, and cleanup. The next levels introduce persistence, proxies, security, and operational evidence around that contract. Maintainability comes from knowing what each layer promises and which test observes it, rather than adding @Transactional or @Async everywhere and hoping annotations infer the intended business behavior.

Common errors and fixes

An eligible decision was reported as a completed reservation

Why it happens
Snapshot calculation was mistaken for a committed shared-state transition.
How to fix it
Protect and observe the actual mutation boundary.
Prevent it next time
Use distinct decision and completion result names.

An unavailable product source became zero price

Why it happens
Operational failure was converted into plausible domain data.
How to fix it
Preserve failure and map it at the application boundary.
Prevent it next time
Test missing, valid zero where allowed, and failed retrieval separately.

A unit test was presented as rollback proof

Why it happens
Only pure logic was exercised.
How to fix it
Add an appropriately isolated transaction integration test when authorized.
Prevent it next time
Label each test's observed boundary.

Check your understanding

1. Two callers independently evaluate available5 with requested4 and both receive eligible. Is the policy calculation wrong?

Show worked solution

Not necessarily; both evaluated the same supplied snapshot correctly. The missing guarantee is the atomic shared-state transition. Guard the database update and coordinate the full operation rather than assuming repeated pure decisions reserve stock.

2. A service changes stock and then sends an email before saving the order. What failure boundary is unclear?

Show worked solution

The email cannot be rolled back with a later database failure. Define the database atomic unit and coordinate external effects through a reviewed design such as an outbox. Test failure/retry behavior without sending real messages.

Key takeaways

  • A managed service annotation does not define a complete use case.
  • Pure decisions and committed transitions have different evidence.
  • Explicit ownership and outcome contracts make later framework features safer.

Official references