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.
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.
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.
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);
}
}
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.