Implementing Business Workflows
UseCase Responsibilities
UseCase Responsibilities
So far, we have built most of the application's foundation:
HTTP API
Domain Model
PostgreSQL Schema
JPA Mapping
Repositories
Flyway Migrations
Pagination
Indexes
Each of these pieces solves an important problem.
But none of them alone represents a complete business operation.
When a Customer calls:
POST /api/v1/orders
it is not enough for the Handler to:
parse the request
and it is not enough for the Repository to:
persist an Order
A complete Order creation workflow must coordinate several responsibilities:
identify the authenticated Customer
reject duplicate Product lines
load current Products
verify Products are orderable
capture current server-side prices
consume Inventory safely
construct Order + OrderItems
persist the Order aggregate
And the local database changes must be:
all-or-nothing
The component that understands this complete operation is the:
UseCase
Our architecture remains:
Handler
↓
UseCase
↓
Repository
When an external system participates:
Handler
↓
UseCase
├── Repository
└── External Service
The goal of this lesson is:
Understand exactly what belongs inside a UseCase, what stays inside the Domain, what belongs behind Repository boundaries, where local transaction boundaries live, and why we avoid a giant internal
OrderService.
What Is a UseCase?
A UseCase represents:
One meaningful application operation.
Examples in our application include:
CreateProductUseCase
UpdateProductUseCase
DeactivateProductUseCase
SetInventoryUseCase
CreateOrderUseCase
GetOrderUseCase
GetOrderHistoryUseCase
CancelOrderUseCase
Later:
PayOrderUseCase
Each class answers one question:
What is the application being asked to do?
For example:
CreateOrderUseCase
means:
Create an Order
It does not mean:
everything related to Orders
That distinction is important.
A UseCase Is an Application Workflow
A UseCase coordinates the pieces required to complete one operation.
For Create Order:
CreateOrderUseCase
├── ProductRepository
├── InventoryRepository
├── Order domain
└── OrderRepository
The UseCase understands the sequence.
Conceptually:
1. obtain authenticated CustomerId
2. reject duplicate ProductIds
3. load authoritative Product state
4. verify every Product exists and is active
5. capture current Product.priceCents
6. construct Order + OrderItems
7. consume Inventory atomically
8. persist Order
9. commit
No individual Domain Entity or Repository should own that entire workflow.
Why Not Put Everything in Order?
Imagine:
order.create(
productRepository,
inventoryRepository,
customerId
);
Now the Domain object knows about:
Repositories
database access
persistence
That is the wrong direction.
Order should understand its own business truth:
valid Order identity
valid OrderItems
initial lifecycle state
legal lifecycle transitions
total calculation
It should not know:
where Products are stored
how Inventory is persisted
who is authenticated
where transactions begin
how PostgreSQL performs atomic updates
Those are application and persistence concerns.
Why Not Put Everything in Repository?
Another bad direction is:
orderRepository.createOrder(
customerId,
requestedItems
);
and inside the Repository:
load Products
check active state
determine price
consume Inventory
construct Order
decide application errors
save everything
Now the persistence layer has become the application layer.
Repository should answer persistence questions such as:
find Product state
persist Product state
consume Inventory atomically
load owned Order
persist Order aggregate
It should not invent the business workflow around those operations.
Why Not Put Everything in Handler?
Bad:
@PostMapping
public ResponseEntity<?> createOrder(...) {
Product product =
productRepository.findById(...);
if (!product.active()) {
...
}
inventoryRepository
.consumeIfAvailable(...);
Order order = ...;
orderRepository.save(order);
...
}
Now the HTTP layer owns:
application orchestration
persistence
transactions
business decisions
The Handler becomes difficult to test and cannot be reused outside HTTP.
Our Handler remains:
transport boundary
not:
business workflow
Responsibility Split
A useful mental model is:
Handler
→ translate transport
UseCase
→ coordinate one application operation
Domain
→ protect local business truth
Repository
→ query and persist application state
External Service
→ communicate with an external system
This separation is the foundation of our application architecture.
Handler Responsibilities
A Handler may:
read path parameters
read query parameters
read request body
trigger transport validation
obtain authenticated identity
convert Request DTO → UseCase input
invoke UseCase
convert result → Response DTO
return the correct success status/header
For:
POST /api/v1/orders
the request may contain:
{
"items": [
{
"productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
"quantity": 2
}
]
}
The Handler does not accept authoritative values for:
CustomerId
unitPriceCents
totalCents
OrderStatus
OrderId
createdAt
Those are server-controlled.
UseCase Responsibilities
A UseCase commonly owns:
operation sequencing
cross-capability coordination
Repository coordination
operation-specific ownership checks
rules requiring several pieces of state
local transaction boundary
invoking Domain behaviour
interpreting expected Repository outcomes
coordinating External Services
when the operation requires them
The code should read like the business operation.
Domain Responsibilities
Domain objects protect rules about their own valid state.
Product
Product protects rules such as:
name must remain valid
priceCents cannot be negative
deactivation changes Product state
Inventory
Inventory protects local quantity invariants such as:
quantity cannot be negative
Its in-memory methods do not replace our database concurrency strategy.
OrderItem
OrderItem protects:
quantity > 0
unitPriceCents >= 0
subtotal uses exact arithmetic
Conceptually:
subtotalCents
=
quantity × unitPriceCents
Order
Order protects:
at least one item
no duplicate Product line
initial status = UNPAID
UNPAID → PAID
UNPAID → CANCELLED
PAID and CANCELLED are terminal
totalCents derives from OrderItems
These rules should remain true regardless of whether the caller is:
REST API
test
scheduled workflow
future internal adapter
Repository Responsibilities
Repository handles persistence concerns such as:
load durable state
persist durable state
execute efficient queries
apply database pagination
apply ownership-scoped queries
perform locking where required
perform concurrency-safe atomic operations
translate persistence representation
The implementation may understand:
JPA
Hibernate
JPQL
SQL
PostgreSQL
locking
The UseCase should not.
Cross-Object Rules Need Workflow Coordination
Consider:
Can this Customer order three units of Product X?
Answering that can depend on:
Product exists?
Product active?
Inventory available?
authenticated Customer?
requested quantity?
No single Domain object owns all those facts.
That is a strong sign that the operation needs:
CreateOrderUseCase
coordination.
Local Invariant vs Workflow Rule
Compare:
OrderItem.quantity > 0
This is intrinsic to OrderItem.
So:
Domain
owns it.
Now compare:
Can three units of this Product
be consumed from current Inventory?
This depends on:
requested Order line
+
current persisted Inventory state
+
concurrent database activity
The UseCase owns the intent.
The Repository/PostgreSQL layer owns the concurrency-safe mechanism.
Create Order — Canonical Workflow
Our v1 workflow is:
authenticated Customer
↓
reject duplicate ProductIds
↓
load current Products
↓
verify existence
↓
verify Products active
↓
capture current priceCents
↓
construct Order + OrderItems
↓
consume Inventory atomically
↓
persist Order aggregate
↓
commit
All local database mutations occur inside one transaction.
Let's examine each responsibility.
1. Obtain the Authenticated Customer
The client does not choose ownership with:
{
"customerId": "customer-123"
}
Customer identity is server-controlled.
Later our security flow will be:
authentication token
↓
Spring Security
↓
authenticated identity
↓
CustomerId
The Handler/security boundary resolves that identity and invokes the UseCase with the resulting:
CustomerId
The Order's ownership comes from trusted authenticated state.
Not from the request body.
2. Reject Duplicate Product Lines
A request could contain:
Product X × 2
Product X × 3
Our contract rejects duplicate ProductIds.
CreateOrderUseCase should detect duplicates:
before Inventory side effects
and before persistence work that could alter durable state.
The Domain Order also protects the same invariant defensively so an invalid Order cannot be constructed through another future code path.
This gives us:
UseCase
→ early application rejection
and:
Domain
→ final invariant protection
3. Load Current Products
The UseCase asks:
ProductRepository
for authoritative current Product state.
It never trusts client-provided:
name
priceCents
active state
The server determines business truth.
For every requested ProductId we need to know:
does Product exist?
is Product active?
what is the current priceCents?
4. Product Must Exist
If a requested ProductId does not exist:
CreateOrderUseCase
→ PRODUCT_NOT_FOUND
This is an application failure.
The Handler later maps it to the canonical HTTP error contract.
The Repository itself simply communicates:
found
or
not found
It does not create HTTP responses.
5. Product Must Be Orderable
For new Order creation, a Product must currently be:
active
If:
active = false
the UseCase rejects creation with:
PRODUCT_NOT_ORDERABLE
The HTTP boundary later maps that application meaning to:
409 Conflict
The UseCase knows:
this operation requires
an active Product
The Product object knows its own:
active state
6. Capture Server-Side Price
Customer Product browse may previously have shown:
{
"priceCents": 10000
}
But that displayed price is not a permanent quote.
During successful Order creation, the UseCase reloads current Product state and copies:
Product.priceCents
into:
OrderItem.unitPriceCents
This gives us historical pricing.
Later Product price changes do not change existing Order totals.
Purchase-Time Price Is Immutable Historical State
Suppose Product price changes:
10000 cents
→
12000 cents
after the Order was created.
Historical OrderItem remains:
unitPriceCents = 10000
because the UseCase captured the authoritative price at Order creation.
The client never supplies this value.
7. Construct the Domain Order
Once the required Product data is available, the UseCase creates:
Order
OrderItems
using Domain construction behaviour.
The application generates:
OrderId
→ UUID
before persistence.
For example conceptually:
OrderId orderId =
OrderId.newId();
The application also determines creation time using the injected:
Clock
Conceptually:
Instant createdAt =
Instant.now(clock);
Then Domain construction produces an Order that is already valid:
known OrderId
known CustomerId
valid OrderItems
status = UNPAID
known createdAt
No Database-Generated Order Identity
There is no flow like:
save Order
↓
database generates numeric ID
↓
return generated identity
Our identity already exists before persistence:
OrderId.newId()
So OrderRepository does not need to return the Order merely to reveal a generated key.
8. Consume Inventory
Our accepted v1 semantics are:
successful Order creation
→ immediately consumes Inventory
There is no:
reservation table
temporary hold
expiry
warehouse allocation
in v1.
But Inventory consumption must be concurrency-safe.
Do Not Use Read → Check → Save
Naive:
load Inventory
check availableQuantity >= requested
decrease Java value
save
is vulnerable to concurrent requests.
Two transactions can both observe the same available quantity before either commits.
So CreateOrderUseCase does not implement concurrency with ordinary entity mutation.
Use the Repository Operation We Already Designed
The UseCase expresses application intent:
boolean consumed =
inventoryRepository
.consumeIfAvailable(
productId,
quantity
);
The persistence adapter implements that operation using the canonical PostgreSQL update:
UPDATE inventory
SET available_quantity =
available_quantity - :quantity
WHERE product_id = :productId
AND available_quantity >= :quantity;
Interpretation:
1 affected row
→ consumed successfully
0 affected rows
→ cannot consume requested quantity
The UseCase translates failure into:
INSUFFICIENT_INVENTORY
It does not know the SQL.
Missing Inventory
If no Inventory row exists for the Product, the conditional update also cannot consume anything.
For Create Order, the application-level outcome remains:
INSUFFICIENT_INVENTORY
There is no separate requirement for a public:
INVENTORY_NOT_FOUND
Customer error.
Deterministic Inventory Consumption Order
For a multi-item Order, the UseCase processes ProductIds in a deterministic UUID order before calling the atomic consume operation.
Conceptually:
requested lines
↓
sort by ProductId UUID
↓
consume Product A
↓
consume Product B
↓
consume Product C
This gives concurrent transactions a consistent lock/update order and reduces avoidable deadlock risk.
The public OrderItem order does not become a business ordering contract because of this internal processing step.
Partial Consumption Is Safe Because of the Transaction
Suppose an Order requests:
Product A × 1
Product B × 2
Product A consumption succeeds.
Product B fails because stock is insufficient.
If both updates are inside the same:
CreateOrderUseCase transaction
the application throws the expected business failure and the transaction rolls back.
Result:
Product A Inventory restored automatically
by transaction rollback
Product B unchanged
no Order persisted
This is why Inventory consumption and Order persistence belong to one local atomic operation.
9. Persist the Order
After all required Inventory operations succeed:
OrderRepository
persists:
Order
OrderItems
using the already-known:
OrderId UUID
and purchase-time:
unitPriceCents
The Repository handles:
JPA entities
composite OrderItem key
Hibernate
SQL
The UseCase works with:
Order
UseCase Owns the Local Transaction
Create Order modifies:
Inventory
Order
OrderItems
Suppose Inventory changes commit, then Order insertion fails.
Without one transaction we could get:
stock consumed
but
no Order
That is invalid business state.
We need:
all local changes succeed
or:
all local changes roll back
Transaction Around the Business Operation
Conceptually:
@Transactional
public CreateOrderResult execute(
CreateOrderCommand command
) {
// reject duplicate ProductIds
// load Products
// verify Product orderability
// build Order from server-side data
// consume Inventory atomically
// persist Order
return result;
}
The important concept is not merely:
@Transactional
The important concept is:
One local business operation defines one atomic local database boundary.
Why the UseCase Owns That Boundary
If:
InventoryRepository
commits independently and:
OrderRepository
commits independently, then Create Order is not atomic.
The UseCase is the layer that understands:
these Repository operations together
represent one business action
Therefore it is the natural transaction owner.
Repositories Participate in the Same Transaction
Inside:
CreateOrderUseCase
we may call:
ProductRepository
InventoryRepository
OrderRepository
Their PostgreSQL/JPA work participates in the surrounding application transaction.
Conceptually:
CreateOrderUseCase
│
│ @Transactional
│
├── ProductRepository
├── InventoryRepository
└── OrderRepository
The transaction follows the workflow.
Transaction Does Not Belong in Domain
Avoid:
@Transactional
public void cancel() {
...
}
inside Order.
Domain does not know about:
Spring
database
transaction manager
order.cancel() only protects Order lifecycle.
Transaction Does Not Belong in Handler
Avoid:
@PostMapping(...)
@Transactional
public ResponseEntity<?> cancelOrder(...) {
...
}
Now the HTTP adapter owns business atomicity.
If another adapter later invokes the same operation, the transaction policy may disappear.
Transaction semantics should remain with the application operation.
Cancel Order
Our canonical cancellation workflow is:
authenticated Customer
↓
load owned Order for update
↓
Order.cancel()
↓
restore Inventory
↓
commit
The operation combines:
Order lifecycle
Customer ownership
Inventory restoration
transaction coordination
No single Domain Entity owns all of those concerns.
Ownership During Cancellation
The Customer may cancel only their own Order.
Persistence can support this using an ownership-scoped lookup:
OrderId
+
CustomerId
and return:
Order
only if it belongs to that Customer.
This also supports our privacy contract:
missing Order
and
non-owned Order
→ ORDER_NOT_FOUND
The UseCase coordinates the ownership-sensitive operation.
Order Owns Cancellation Legality
Once the correct Order is loaded:
order.cancel();
protects the lifecycle.
Allowed:
UNPAID → CANCELLED
Rejected:
PAID → CANCELLED
CANCELLED → CANCELLED
The UseCase should not duplicate the transition rule with:
if (
order.status()
== OrderStatus.UNPAID
) {
...
}
and arbitrary status mutation.
The Domain knows its own legal state transitions.
Inventory Restoration Is Workflow Coordination
Cancellation must also restore each OrderItem quantity.
That affects:
Order
+
Inventory
so the UseCase coordinates the operation.
Order does not call:
InventoryRepository
and does not manipulate other aggregates directly.
Cancellation Transaction
These two effects must succeed together:
Order status
→ CANCELLED
Inventory
→ restored
Otherwise we could get:
Order cancelled
Inventory not restored
or:
Inventory restored
Order still UNPAID
Therefore:
CancelOrderUseCase
owns one local PostgreSQL transaction.
Cancellation Locking
Our accepted persistence strategy uses an ownership-scoped Order lookup that can lock the target Order row for update.
Conceptually:
findOwnedForUpdate(
orderId,
customerId
)
This helps serialize competing lifecycle operations such as:
cancel
pay completion
The UseCase does not know how JPA/PostgreSQL implements the lock.
It simply requires the Repository semantics.
Restore Inventory in Deterministic Order
Cancellation restores Inventory for all OrderItems.
Just as creation consumes in deterministic ProductId order, restoration should follow deterministic ProductId order as well.
This keeps multi-row database mutation order predictable.
Missing Inventory During Cancellation
Cancellation is different from Order creation.
A successful Order creation previously consumed Inventory.
If that required Inventory row later disappears unexpectedly, cancellation should not silently recreate it.
That is:
system / integrity failure
not:
ordinary Customer business conflict
Preserving failure meaning matters.
A UseCase Should Read Like the Operation
A well-designed Cancel Order UseCase should read approximately like:
@Transactional
public CancelOrderResult execute(
CancelOrderCommand command
) {
Order order =
orderRepository
.findOwnedForUpdate(
command.orderId(),
command.customerId()
)
.orElseThrow(
OrderNotFoundException::new
);
order.cancel();
restoreInventory(
order.items()
);
orderRepository.save(
order
);
return toResult(
order
);
}
The exact implementation may evolve.
Notice what is absent:
HTTP status codes
JSON
JPA entities
EntityManager
SQL
PostgreSQL locking syntax
The code expresses the business operation.
UseCase Input
A UseCase does not have to depend directly on an HTTP Request DTO.
Transport:
public record CreateOrderRequest(
List<CreateOrderItemRequest> items
) {
}
Application input may be:
public record CreateOrderCommand(
CustomerId customerId,
List<CreateOrderItemCommand> items
) {
}
The Handler is responsible for converting:
HTTP representation
→ application input
This prevents transport concerns from defining the application API.
Do Not Create Command Objects for Ceremony
If a UseCase needs only:
ProductId
this is fine:
execute(
ProductId productId
);
There is no need to create:
DeactivateProductCommand
if it adds no meaning.
Use command/result types when they clarify the operation.
Not because every method needs its own class.
UseCase Output
A UseCase may return:
Product
Order
OrderSummary
PageResult<OrderSummary>
operation-specific result
depending on the application need.
It should not return:
ResponseEntity
HttpStatus
ApiProblem
because those are transport concerns.
Application Errors
UseCases may expose application failures such as:
PRODUCT_NOT_FOUND
PRODUCT_NOT_ORDERABLE
INSUFFICIENT_INVENTORY
ORDER_NOT_FOUND
ORDER_NOT_CANCELLABLE
ORDER_NOT_PAYABLE
These describe application meaning.
The HTTP boundary later converts them into:
status
Problem Details type
title
detail
code
Business Failure vs System Failure
Insufficient Inventory is expected:
business conflict
A PostgreSQL connection failure is not.
Do not transform:
database unavailable
into:
INSUFFICIENT_INVENTORY
just because the failure occurred during Inventory access.
Failure semantics should remain accurate.
Do Not Catch Everything
Bad:
try {
...
} catch (Exception exception) {
throw new OrderCreationFailedException();
}
Now:
Product missing
Product inactive
Inventory insufficient
database outage
mapping bug
could all become one indistinguishable error.
Only translate failures when a specific boundary gives them a different intentional application meaning.
One UseCase Per Meaningful Operation
Avoid a giant:
OrderService
containing:
createOrder()
getOrder()
listOrders()
cancelOrder()
payOrder()
validateOrder()
calculateTotal()
...
That class gradually becomes:
everything related to Orders
and accumulates:
too many dependencies
different transaction needs
different security rules
different reasons to change
Prefer Operation-Oriented UseCases
Instead:
CreateOrderUseCase
GetOrderUseCase
GetOrderHistoryUseCase
CancelOrderUseCase
PayOrderUseCase
Each has:
focused dependencies
focused input/output
focused tests
clear transaction semantics
This is easier to reason about.
Do Not Split Every Internal Step Into a UseCase
The opposite extreme is also poor design.
Avoid:
ValidateCustomerUseCase
LoadProductUseCase
ValidateProductUseCase
LoadInventoryUseCase
CheckInventoryUseCase
CalculateOrderTotalUseCase
SaveOrderUseCase
just so:
CreateOrderUseCase
can call all of them.
A UseCase represents:
one meaningful application operation
not:
one method for every line of orchestration
Private methods and Domain methods are usually enough for internal substeps.
Avoid UseCase-to-UseCase Chains by Default
For example:
CreateOrderUseCase
→ GetProductUseCase
is usually unnecessary.
Both represent application operations.
Create Order can use:
ProductRepository
directly for the state it needs.
Otherwise endpoint-oriented operations become unnecessarily coupled to each other.
Shared Intrinsic Rules Belong in Domain
If multiple UseCases need:
Product price cannot be negative
that belongs inside:
Product
not:
ValidateProductUseCase
Domain is where reusable intrinsic business behaviour belongs.
Repositories Are Shared Boundaries
Multiple operations may use:
OrderRepository
For example:
GetOrderUseCase
↓
OrderRepository
CancelOrderUseCase
↓
OrderRepository
PayOrderUseCase
↓
OrderRepository
That is expected.
The persistence capability is reusable even though the operations remain separate.
UseCases Must Be Stateless
Spring application components are normally singleton Beans.
Bad:
@Component
public class CreateOrderUseCase {
private CustomerId currentCustomer;
private List<Product> currentProducts;
}
Concurrent requests could overwrite each other's state.
Request-specific state belongs in:
method arguments
local variables
Dependencies may be fields.
Request data should not.
Constructor Injection
Dependencies remain explicit:
@Component
public class CancelOrderUseCase {
private final OrderRepository
orderRepository;
private final InventoryRepository
inventoryRepository;
public CancelOrderUseCase(
OrderRepository orderRepository,
InventoryRepository inventoryRepository
) {
this.orderRepository =
orderRepository;
this.inventoryRepository =
inventoryRepository;
}
}
This immediately tells us what infrastructure the operation needs.
It also keeps plain unit testing straightforward.
Inject Only Real Dependencies
Do not inject everything "just in case."
For Create Order, real dependencies may include:
ProductRepository
InventoryRepository
OrderRepository
Clock
because:
Product state
Inventory mutation
Order persistence
Order.createdAt
are part of the operation.
It does not need:
PaymentService
because payment is a separate operation:
POST /api/v1/orders/{orderId}/pay
It also does not need:
EntityManager
ObjectMapper
ApplicationContext
for application orchestration.
UseCase and Time
Order creation genuinely needs current time.
Our canonical design therefore injects:
Clock
and computes:
Instant createdAt =
Instant.now(clock);
This gives us:
deterministic tests
one application-owned creation timestamp
no database CURRENT_TIMESTAMP default
The time source exists because the operation genuinely needs controlled time semantics.
UseCase Should Not Read Environment Variables
Bad:
String paymentUrl =
System.getenv(
"PAYMENT_URL"
);
inside a UseCase.
Infrastructure configuration belongs outside application workflow.
Later PayOrderUseCase sees:
PaymentService
not:
provider URL
API key
HTTP timeout
UseCase Should Not Know JPA
Avoid inside UseCases:
entityManager.find(...);
entityManager.lock(...);
repository.flush();
entityManager.createNativeQuery(...);
If correctness requires:
row lock
atomic conditional UPDATE
special query
the application Repository exposes a meaningful operation and the persistence adapter implements the mechanics.
Example — Concurrency-Safe Inventory Intent
Application:
boolean consumeIfAvailable(
ProductId productId,
int quantity
);
UseCase meaning:
consume this quantity
if current Inventory allows it
Persistence meaning:
perform the concurrency-safe
PostgreSQL operation
This keeps SQL out of application orchestration.
A UseCase May Coordinate Several Repositories
That is not automatically a design smell.
CreateOrderUseCase legitimately needs:
ProductRepository
InventoryRepository
OrderRepository
because Create Order crosses:
Product
Inventory
Order
capabilities.
Trying to hide all three behind:
OrderCreationRepository
would reduce constructor parameters but obscure responsibilities.
Cross-Capability Coordination Is the UseCase's Job
Our architecture is a:
modular monolith
Product, Inventory, and Order have clear capability boundaries.
But they still run:
inside one JVM
inside one deployable application
against one PostgreSQL database
So CreateOrderUseCase can coordinate them in-process.
We do not need:
Product HTTP call
Inventory HTTP call
Kafka event
distributed transaction
inside our own application.
That would introduce distributed-system complexity without a requirement.
A UseCase Is Not a Microservice
CreateOrderUseCase is:
application logic
inside our modular monolith.
It is not:
a separately deployed service
Capability boundaries improve code ownership and architecture.
They do not require network boundaries.
No Generic Internal Service Layer
Many Spring tutorials use:
Controller
→ Service
→ Repository
Our terminology is more specific:
Handler
→ UseCase
→ Repository
because:
UseCase
communicates:
one application operation
more precisely.
We reserve:
Service
for external integrations such as:
PaymentService
Why Service Is Reserved for External Integrations
Later:
PayOrderUseCase
may coordinate:
OrderRepository
PaymentService
That immediately communicates:
Repository
→ local persistence
Service
→ external capability
We avoid ambiguous classes such as:
OrderService
ProductService
InventoryService
for internal workflows.
External Calls Change Transaction Design
Future Pay Order flow conceptually includes:
load owned Order
verify payability
call PaymentService
handle provider outcome
re-check current persisted Order
markPaid()
We cannot make:
PostgreSQL
+
remote Payment provider
one atomic database transaction.
If the provider succeeds and our local write later fails, we have a cross-system consistency problem.
That requires mechanisms such as:
provider idempotency
provider references
retry/reconciliation
failure recovery
not one enormous @Transactional method.
Do Not Hold a DB Transaction Across Payment
Avoid:
BEGIN TRANSACTION
lock Order
call Payment provider
wait on network
mark PAID
COMMIT
This can hold:
database connection
row lock
transaction resources
during unpredictable external I/O.
UseCase owns the workflow.
But local transaction design must respect external system boundaries.
UseCase and Authorization
Not every authorization rule belongs entirely in a security filter.
Endpoint-level access can be handled by Spring Security.
But operation-specific ownership often needs application context.
Example:
Customer may cancel
only their own Order
This requires:
authenticated CustomerId
target Order
and naturally belongs in the application operation.
Role vs Ownership
Role answers:
Is this type of caller
allowed to perform this action?
For example:
CUSTOMER
ADMIN
Ownership answers:
Does this particular Customer
own this particular Order?
Those are different questions.
The Security module will define the exact boundary later.
Do Not Put Caller Roles Into Order
Avoid:
order.cancel(
currentUserRole
);
Order does not care whether the caller is:
CUSTOMER
ADMIN
unless role itself changes an intrinsic lifecycle rule.
Order asks:
Can an Order in this state
be cancelled?
UseCase/security asks:
May this caller attempt
this operation?
UseCase Does Not Build Problem Details
UseCase should not create:
{
"type": "...",
"status": 409,
"instance": "..."
}
It produces application meaning:
InsufficientInventory
OrderNotCancellable
ProductNotFound
The centralized HTTP error boundary converts that into our canonical RFC 7807-style response:
type
title
status
detail
instance
code
errors
UseCase Unit Testing
UseCases are excellent unit-test targets because their dependencies are explicit.
For:
CancelOrderUseCase
we can test with Repository doubles:
own UNPAID Order
→ cancellation succeeds
PAID Order
→ ORDER_NOT_CANCELLABLE
CANCELLED Order
→ ORDER_NOT_CANCELLABLE
missing/non-owned Order
→ ORDER_NOT_FOUND
successful cancellation
→ Inventory restoration requested
No HTTP server is needed.
No PostgreSQL is needed when testing orchestration.
Test Behaviour, Not Trivia
Avoid tests whose main concern is:
repository.findById()
was called exactly once
unless interaction count genuinely affects correctness.
Prefer assertions about:
returned result
Domain transition
persistence intent
business error
Inventory effect
Tests should survive harmless refactoring.
Repository Tests Cover Persistence
Separate PostgreSQL integration tests prove:
OrderRepository persists Order
JPA mapping is correct
constraints work
ownership queries work
Inventory atomic consumption works
locking works
The UseCase unit test does not need to reproduce those infrastructure mechanics.
Handler Tests Cover Transport
API/Handler tests verify:
request parsing
request validation
HTTP status
Location header
Problem response
DTO mapping
authentication integration
Each layer proves its own responsibility.
UseCase Method Naming
A focused UseCase can simply expose:
execute(...)
because the class name already communicates the operation:
createOrderUseCase.execute(...);
cancelOrderUseCase.execute(...);
That is concise and clear.
Consistency matters more than repeating the class name in the method.
UseCase Size
There is no meaningful rule such as:
UseCase must be below 50 lines
A UseCase should be:
large enough to express
one operation clearly
small enough that unrelated
operations do not accumulate
If Create Order benefits from private methods such as:
rejectDuplicateProductIds()
loadProducts()
buildOrder()
consumeInventory()
that is fine.
Do not split code purely for line-count aesthetics.
Private Method vs New Component
Suppose this is used only inside CreateOrderUseCase:
private void ensureOrderable(
Product product
) {
...
}
It can remain private.
Do not immediately create:
ProductOrderabilityValidator
ProductEligibilityManager
OrderingPolicyService
unless a genuinely reusable business concept emerges.
Avoid Manager, Helper, Processor
Vague classes often appear when responsibility is unclear:
OrderManager
OrderHelper
OrderProcessor
OrderUtils
Ask instead:
Is this one application operation?
Is this intrinsic Domain behaviour?
Is this persistence?
Is this external integration?
Clear boundaries usually produce clearer names.
UseCase and Domain Work Together
A healthy workflow often reads:
UseCase
→ asks Repository for state
→ asks Domain to perform behaviour
→ persists resulting state
For cancellation:
order.cancel();
is better than:
if (
order.status()
== OrderStatus.UNPAID
) {
order.setStatus(
OrderStatus.CANCELLED
);
}
The UseCase coordinates.
The Domain protects its own transition.
"Tell, Don't Ask" Without Extremism
We avoid an anemic Domain where:
UseCase reads every field
UseCase decides every local rule
UseCase assigns every state directly
But we also do not force cross-resource rules into individual Domain Entities.
Healthy split:
UseCase
→ coordinates
Domain
→ protects local truth
Example — Order.totalCents()
The UseCase does not need to repeatedly implement:
sum(
quantity × unitPriceCents
)
if:
order.totalCents();
already represents that Domain behaviour.
Likewise:
orderItem.subtotalCents();
belongs naturally with OrderItem.
Exact arithmetic remains part of that Domain implementation.
Inventory Shows Why Both Layers Matter
Domain Inventory can protect an in-memory invariant such as:
quantity cannot become negative
But concurrent Order creation requires:
PostgreSQL atomic update
So all three layers participate:
Domain
→ local invariant
Repository/PostgreSQL
→ concurrency correctness
UseCase
→ workflow coordination
No single layer replaces the others.
The UseCase Does Not Replace Domain
If every rule moves into UseCases:
Order becomes data
Product becomes data
Inventory becomes data
and application code fills with:
if status == ...
if price < ...
if quantity ...
That recreates the anemic Domain problem.
Domain Does Not Replace UseCase
Likewise, Domain Entities should not:
load other aggregates
call Repositories
inspect authenticated user
open transactions
call external providers
Those are application workflow concerns.
We need both layers because they solve different kinds of reasoning.
Responsibility Test 1
Ask:
Can this rule be decided using only this object's own valid state?
If yes, it likely belongs in Domain.
Example:
Can this PAID Order be cancelled?
Order knows.
Responsibility Test 2
If the rule requires:
authenticated Customer
another aggregate
current database state
concurrent persistence state
external provider
it probably requires UseCase coordination.
Responsibility Test 3
Ask:
Is this about how data is stored or fetched?
If yes:
Repository / persistence
Examples:
JPQL
SQL
locking
pagination
JPA mapping
native conditional UPDATE
Responsibility Test 4
Ask:
Is this about HTTP representation?
If yes:
Handler / transport
Examples:
Request DTO
Response DTO
HTTP status
Location header
Problem Details
Responsibility Test 5
Ask:
Is this an external capability?
If yes:
Service
Example:
PaymentService
UseCase coordinates it when the operation requires it.
Create Order Responsibility Map
Handler
POST /api/v1/orders
validate request shape
obtain authenticated CustomerId
convert request → application input
invoke CreateOrderUseCase
map Order result → response
return 201 Created
set Location:
/api/v1/orders/{orderId}
CreateOrderUseCase
reject duplicate ProductIds
load authoritative Product state
reject missing Products
reject inactive Products
capture current priceCents
generate OrderId
generate createdAt using Clock
construct Order + OrderItems
process ProductIds
in deterministic UUID order
consume Inventory atomically
persist Order aggregate
own local transaction
Product
protect Product state
name invariant
priceCents invariant
active state
Inventory
represent available quantity
protect local quantity invariant
Concurrent consumption itself remains a Repository/PostgreSQL concern.
Order
valid OrderItems
no duplicate Product line
starts UNPAID
derive totalCents
protect lifecycle transitions
Repositories
load Product state
perform atomic Inventory consumption
persist Order + OrderItems
implement JPA/PostgreSQL mechanics
PostgreSQL
durability
constraints
local transaction
atomic conditional update
locking primitives
This is the architecture working as one system.
Cancel Order Responsibility Map
Handler
read OrderId
obtain authenticated CustomerId
invoke CancelOrderUseCase
map result/error
CancelOrderUseCase
load owned Order for update
invoke Order.cancel()
restore Inventory
in deterministic ProductId order
persist resulting state
own local transaction
Order
UNPAID → CANCELLED
reject PAID cancellation
reject repeated cancellation
Inventory Repository
restore quantity atomically
detect unexpected missing Inventory
Pay Order Preview
Later:
PayOrderUseCase
will coordinate:
load owned Order
verify current payability
call PaymentService
interpret payment outcome
reload/re-check persisted Order
Order.markPaid()
persist state
Notice:
PaymentService
appears because Payment is external.
We do not rename the workflow:
OrderService
just because it coordinates multiple dependencies.
Common Mistake — Giant OrderService
One class becomes responsible for every Order operation and accumulates unrelated dependencies.
Prefer operation-focused UseCases.
Common Mistake — One UseCase per Internal Step
This creates ceremony and UseCase-to-UseCase chains.
A UseCase represents a meaningful application operation.
Common Mistake — Business Logic in Handler
Transport should not own workflow orchestration.
Common Mistake — Repository Becomes Business Layer
Persistence should not decide Product orderability, Order lifecycle, or Customer ownership policy.
Common Mistake — Domain Calls Repository
Domain should remain persistence-independent.
Common Mistake — UseCase Uses JPA Entities
UseCases speak in:
Domain objects
application input/results
not:
ProductEntity
OrderEntity
Pageable
Common Mistake — UseCase Uses EntityManager
Database mechanics stay behind Repository boundaries.
Common Mistake — One Transaction per Repository Call
Multi-Repository business operations lose atomicity.
The transaction follows the UseCase.
Common Mistake — Remote Call Inside Long DB Transaction
External systems cannot become part of a PostgreSQL transaction merely because the method has @Transactional.
Common Mistake — UseCase Builds HTTP Errors
Application failure meaning and HTTP representation remain separate.
Common Mistake — Request State Stored in Fields
UseCases are singleton components and must remain stateless.
Common Mistake — Trusting Client-Controlled Business State
The server derives:
CustomerId
OrderId
priceCents
unitPriceCents
totalCents
status
createdAt
from trusted application state.
Common Mistake — Service for Every Internal Class
Our terminology remains:
Handler
→ transport
UseCase
→ application workflow
Repository
→ persistence
Service
→ external integration
Common Mistake — UseCase-to-UseCase Dependencies by Default
Operations should generally share:
Domain behaviour
Repository abstractions
rather than call each other merely for internal reuse.
Common Mistake — Cross-Aggregate Rule Forced Into One Entity
Rules requiring:
Product
Inventory
Customer
belong to workflow coordination rather than pretending one Entity owns all of them.
UseCase Review Checklist
For every UseCase, ask:
What one application operation
does this represent?
What input does it really need?
Which persisted state
must be loaded?
Which Repositories
does the operation require?
Which rules belong
inside Domain objects?
Does the operation cross
multiple capabilities?
Does it require one local
database transaction?
Is ownership enforced
at the correct boundary?
Is server-controlled data
derived rather than trusted?
Is JPA/SQL leaking
into the UseCase?
Is HTTP representation
leaking into the UseCase?
Is an external dependency
represented through a Service?
Is the UseCase stateless?
Have unrelated operations
accumulated here?
Have we invented unnecessary
Manager/Helper abstractions?
Architecture Going Forward
Business workflow implementation will follow:
Handler
↓
UseCase
├── Domain
└── Repository
↓
PostgreSQL
When external systems participate:
Handler
↓
UseCase
├── Domain
├── Repository
└── External Service
The UseCase sits at the center because it understands:
what operation is being performed
without owning:
HTTP
SQL
JPA mapping
external provider protocol
Engineering Principle
The core principle is:
A UseCase coordinates one meaningful application operation. It orchestrates state and dependencies, while Domain objects protect their own invariants and Repositories own persistence mechanics.
Another:
Transaction boundaries should follow business operations, not individual Repository methods. If several local database changes form one operation, the UseCase is the natural place to define their atomic boundary.
And:
UseCases should make workflows easier to read—not create another generic layer of
Service,Manager, andHelperabstractions.
Summary
In this lesson, we established that:
- A UseCase represents one meaningful application operation.
- Our architecture remains
Handler → UseCase → Repository. - Domain objects participate in the workflow without being replaced by the UseCase.
Serviceremains reserved for external integrations such as Payment.- Handlers own HTTP and transport concerns.
- UseCases own application orchestration.
- Domain objects protect their own local invariants and lifecycle.
- Repositories own persistence queries and database mechanics.
- Cross-capability rules naturally require UseCase coordination.
- Create Order coordinates authenticated Customer identity, Product state, Inventory, pricing, Domain construction, and persistence.
- CustomerId is derived from authenticated server-side identity.
- Clients do not control Order ownership.
- Duplicate ProductIds are rejected before side effects.
- Domain also protects duplicate Product-line invariants defensively.
- Product existence is resolved from authoritative Repository state.
- Inactive Products produce
PRODUCT_NOT_ORDERABLE. - Purchase-time
unitPriceCentscomes from current server-side ProductpriceCents. - OrderId is generated by the application as a UUID.
- Order creation time comes from an injected
Clock. - Order starts
UNPAIDthrough Domain construction. - Inventory is consumed during Order creation in v1.
- Inventory consumption uses
consumeIfAvailable(...). consumeIfAvailable(...)is backed by an atomic conditional PostgreSQL update.- Zero affected rows become
INSUFFICIENT_INVENTORY. - Multi-item Inventory operations use deterministic ProductId ordering.
- Create Order requires one local transaction covering Inventory consumption and Order persistence.
