Lesson 68 of 159
Written lesson · 17 min

Use Case Responsibilities

What a use case owns, what it delegates, and why one business operation is one transaction.

Preview

You are viewing a free preview lesson.

So far, we have built most of the application's foundation: the HTTP API, the domain model, the PostgreSQL schema, JPA mapping, repositories, Flyway migrations, pagination and indexes. Each piece solves an important problem, but none of them alone represents a complete business operation.

When a Customer calls POST /api/v1/orders, parsing the request (the handler) or persisting an Order (the repository) is not enough: the operation checks Products, captures prices, consumes Inventory and persists the Order, all-or-nothing. The component that understands this complete operation is the use case. Our architecture remains handler → use case → Repository; when an external system participates, the use case also calls an external Service. The goal of this lesson is:

Understand exactly what belongs inside a use case, 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 use case represents one meaningful application operation. Our application has CreateProductUseCase, UpdateProductUseCase, DeactivateProductUseCase, SetInventoryUseCase, CreateOrderUseCase, GetOrderHistoryUseCase and CancelOrderUseCase, and later GetOrderUseCase and PayOrderUseCase. Each class answers one question:

What is the application being asked to do?

CreateOrderUseCase means "create an Order", not "everything related to Orders".

A UseCase Is an Application Workflow

CreateOrderUseCase works with ProductRepository, InventoryRepository, the Order domain and OrderRepository, and it understands the sequence. In v1, every local database change in it runs inside one transaction:

Drawing diagram…

No domain entity, repository or handler should own that entire workflow:

  • In Order: order.create(productRepository, inventoryRepository, customerId) makes the domain depend on repositories and database access. Order knows its own truth (valid items, initial state, legal transitions, the total), not where Products are stored, who is authenticated or where transactions begin.
  • In the repository: an orderRepository.createOrder(customerId, requestedItems) that checks Products, determines prices, consumes Inventory and decides application errors turns persistence into the application layer. A Repository answers persistence questions; it does not invent the workflow around them.
  • In the handler: a @PostMapping method that queries repositories, checks product.active() and saves the Order makes the HTTP layer own orchestration, persistence, transactions and business decisions. It is hard to test and cannot be reused outside HTTP.

The Responsibility Split

Each part of the architecture has one kind of job:

ComponentJobTypical responsibilities
HandlerTranslate transportRead and validate the request; obtain the authenticated identity; convert to use case input; map the result to a response, status and headers
Use caseCoordinate one application operationSequencing; coordinating repositories, capabilities and External Services; operation-specific ownership; rules needing several pieces of state; the local transaction; interpreting expected repository outcomes
DomainProtect local business truthInvariants and lifecycle of each object
RepositoryQuery and persist application stateBounded, paginated and ownership-scoped queries; locking; concurrency-safe atomic operations; mapping the persistence representation
External ServiceCommunicate with an external systemPayment provider calls

Drawing diagram…

The use case sits at the centre because it knows which operation is being performed, without owning HTTP, SQL, JPA mapping or a provider's protocol. Module 6's JpaOrderRepository, with OrderJpaRepository and OrderEntity, understands JPA, SQL and locking; the use case sees only OrderRepository.

For POST /api/v1/orders, the handler receives only what the client may choose:

json
{
  "items": [
    {
      "productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
      "quantity": 2
    }
  ]
}

It does not accept CustomerId, unitPriceCents, totalCents, OrderStatus, OrderId or createdAt. Those are server-controlled.

Domain Responsibilities

Domain objects protect rules about their own valid state:

Domain objectProtects
ProductName stays valid; priceCents cannot be negative; deactivation changes Product state
InventoryQuantity cannot be negative
OrderItemquantity > 0; unitPriceCents >= 0; subtotal quantity × unitPriceCents with exact arithmetic
OrderAt least one item; no duplicate Product line; starts UNPAID; UNPAID → PAID and UNPAID → CANCELLED; PAID and CANCELLED are terminal; totalCents derives from OrderItems

They hold for every caller: the REST API, a test, a scheduled job or a future adapter. Inventory's in-memory methods do not replace our database concurrency strategy, though.

Some rules need more than one object. Can this Customer order three units of Product X? depends on the Product, the Inventory, the Customer and the requested quantity; no single domain object owns all those facts, so the operation needs use case coordination. Compare OrderItem.quantity > 0, which is intrinsic to OrderItem and owned by the domain, with "can three units be consumed from current Inventory?", which depends on the requested line, the persisted Inventory state and concurrent database activity. The use case owns that intent; the repository and PostgreSQL own the concurrency-safe mechanism.

Create Order: Identity and Duplicate Lines

Let's walk through the workflow. The client never sends "customerId": "customer-123": later, Spring Security turns an authentication token into an identity, the handler reads currentUser.customerId() and passes it on. Ownership comes from trusted authenticated state, not from the request body.

A request could contain Product X × 2 and Product X × 3. Our contract rejects duplicate ProductIds, and CreateOrderUseCase detects them before any Inventory side effect or persistence work. The domain Order also protects the invariant defensively, so no future code path can construct such an Order: the use case gives early application rejection, the domain final invariant protection.

Loading Products, Prices and Inventory

The use case asks ProductRepository for authoritative current Product state and never trusts a client-provided name, price or active state. A missing Product fails with PRODUCT_NOT_FOUND (the repository only reports "found" or "not found"); an inactive one fails with PRODUCT_NOT_ORDERABLE. The use case knows that this operation requires an active Product; the Product knows its own active state. The current Product.priceCents is copied into OrderItem.unitPriceCents (4.06), and the Order is built through Order.create(...) with an application-generated OrderId and createdAt from the injected Clock, so OrderRepository.save(...) returns void.

Inventory is consumed at creation, and the naive load-check-decrease-save is not concurrency-safe. The use case expresses only its intent through a repository operation:

<!-- fragment -->
java
boolean consumed =
        inventoryRepository.consumeIfAvailable(productId, quantity);

false becomes INSUFFICIENT_INVENTORY; the use case never sees the SQL. Lesson 7.08 builds the conditional UPDATE behind it and the ProductId ordering of multi-item consumption.

The Use Case Owns the Transaction

Suppose an Order requests Product A × 1 and Product B × 2; A is consumed, then B fails for lack of stock. If InventoryRepository and OrderRepository committed independently, A's Inventory would be gone with no Order to show for it. The use case knows these calls form one business action, so it owns the boundary:

<!-- fragment -->
java
@Transactional
public Order execute(CreateOrderCommand command) {
    // reject duplicate ProductIds
    // load Products and verify orderability
    // build Order from server-side data
    // consume Inventory atomically
    // persist Order
    return order;
}

The repositories called inside execute join the surrounding transaction, and the business failure rolls everything back. The point is not the annotation but:

One local business operation defines one atomic local database boundary.

The transaction belongs in neither the domain nor the handler. A @Transactional cancel() inside Order ties the domain to Spring; a @Transactional @PostMapping method makes the HTTP adapter own business atomicity, and another adapter calling the same operation would lose it. Lessons 7.09 and 7.10 cover transaction mechanics and failure scenarios.

Cancellation shows the same split: it loads the owned Order for update, lets order.cancel() decide legality, restores Inventory and commits, all in one transaction. Ownership, Inventory and locking are use case and repository concerns that no single domain entity owns (7.11–7.12 build it).

A UseCase Should Read Like the Operation

The cancellation use case takes a small command:

java
package com.tarkev.ordermanagement.order.application;

import com.tarkev.ordermanagement.order.domain.CustomerId;
import com.tarkev.ordermanagement.order.domain.OrderId;

public record CancelOrderCommand(CustomerId customerId, OrderId orderId) {
}

It receives its dependencies through the constructor and keeps the transaction on execute (Order.cancel() is the lifecycle method from module 4):

<!-- fragment -->
java
package com.tarkev.ordermanagement.order.application;

import com.tarkev.ordermanagement.inventory.application.InventoryRepository;
import com.tarkev.ordermanagement.order.domain.Order;
import com.tarkev.ordermanagement.order.domain.OrderItem;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.Comparator;
import java.util.List;

@Component
public class CancelOrderUseCase {

    private final OrderRepository orderRepository;
    private final InventoryRepository inventoryRepository;

    public CancelOrderUseCase(OrderRepository orderRepository,
            InventoryRepository inventoryRepository) {
        this.orderRepository = orderRepository;
        this.inventoryRepository = inventoryRepository;
    }

    @Transactional
    public Order execute(CancelOrderCommand command) {
        Order order = orderRepository
                .findOwnedForUpdate(command.orderId(), command.customerId())
                .orElseThrow(OrderNotFound::new);

        order.cancel();
        restoreInventory(order.items());
        orderRepository.save(order);
        return order;
    }

    private void restoreInventory(List<OrderItem> items) {
        items.stream()
                .sorted(Comparator.comparing(item -> item.productId().value()))
                .forEach(item -> inventoryRepository.restore(
                        item.productId(), item.quantity()));
    }
}

The details evolve in 7.12. Notice what is absent: HTTP status codes, JSON, JPA entities, the EntityManager, SQL and locking syntax. The code expresses the business operation, and the constructor shows exactly which infrastructure it needs, which keeps plain unit testing straightforward.

UseCase Input and Output

A use case does not depend on an HTTP request DTO. CreateOrderRequest from module 5 is the transport representation, and the handler converts it into application input:

java
package com.tarkev.ordermanagement.order.application;

import com.tarkev.ordermanagement.order.domain.CustomerId;
import com.tarkev.ordermanagement.product.domain.ProductId;
import java.util.List;

public record CreateOrderCommand(CustomerId customerId, List<Item> items) {

    public record Item(ProductId productId, int quantity) {
    }
}

Transport concerns then do not define the application API. Commands are not ceremony, though: if a use case needs only a ProductId, execute(ProductId productId) is fine.

On the way out, a use case returns a Product, an Order, an OrderSummary, a PageResult<OrderSummary> or an operation-specific result. It never returns ResponseEntity, HttpStatus or ApiProblem, which are transport concerns.

Application Errors

Use cases signal application failures such as PRODUCT_NOT_FOUND, INSUFFICIENT_INVENTORY or ORDER_NOT_CANCELLABLE through exception types like ProductNotFound and InsufficientInventory. They never build a Problem Details body; ApiExceptionHandler maps the meaning to the RFC 9457 response (5.07). Lesson 7.13 separates these business failures from system failures such as a database outage.

One UseCase Per Meaningful Operation

Avoid a giant OrderService with createOrder(), getOrder(), listOrders(), cancelOrder(), payOrder() and calculateTotal(). It becomes "everything related to Orders", with too many dependencies, different transaction needs, different security rules and different reasons to change. Operation-oriented use cases each have focused dependencies, input and output, tests and transaction semantics.

The opposite extreme is also poor design: LoadProductUseCase, CheckInventoryUseCase, CalculateOrderTotalUseCase and SaveOrderUseCase just so CreateOrderUseCase can call them. Private methods and domain methods cover internal substeps. Chains such as CreateOrderUseCase → GetProductUseCase are usually unnecessary too; Create Order uses ProductRepository directly instead of coupling operations to each other. Operations share in two other ways:

  • Intrinsic rules live in the domain. "Product price cannot be negative" belongs in Product, not in a ValidateProductUseCase.
  • Repositories are shared boundaries. GetOrderUseCase, CancelOrderUseCase and PayOrderUseCase all use OrderRepository.

Stateless UseCases and Explicit Dependencies

Spring application components are normally singleton beans, so request data never lives in fields:

<!-- fragment -->
java
@Component
public class CreateOrderUseCase {

    private CustomerId currentCustomer;
    private List<Product> currentProducts;
}

Concurrent requests could overwrite each other's state. Request data belongs in method arguments and local variables; only dependencies are fields.

Inject only real dependencies. Create Order needs the three repositories and a Clock, because Product state, Inventory, Order persistence and createdAt are part of the operation; Instant.now(clock) gives deterministic tests and an application-owned timestamp instead of a database CURRENT_TIMESTAMP default. It does not need a PaymentService (payment is the separate POST /api/v1/orders/{orderId}/pay), the EntityManager, an ObjectMapper or the ApplicationContext. Two more things stay out:

  • Environment variables. System.getenv("PAYMENT_URL") mixes configuration into the workflow; PayOrderUseCase sees a PaymentService, not a URL, API key or timeout.
  • JPA. entityManager.find(...), entityManager.lock(...), repository.flush() or a native query do not belong here. When correctness needs a lock, a conditional UPDATE or a special query, the repository exposes a meaningful operation such as consumeIfAvailable(productId, quantity) and the adapter implements the mechanics.

Several Repositories and External Services

A use case that coordinates several repositories is not automatically a design smell. Create Order crosses the Product, Inventory and Order capabilities, so it needs all three repositories; hiding them behind an OrderCreationRepository would shorten the constructor but obscure responsibilities. They run in-process, in one modular monolith (2.04).

Service stays reserved for external integrations (2.04): when PayOrderUseCase coordinates OrderRepository and PaymentService, the names say which is local persistence and which an external capability. That external call also changes transaction design: PostgreSQL and a payment provider cannot be one atomic transaction, so the provider call stays outside it (9.05).

Authorization and Testing

Operation-specific ownership, such as "a Customer may cancel only their own Order", needs the authenticated CustomerId and the target Order, so it belongs in the application operation and its owner-scoped query (module 8). Caller roles stay out of the domain: avoid order.cancel(currentUserRole). The Order asks only "Can an Order in this state be cancelled?"

Because their dependencies are explicit, use cases are good unit-test targets: with repository doubles we can prove that cancelling an own UNPAID Order succeeds and requests Inventory restoration, and that a PAID Order fails with ORDER_NOT_CANCELLABLE, without an HTTP server or PostgreSQL. Module 10 builds the test strategy.

Naming and Size

A focused use case exposes execute(...), because the class name already names the operation: createOrderUseCase.execute(...). There is no meaningful line limit either. A use case is large enough to express one operation clearly and small enough that unrelated operations do not accumulate; private methods such as rejectDuplicateProductIds(), loadProducts() and consumeInventory() are fine.

A private void ensureOrderable(Product product) used only by Create Order stays private rather than becoming a ProductOrderabilityValidator or OrderingPolicyService before a reusable concept emerges. Vague names such as OrderManager, OrderHelper, OrderProcessor and OrderUtils usually mean the responsibility is unclear: ask whether the code is an application operation, domain behaviour, persistence or an external integration.

UseCase and Domain Work Together

A healthy workflow asks a repository for state, asks the domain to perform behaviour and persists the result. For cancellation, order.cancel() is better than:

<!-- fragment -->
java
if (order.status() == OrderStatus.UNPAID) {
    order.setStatus(OrderStatus.CANCELLED);
}

The use case coordinates; the domain protects its own transition. This is "tell, don't ask" without extremism: the use case calls order.totalCents() instead of re-summing quantity × unitPriceCents, but cross-resource rules are not forced into one Entity. Inventory shows all layers at work: the domain keeps quantity non-negative, PostgreSQL's atomic update provides concurrency correctness and the use case coordinates.

Neither layer replaces the other. If every rule moves into use cases, the domain objects become data and application code fills with if status == ...: the anemic domain problem. If Entities load other aggregates, call repositories, inspect the authenticated user, open transactions or call providers, they take on workflow concerns.

Deciding Where a Rule Belongs

Five questions place most rules:

QuestionIf yesExamples
Can it be decided from this object's own valid state?DomainCan this PAID Order be cancelled?
Does it need the authenticated Customer, another aggregate, current or concurrent database state, or a provider?Use caseCan this Customer order three units of Product X?
Is it about how data is stored or fetched?Repository and persistenceJPQL, SQL, locking, pagination, JPA mapping, a conditional UPDATE
Is it about HTTP representation?HandlerDTOs, status, Location header, Problem Details
Is it an external capability?Service, coordinated by the use casePaymentService

Practice Exercise 1: Place the Rule

Solutions: lesson 7.15.

Name the part of the architecture that owns each rule (handler, use case, domain, repository or external Service), with one sentence of reasoning. The five questions of the previous section are enough.

  1. A quantity of 1.5 in the request body is rejected.
  2. A CANCELLED Order can never become PAID.
  3. A Customer's Order history contains only that Customer's Orders.
  4. Order history is ordered created_at DESC, id DESC.
  5. Cancelling an Order gives every line's quantity back to Inventory.
  6. The provider expects the amount in cents and an API key in a header.

Practice Exercise 2: Review a Use Case Draft

A teammate opens a pull request with this first version of cancellation. Find at least four problems and say where each piece of work should live instead.

<!-- fragment -->
java
@Component
public class CancelOrderUseCase {

    private final OrderRepository orderRepository;
    private Order order;

    public ResponseEntity<OrderResponse> execute(String orderId,
            String customerId) {
        order = orderRepository.findOwnedForUpdate(
                new OrderId(UUID.fromString(orderId)),
                new CustomerId(customerId)).orElseThrow(OrderNotFound::new);
        if (order.status() == OrderStatus.UNPAID) {
            order.setStatus(OrderStatus.CANCELLED);
        }
        orderRepository.save(order);
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

Practice Exercise 3: One Action, One Transaction

See in your local PostgreSQL 18 why Create Order needs one transaction. Open psql with docker compose exec postgres psql -U order_management order_management and create two practice Products: Product A (11111111-1111-4111-8111-111111111111) with 5 units and Product B (22222222-2222-4222-8222-222222222222) with 1 unit.

An Order asks for A × 2 and B × 3. Without BEGIN, run the conditional consumption for both lines, the same UPDATE … SET available_quantity = available_quantity - :quantity WHERE product_id = :productId AND available_quantity >= :quantity as consumeIfAvailable in 6.08. Then set A back to 5 and repeat both updates between BEGIN and ROLLBACK. Before each run, write down A's final quantity; check it with a SELECT. UPDATE 0 is the false that makes the use case throw. Delete both practice rows at the end.

Knowledge Check

Question 1

Why is "can this Customer order three units of Product X?" not a rule the Order class can decide?

Question 2

What goes wrong if a @PostMapping method carries @Transactional and calls the repositories itself?

Question 3

Why must a use case keep request data in local variables rather than fields?

Question 4

CreateOrderUseCase depends on three repositories. Why is hiding them behind one OrderCreationRepository not an improvement?

Question 5

Why does PayOrderUseCase see a PaymentService and not the provider's URL and API key?

Knowledge Check Answers

Answer 1

The answer depends on the Product, the persisted Inventory, the requested line and concurrent requests, and no single domain object holds all of those facts. The use case coordinates the decision, and the repository with PostgreSQL provides the concurrency-safe mechanism.

Answer 2

The HTTP adapter then owns orchestration and business atomicity: the operation cannot be reused or tested without HTTP, and another adapter calling the same work would lose the transaction.

Answer 3

Use cases are singleton beans shared by all requests. A field holding the current Order or Customer can be overwritten by a concurrent request, so one Customer's request could act on another's data.

Answer 4

The constructor would be shorter, but the class would hide which capabilities the operation really touches and would turn persistence into a second application layer. Three focused repositories make the operation's dependencies visible and testable.

Answer 5

Configuration and protocol details are the integration's concern. The use case decides when to pay and what to do with the result; the Service hides how the provider is reached, so the workflow does not change when the URL, key or timeout does.

Engineering Principle

A UseCase coordinates one meaningful application operation. It orchestrates state and dependencies, while domain objects protect their own invariants and repositories own persistence mechanics.

Use cases should make workflows easier to read, not add another generic layer of Service, Manager and Helper abstractions.

Summary

  • A use case is one meaningful application operation; the architecture stays handler → use case → Repository, with Service reserved for external integrations.
  • Handlers own transport, use cases orchestration, domain objects their invariants and lifecycle, repositories persistence mechanics.
  • Create Order derives the CustomerId from authentication, rejects duplicate ProductIds before side effects, rejects missing or inactive Products, captures the current price and consumes Inventory through consumeIfAvailable(...).
  • The use case owns one local transaction per operation, never the domain or the handler.
  • One use case per meaningful operation: no giant OrderService, no use case per internal step.
  • Use cases are stateless, take commands rather than HTTP DTOs and return application results and failures, never HTTP responses.

In the next lesson, we apply these responsibilities to our first complete workflow, Product creation.

Think first

Coming soon

Checkpoint questions that ask you to commit to an answer before the lesson shows it.