Implementing Business Workflows

UseCase Responsibilities

LugemineEelvaade

Vaatad tasuta eelvaate õppetundi.

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, and Helper abstractions.


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.
  • Service remains 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 unitPriceCents comes from current server-side Product priceCents.
  • OrderId is generated by the application as a UUID.
  • Order creation time comes from an injected Clock.
  • Order starts UNPAID through 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.