Advanced System Design Tutorial 0/70 lessons ~6 min read Lesson 18

    Repository, Unit of Work and Specification

    TechLearningPro's order service could place a course order, apply a coupon, and write an outbox row for the receipt. The application service took a Spring Data 'JpaRepository' and

    Course progress0%
    Focus
    15 guided sections
    Practice signal
    Examples included
    Career prep
    Interview Q&A included

    Repository, Unit of Work and Specification

    Advanced System Design & Software Architecture · Lesson 18 · Low-Level Design and Design Patterns

    Lesson Overview

    TechLearningPro's order service could place a course order, apply a coupon, and write an outbox row for the receipt. The application service took a Spring Data JpaRepository and called save three times. Each repository method carried its own @Transactional. A coupon update committed. The order update then failed a check constraint. The outbox row was never written. Support saw a redeemed coupon and no order. A second defect arrived the same quarter: an admin screen composed search predicates by concatenating request parameters into a native query, and a tenant filter was optional. One missing parameter returned another organization's orders.

    Repository, unit of work, and specification are the three persistence collaborations that prevent those two failures from being the same class. A repository is the collection-like contract for an aggregate: the application asks for an order and later saves it, in domain terms, without naming tables in the use case. A unit of work is the boundary that remembers what must commit or roll back together and, in JPA, the persistence context that tracks those objects until flush. A specification is a predicate object the application composes — tenant, state, date range — so a query can grow without a new method and without string-built SQL.

    Spring already implements pieces of this, and the lesson uses those pieces by their real names. EntityManager's persistence context is the unit of work when you choose JPA. @Transactional on the application service, backed by a PlatformTransactionManager, is the boundary you declare. JdbcClient (Spring Framework 6.1) is the explicit-SQL alternative: there is no dirty checking, so the unit of work is the transaction around the statements you wrote, not a hidden identity map. JpaSpecificationExecutor and Specification are Spring Data JPA's specification mechanism, and they speak Jakarta Criteria. They belong in an adapter. The use case from Hexagonal Architecture and Ports & Adapters depends on OrderRepository methods you named (find, save, search). It does not depend on Page, Sort, or CriteriaBuilder.

    SOLID Principles in Practice is the reason the repository is an interface the policy owns. Clean Architecture is the reason @Transactional is not pasted onto the entity. This lesson is the persistence detail those lessons left open: one transaction for the order and its outbox, optimistic concurrency when two operators edit the order, and a specification factory that cannot forget the tenant predicate.

    Learning Objectives

    By the end of this lesson you will be able to:

    1. Define a domain repository per aggregate and implement it with either Spring Data or JdbcClient without leaking those APIs into the use case.
    2. Place the unit of work on the application service so the order update and the outbox insert commit or roll back together.
    3. Explain what JPA's persistence context tracks that JdbcClient does not, and what that means for identity and flush.
    4. Compose a Spring Data Specification for an admin search that always applies the tenant predicate and binds every client value as a parameter.
    5. Use @Version and handle ObjectOptimisticLockingFailureException when two updates race.
    6. Keep remote calls outside the transaction, consistent with lessons 5 and 6, and name the crash window that remains.
    7. Test the use case with an in-memory repository and test the specification against a real database slice.

    Prerequisites

    • Lessons 5 and 6: ports, adapters, and the rule that a database transaction does not wrap a gateway call. Lesson 17's EnrollmentStore is the informal repository this lesson makes precise. Lesson 4's dependency inversion: the use case owns the repository interface.
    • Java 21 and Spring Boot 3 with Spring Framework 6.1 or newer, so JdbcClient is the JDBC API in the examples. Jakarta Persistence for the JPA path (jakarta.persistence.EntityManager, @Version). Spring Data JPA's Specification and JpaSpecificationExecutor for the criteria path.
    • SQL enough to read a unique constraint and a where clause. You will not need a database vendor certification. Examples assume PostgreSQL types (uuid, timestamptz) because that is what TechLearningPro runs; the repository port does not mention them.

    Detailed Core Concepts

    Repository, in the sense that survives the framework

    Eric Evans and Martin Fowler describe a repository as a collection of aggregates the application can pretend is in memory. You add an order, you ask for an order by identity, you ask for the orders that match a domain condition. The repository hides inserts, updates, and row mapping. It is not a general query dump for every table in the schema, and it is not automatically one Spring Data interface per entity.

    The contract TechLearningPro's order use case owns looks like this:

    java
    public interface OrderRepository {
    Optional<Order> find(OrderId id);
    Optional<PlacedReceipt> findByIdempotency(PrincipalId principal, String key);
    void save(Order order, OutboxMessage receipt);
    OrderSearchPage search(OrderSearch query);
    }

    save takes the order and the outbox message because those two writes are one aggregate-level consistency rule for this service: an order that is placed has a receipt message waiting. If a later design splits them, the signature changes in the open, with a new transaction story. A pair of repositories with separate save methods invites the caller to commit one and forget the other. The interface makes the coupling visible.

    Spring Data's JpaRepository<OrderEntity, UUID> can implement that contract inside an adapter. It should not be the contract. JpaRepository exposes flush, deleteInBatch, Slice, and derived query methods whose names are schema vocabulary. Once the use case calls findByLearner_IdAndStatusIn, the persistence model has moved into the application service, and lesson 6's port is gone even though an interface is present. The adapter may use those methods internally. The use case calls find and search.

    A repository is per aggregate, not per table. Order lines live inside Order and are saved with it. A LineRepository that the use case calls to append a line lets the line exist without the order's invariants. Reporting queries that span aggregates do not have to go through the write repository. A read model with its own SQL is a valid second adapter, and it should not accept save. Mixing reporting SQL into the aggregate repository is how OrderRepository grows forty methods and every change to a dashboard risks the place-order path.

    Unit of work is the transaction plus, in JPA, the persistence context

    Fowler's unit of work keeps the set of objects a business transaction has created, changed, or deleted, and writes them at the end, resolving concurrency. You do not call it by that name in Spring. You get the behavior from two mechanisms that people conflate.

    The first is the database transaction, started by @Transactional or by TransactionTemplate.execute against a PlatformTransactionManager. For JPA that manager is JpaTransactionManager. For JdbcClient it is DataSourceTransactionManager. Both bind a connection to the thread. Several repository calls in one @Transactional method share that connection and commit once when the method returns. If the method throws a runtime exception, Spring marks the transaction rollback-only. Checked exceptions do not trigger rollback unless rollbackFor includes them. That default is a real source of "half saved" bugs when a domain exception is checked. This course's domain exceptions are unchecked.

    The second mechanism exists only on the JPA path. The EntityManager persistence context is an identity map for the transaction. Load an order twice in the same transaction and you receive the same instance. Mutate a managed field and flush writes an update even if you forgot save, which surprises people coming from explicit SQL. save on a Spring Data repository on a new entity inserts; on a managed entity it is often redundant because dirty checking will flush. Relying on that implicit flush inside a transaction is legitimate JPA. Relying on it across a method that is not transactional persists nothing and throws no error until someone notices the missing row. Make the transaction boundary obvious on the application service. Do not depend on an Open EntityManager filter you have not documented to extend the persistence context into the controller, because lazy loads then fire during JSON rendering and the transaction story becomes "whatever the view touched."

    JdbcClient has neither dirty checking nor an identity map. You write the update and the insert. Two loads of the same id return two objects. The unit of work is only the transaction: the statements either all commit or all roll back. That is easier to see and easier to get wrong if you forget a statement. Choose JPA when the aggregate is a graph you want tracked. Choose JdbcClient when the SQL is the design, the graph is small, and you want the statements in review. TechLearningPro's order path in this lesson shows both: the walkthrough uses JdbcClient so every write is visible, and the admin search uses JPA specifications because dynamic predicates are what CriteriaBuilder is for. Using both in one service is allowed. Using both on the same aggregate without a single transaction manager story is not. One aggregate, one mapper, one transaction manager.

    Propagation matters at the boundary. The default Propagation.REQUIRED joins the current transaction or starts one. Repository methods annotated @Transactional with REQUIRED join the service's transaction, which is what you want for the outbox. REQUIRES_NEW on the outbox repository suspends the order transaction and commits the message even when the order rolls back, which recreates the coupon bug with extra steps. Use REQUIRES_NEW only for a write that must survive the caller's failure, such as an audit of the failure itself, and test that the outer rollback does not undo it and that the inner commit does not surprise you.

    The transaction stays short and local. Lessons 5 and 6 already forbid holding it across an acquirer HTTP call. This lesson repeats the rule because the repository is where teams hide the call: a @Transactional service loads the order, calls the acquirer, then save. The connection sits checked out for the whole round trip. Under load the pool empties and the database looks unhealthy while the acquirer is merely slow. Sequence the work as: read what you need, commit or end the read, call the acquirer, open a short transaction to persist the outcome. The crash between approval and persist remains. The idempotency key and a reconciliation query are the mitigation. A transaction around the HTTP call does not close that window; the process can still die after the acquirer responds and before commit.

    Specification is a predicate you compose, not a query language in the use case

    A specification, in the Evans and Fowler sense, is a predicate that can be checked against an object and can be combined with other predicates. "Tenant is this organization" and "state is placed" and "placed after this instant" are specifications. Their conjunction is a specification. The in-memory form is boolean isSatisfiedBy(Order). The persistence form is a where clause. The point of one type for both is that a rule can be tested without a database and executed against a database. In practice the Criteria implementation is awkward to evaluate in memory, so this lesson keeps a small domain OrderCriteria record and translates it in the adapter. That is specification as a pattern, with Spring Data's Specification<OrderEntity> as the adapter's translation target, not as a type the use case imports.

    Spring Data's interface is the one you will see in code reviews:

    java
    @FunctionalInterface
    public interface Specification<T> {
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
    }

    On current Spring Data, Specification also exposes and, or, where, and not so predicates compose without a hand-written combiner. JpaSpecificationExecutor<T> adds findAll(Specification, Pageable), count, and exists. Those methods are real, and they return Spring Data page types. The adapter may call them. It maps the result to OrderSearchPage, a type the application defines with a list and a cursor or an offset the API actually promised. Returning Page<OrderEntity> from a controller couples JSON to a persistence entity and to Spring Data's page JSON shape. Map it.

    Specifications are justified when the predicate is chosen at runtime: an admin filter form. They are a poor default for a single known query. find(OrderId) should be a parameterized statement you can read, or a Spring Data method with a fixed name, inside the adapter. Criteria for a primary-key lookup hides an index behind an API that is harder to explain to a database reviewer. Derived query methods and @Query with bound parameters sit between the two: fixed shape, still in the adapter.

    The security property is not optional. The tenant predicate is applied inside the factory that builds the specification, using the principal from the server-side session, not a tenant id taken from the query string and trusted. Client values become CriteriaBuilder parameters through cb.equal and cb.greaterThan, which the provider sends as bound parameters. A specification that concatenates strings into a native query has abandoned the API. The lesson's factory has no string concatenation path.

    Optimistic concurrency belongs to the unit of work

    Two support agents open the same order and both apply a coupon. Last write wins if the update is update orders set coupon = ? where id = ?. The second coupon overwrites the first with no error. Optimistic locking adds a version. In JPA, @Version on a numeric field makes the update where id = ? and version = ?, and a stale write throws ObjectOptimisticLockingFailureException (Spring's translation of the persistence provider's optimistic failure). With JdbcClient you write that predicate yourself and treat an update count of zero as a conflict.

    The repository surfaces a domain OrderConflict to the use case, or lets the Spring exception cross the adapter boundary only if the application has agreed to catch it there. Prefer mapping inside the adapter so the use case stays free of org.springframework.dao. The caller retries by reloading, or returns 409. Automatic retry of a coupon apply is unsafe if the command is not idempotent: the retry might apply a second logical action against the new version. The idempotency key from lesson 17 decides. A replay of the same key returns the stored result. A new key after a conflict is a new attempt the user asked for.

    Pessimistic locks (SELECT … FOR UPDATE) are available and sometimes right: allocating a scarce row, which lesson 20 does for a parking spot. For an order edit, a pessimistic lock holds a database lock across the user's think time if you are careless enough to open it when the page loads. Take it only inside the short update transaction, or use the version. This lesson uses the version for orders.

    What the patterns do not do

    They do not make distributed transactions. Two databases, or a database and a mail server, do not become atomic because both repositories were called inside one @Transactional method. The annotation binds one transaction manager. A second resource needs its own plan: outbox, as this lesson writes, or an explicit saga, which a later module owns.

    They do not replace indexes. A specification that ors several like '%x%' predicates will be correct and slow. The repository adapter is where a reviewer looks at the SQL the provider generated. Log it in a test against a real database when the search is on the critical path.

    They do not authorize. A repository that returns an order because the id exists will return another tenant's order if the use case forgets to pass the principal. find in this lesson takes the principal and the id, and the SQL includes both. Hiding authorization inside a global filter is possible with JPA entity listeners and easy to bypass with a native query. The explicit predicate is the one the test can see.

    Architecture and Design Diagrams

    Imports point inward. The transaction is opened by the outer action and joined by the adapter.

    mermaid
    flowchart TB
    subgraph adapters [Adapters]
    jdbc[JdbcOrderRepository]
    spec[JpaOrderSearch]
    outbox[Outbox table]
    end
    subgraph app [Application]
    place[PlaceOrderHandler]
    port[OrderRepository]
    criteria[OrderSearch]
    end
    subgraph domain [Domain]
    order[Order]
    end
    place --> order
    place --> port
    jdbc --> port
    spec --> port
    jdbc --> outbox
    place --> criteria

    JpaOrderSearch is part of the repository adapter, not a second port the handler chooses ad hoc. The handler calls search. The adapter decides that dynamic predicates go through Criteria. A reader of the handler sees one port.

    Placing an order, with the acquirer already approved in a previous step so this transaction is local. When this handler must call the acquirer, the call sits before begin.

    mermaid
    sequenceDiagram
    participant Handler as PlaceOrderHandler
    participant Repo as OrderRepository
    participant DB as Database
    Handler->>Repo: findByIdempotency
    alt Key exists
    Repo-->>Handler: stored receipt
    else New order
    Handler->>Handler: order.place and build outbox
    Handler->>Repo: save order and outbox
    Repo->>DB: begin
    Repo->>DB: insert order
    Repo->>DB: insert outbox
    DB-->>Repo: commit
    end

    A version conflict on a later coupon command:

    mermaid
    sequenceDiagram
    participant A as Agent A
    participant B as Agent B
    participant Repo as OrderRepository
    A->>Repo: save coupon, version 3
    Repo-->>A: version 4
    B->>Repo: save coupon, version 3
    Repo-->>B: OrderConflict

    Agent B reloads, sees version 4 and agent A's coupon, and decides with the user. The repository does not merge coupons.

    Technical Explanation

    Package and transaction boundary

    com.techlearningpro.orders.domain          Order, OrderId, Money, OrderConflict
    com.techlearningpro.orders.application     PlaceOrderHandler, OrderRepository, OrderSearch
    com.techlearningpro.orders.adapter.jdbc   JdbcOrderRepository using JdbcClient
    com.techlearningpro.orders.adapter.jpa    OrderSearchSpecification, entity, Spring Data repository
    com.techlearningpro.orders.bootstrap      @Configuration, transaction manager
    

    PlaceOrderAction is a @Service whose method is @Transactional and delegates to PlaceOrderHandler. The handler is plain Java and receives OrderRepository. The JDBC adapter's methods are not annotated REQUIRES_NEW. They participate in the caller's transaction because JdbcClient uses the thread-bound connection from DataSourceTransactionManager.

    If there is no outer transaction, each JdbcClient update auto-commits on its own connection checkout, and the coupon bug is back. A test that calls the handler without the action, against a real database, will show two commits. The unit test with an in-memory repository cannot show that. One @DataJpaTest or a JDBC integration test that fails the second insert and asserts the first is gone is the test that locks the unit of work. Keep it.

    JDBC repository, explicit unit of work

    java
    @Repository
    public class JdbcOrderRepository implements OrderRepository {
    private final JdbcClient jdbc;
    public JdbcOrderRepository(JdbcClient jdbc) {
    this.jdbc = jdbc;
    }
    @Override
    public Optional<PlacedReceipt> findByIdempotency(PrincipalId principal, String key) {
    return jdbc.sql("""
    select order_id, state
    from placed_receipt
    where principal_id = :principal and idempotency_key = :key
    """)
    .param("principal", principal.value())
    .param("key", key)
    .query((rs, n) -> new PlacedReceipt(
    OrderId.of(rs.getObject("order_id", UUID.class)),
    rs.getString("state")))
    .optional();
    }
    @Override
    public void save(Order order, OutboxMessage message) {
    int updated = jdbc.sql("""
    update course_order
    set state = :state, coupon_code = :coupon, version = version + 1
    where id = :id and version = :version
    """)
    .param("state", order.state())
    .param("coupon", order.couponCode())
    .param("id", order.id().value())
    .param("version", order.version())
    .update();
    if (updated == 0) {
    throw new OrderConflict(order.id());
    }
    jdbc.sql("""
    insert into outbox(id, order_id, type, payload, created_at)
    values (:id, :orderId, :type, :payload, :createdAt)
    """)
    .param("id", message.id())
    .param("orderId", order.id().value())
    .param("type", message.type())
    .param("payload", message.payload())
    .param("createdAt", message.createdAt())
    .update();
    }
    }

    JdbcClient.sql, param, query(RowMapper), optional, and update are Spring Framework 6.1 APIs. Named parameters are bound. The text blocks are SQL the team owns, not a query language invented for the lesson. The version predicate is the optimistic lock. The outbox insert runs on the same JdbcClient, hence the same transaction when one is active. There is no call to a second database.

    A new order is an insert that this snippet omits for space in the update path; the adapter uses insert when order.version() is the initial version and update otherwise, still inside save. Splitting insert and update across two use-case methods that each commit is the bug. One save, one transaction.

    The idempotency insert uses a unique constraint on (principal_id, idempotency_key). A race that passes the optional read fails the insert. The adapter catches DuplicateKeyException (Spring's org.springframework.dao translation of the SQL integrity error) and loads the winning receipt. The use case sees an optional receipt, not a SQL exception. Catching DuplicateKeyException in the domain would import Spring into the core.

    JPA specification adapter

    The entity stays in the adapter. It is not the domain Order. Mapping is the adapter's job, which is the same mapping cost lesson 5 accepted.

    java
    @Entity
    @Table(name = "course_order")
    class OrderEntity {
    @Id
    private UUID id;
    private UUID principalId;
    private String state;
    private Instant placedAt;
    @Version
    private long version;
    }
    public final class OrderSpecifications {
    private OrderSpecifications() {}
    public static Specification<OrderEntity> matching(OrderSearch search) {
    Specification<OrderEntity> spec = (root, query, cb) ->
    cb.equal(root.get("principalId"), search.principal().value());
    if (search.state() != null) {
    spec = spec.and((root, query, cb) ->
    cb.equal(root.get("state"), search.state()));
    }
    if (search.placedAfter() != null) {
    spec = spec.and((root, query, cb) ->
    cb.greaterThan(root.get("placedAt"), search.placedAfter()));
    }
    return spec;
    }
    }

    The principal predicate is unconditional. state and placedAfter are optional filters. and is the Spring Data Specification default method. Values pass through cb.equal and cb.greaterThan, so they are parameters. Nobody appends search.state() onto a SQL string.

    java
    public interface OrderEntityRepository
    extends JpaRepository<OrderEntity, UUID>, JpaSpecificationExecutor<OrderEntity> {
    }

    The search adapter calls findAll(OrderSpecifications.matching(search), pageable) and maps entities to the domain summary the API returns. Page size is capped in the adapter. A client-supplied size of one million is a capacity problem the specification will faithfully execute if you let it.

    In-memory specification for the rule

    The domain can express the same predicate without Criteria when the rule is worth testing alone:

    java
    public record BelongsToPrincipal(PrincipalId principal) {
    public boolean isSatisfiedBy(Order order) {
    return order.principalId().equals(principal);
    }
    }

    The adapter's specification must agree with it. A contract test runs the same orders through isSatisfiedBy and through the database search and compares ids. When they diverge, the Criteria path has grown a join or a null comparison the record does not have. This is the justification for keeping the word "specification" rather than calling everything a filter DTO. If you will not run that contract test, you still keep the tenant predicate in one factory; you have a query object, which is enough for the injection bug, and you should not claim the in-memory and SQL predicates are one object.

    Handler shape

    java
    public final class ApplyCouponHandler {
    private final OrderRepository orders;
    public ApplyCouponHandler(OrderRepository orders) {
    this.orders = orders;
    }
    public CouponReceipt handle(ApplyCoupon command) {
    Order order = orders.find(command.orderId(), command.principal())
    .orElseThrow(() -> new OrderNotFound(command.orderId()));
    order.applyCoupon(command.couponCode());
    OutboxMessage message = OutboxMessage.couponApplied(order);
    orders.save(order, message);
    return new CouponReceipt(order.id(), order.version());
    }
    }

    applyCoupon is domain behavior: a cancelled order refuses, a second identical coupon is idempotent inside the aggregate if the code matches. The repository does not contain that if. The handler does not contain SQL. The transaction attribute sits on ApplyCouponAction, the @Service that calls handle, so both the version update and the outbox insert share it.

    Practical Implementation or Design Walkthrough

    Problem

    Support agents and the learner's own client update course orders: place, apply a coupon, cancel. An admin search lists orders for one organization with optional state and date filters. Receipt email is driven by an outbox, not by an in-transaction SMTP call.

    Requirements

    1. Place and the receipt outbox row commit together. A failure on the outbox insert leaves no placed order.
    2. Coupon apply uses a version. A stale write throws OrderConflict and writes nothing, including no outbox row.
    3. Idempotency keys are unique per principal. A replay returns the stored receipt and does not insert a second outbox row.
    4. Search always constrains principal_id or the organization id the principal is allowed to see. Filters for state and time are optional and parameterized.
    5. Search page size defaults to 20 and rejects values above 100.
    6. No repository method performs HTTP.

    Assumptions

    • One PostgreSQL database, one transaction manager.
    • The acquirer approval, when required, has already happened and stored an approval code, or this command is a coupon that does not call the acquirer. Lesson 22 owns provider calls.
    • Outbox publishing is a separate poller. This lesson writes the row. The poller, in its own short transactions, marks rows sent. A crash after commit and before send leaves a row to be picked up, which is the point.

    Design choices

    The domain OrderRepository is the port. JdbcOrderRepository implements writes with JdbcClient so the version predicate and the outbox insert are visible in one class. JpaOrderSearch implements search with Specification because the filter set is dynamic. Both classes implement the same port; a thin composite OrderRepositoryAdapter delegates save to JDBC and search to JPA if you do not want two beans of one interface. Simpler, and what this walkthrough prefers when the team is willing: implement both methods in the JDBC adapter, and use specifications only if a measured need for composable predicates appears. The lesson still teaches the specification, because the admin screen's predicates are that need. The composite is explicit in configuration:

    java
    @Configuration
    class OrderPersistenceConfig {
    @Bean
    OrderRepository orderRepository(JdbcOrderRepository jdbc, JpaOrderSearch search) {
    return new RoutingOrderRepository(jdbc, search);
    }
    }

    RoutingOrderRepository is ten lines. It is not a framework. Hiding the route inside a Spring Data base class would put the split where reviews miss it.

    Failure behavior

    OrderConflict maps to HTTP 409 at the controller. OrderNotFound maps to 404. A constraint failure on idempotency maps to the existing receipt inside the adapter before the handler publishes anything; for the JDBC path the handler calls findByIdempotency first and the adapter still handles the race. Duplicate outbox ids are a defect in id generation, not a client error: let them fail the transaction so the order does not commit without a well-formed message, or with a duplicate. Use a UUID the handler creates, stored on the command's result, so a replay does not create a second UUID. The replay returns before save.

    Tests

    • In-memory OrderRepository: coupon on a cancelled order throws; successful apply records one outbox message; conflict is simulated by the fake when versions differ.
    • JDBC integration: start a transaction, insert the order, throw before returning from a test-specific repository that fails the outbox insert, assert the order row is absent after rollback. This test uses the real transaction manager.
    • Two threads apply different coupons with the same starting version. Exactly one update count is 1. The other observes OrderConflict. The outbox contains one new message, not two.
    • Specification contract: three orders, two principals. OrderSearch for principal A with state PLACED returns only A's placed orders. A search that tries to pass principal B's id in a filter field still queries as A, because the factory reads the authenticated principal, not the filter. Assert the SQL or, more stably, assert the rows.
    • ArchUnit: application and domain do not import org.springframework.data.jpa.domain.Specification, JdbcClient, or jakarta.persistence.

    Expected result

    A reviewer can see one transaction covering order and outbox, a version column the update predicates on, and a search factory whose first predicate is the tenant. The use case test runs without PostgreSQL. The rollback test does not.

    Enterprise Real-World Scenarios

    Transactional order updates during a catalog launch

    A launch sells a cohort seat. The order row, a seat-count decrement, and the outbox row for the entitlement must commit together. Seat count is a row in the same database, updated with a conditional update seats set remaining = remaining - 1 where course_id = :id and remaining > 0. An update count of zero is "sold out," and the whole transaction rolls back so the learner is not charged in the order table for a seat they did not get. The repository method save(Order, OutboxMessage, SeatReservation) makes the three writes one port call, or the handler calls three methods on repositories that share REQUIRED and the action's transaction. The first style makes a partial call harder. The second style is acceptable when the action is the only caller and a test proves rollback. What is not acceptable is @Transactional on each method with a non-transactional action, or REQUIRES_NEW on the seat update "so the seat is certainly taken." Certainly taken, and then the order insert fails, and the seat is gone with no order.

    Scale for launch is hundreds of orders per second on one cohort row. The seat row becomes a hotspot under optimistic version checks as well as under a conditional update. The conditional update is a short pessimistic fact: the row lock lasts for the transaction, which is why the transaction must not include HTTP and must not include certificate rendering. If the hotspot dominates, the design changes to a queue that serializes reservations for that course. The repository interface can stay. The adapter behind reserveSeat changes. That is the modifiability you bought by not putting update seats in the controller.

    Admin search across a term

    Trust and safety staff search orders by state, date, coupon code, and course. They always search inside their organization. The screen adds a filter most months. A new Spring Data derived method per filter combination explodes (findByOrgAndStateAndPlacedAtAfterAndCouponCode), and the missing-tenant bug returns whenever someone adds findByState for a "temporary" export. The specification factory is the stable seam: a new optional predicate is a new if in one method, under the principal predicate that has no if. The export job uses the same factory. A second path that "just runs SQL for the warehouse" is allowed as a read replica query, and it is not allowed to omit the organization predicate because it runs as a superuser. The job's connection role should not be a superuser. The predicate should still be in the SQL. Defense in depth here is a database row-level security policy matching the organization column, which this lesson does not configure line by line and which a reviewer should ask about when the same table serves multiple tenants. The application predicate is the one the unit of deployment controls. The database policy is the one that saves you when a native query bypasses JPA.

    The search is a read model. It does not call Order.applyCoupon. Staff who act on a result send a command through the same handler the learner's client uses, with their own authorization check in the driving adapter. Bypassing the handler with entity.setCoupon in the admin controller writes through the persistence context and skips the domain rule that a cancelled order refuses coupons. The specification is for finding. The repository save is for changes that have already passed through the aggregate.

    Best Practices

    • One repository port per aggregate, methods in domain language, implementation in the adapter.
    • One @Transactional boundary on the application action for writes that must succeed or fail together. Default propagation REQUIRED on any inner annotation. Treat REQUIRES_NEW as an explicit decision.
    • Prefer unchecked domain exceptions so rollback happens, or set rollbackFor if a checked exception must roll back.
    • Put the version predicate in the write. Map zero rows or ObjectOptimisticLockingFailureException to a domain conflict at the adapter.
    • Build specifications in one factory that always applies tenant scope. Bind values through CriteriaBuilder. Cap page size.
    • Keep Specification, Page, JdbcClient, and EntityManager out of domain and application. ArchUnit that rule.
    • Do not call remote systems inside the transaction. Persist outcomes in a short transaction after the call, with an idempotency key.
    • Write the outbox in the same transaction as the state it describes. Publish later.
    • Test rollback with a real transaction manager. Test the use case with a fake repository. These are different tests.
    • Log generated SQL for new specifications once, in a test, and look at the predicates.
    • For a known query, use a fixed statement. Save Criteria for predicates that actually vary.

    Common Mistakes and Anti-Patterns

    A Spring Data interface as the use case's repository. The service imports Page and the entity. Every test boots JPA or mocks a method zoo. The port is OrderRepository in the application package. Spring Data sits behind it.

    @Transactional on every repository method and not on the action. Each call commits. The outbox insert's failure cannot undo the order. Move the annotation to the action, or to a single application service method that performs the whole unit of work.

    REQUIRES_NEW on the outbox "so the message is safe." The message commits even when the order rolls back. Consumers act on an order that does not exist. The outbox is safe only if it commits with the order.

    Open session in view as the unit of work. Lazy associations load while rendering JSON. The transaction covers the view. A slow client holds a connection. Load what the use case needs inside the transaction and map to a response that does not touch the entity.

    Specifications built by concatenating SQL. Client state filters become SQL injection. Use CriteriaBuilder parameters or a fixed JdbcClient statement with :state and a null-check that you wrote on purpose (and (:state is null or state = :state)), still bound.

    Optional tenant predicate. A boolean restrictTenant defaults to false in one caller. The factory takes PrincipalId as a non-null constructor argument of OrderSearch and always applies it.

    Identity-map assumptions on JdbcClient. Code loads an order, another method loads it again, mutates the first object, and expects the second to see the mutation. They are different instances. With JPA inside one persistence context they would be the same. Do not port that assumption across the adapter you chose.

    Saving the entity from a specification result and skipping the aggregate. The admin path mutates OrderEntity and calls save. Cancel rules never run. Map to Order, call the behavior, save through the port.

    Troubleshooting and Design Review

    Symptom: coupon row exists, order row does not, or the reverse. The writes use different transactions. Inspect annotations for REQUIRES_NEW and for a missing @Transactional on the action while repository methods commit themselves. Enable transaction logging (org.springframework.orm.jpa or org.springframework.jdbc.datasource.DataSourceTransactionManager at debug in a reproduced test, not as a permanent production setting) and count begin/commit pairs. The rollback integration test should already have failed.

    Symptom: ObjectOptimisticLockingFailureException on every coupon during launch. Many updates hit one row, or the client retries a command while sending the stale version and a new idempotency key, so each retry is a real conflict. Return 409 with the current version for interactive edits. For the seat counter, prefer a conditional update that does not require the client to carry a version, inside the same short transaction.

    Symptom: search returns another organization's orders. The specification path or a native query omitted principal or org. Add the contract test with two tenants. Check for a second repository method the controller calls "just for this export."

    Symptom: lazy initialization exception outside the service. The controller received an entity and touched an association after the transaction closed. Map to a domain or response type inside the transaction. Do not "fix" it by widening the session until you have written down why the view is allowed to query.

    Symptom: deadlocks on place-order. Two transactions lock the seat row and the order unique key in different orders. Take locks in a stable order: seat row, then order insert, in every handler. Keep the transaction free of external calls so lock time stays short.

    Symptom: specification query times out after a new filter. A leading-wildcard like or an or across unindexed columns. Read the plan. A specification is allowed to be rejected in review when it cannot use an index. Add a fixed query for the one heavy filter instead of a fully general predicate builder.

    Design review. Is the port free of Spring Data types? Does one transaction cover order and outbox? Does any path call the network while a transaction is open? Is the tenant predicate unconditional? Is optimistic failure mapped and tested? Does any caller mutate an entity without the aggregate? Is page size capped?

    Interview Questions and Answers

    Q1. What is a repository in domain terms, and why is JpaRepository not automatically that?

    A repository is the application's collection for one aggregate: find by identity, save the aggregate's changes, maybe search by a domain predicate. JpaRepository is a Spring Data interface for a persistent type, with framework methods and query derivation. It can implement a repository. It becomes the domain contract only if you accept framework types and the entity in the use case, which breaks the dependency rule from lessons 5 and 6.

    Q2. What does a unit of work correspond to in a Spring JPA application, and what is different with JdbcClient?

    With JPA, the database transaction demarcated by @Transactional or TransactionTemplate is the commit boundary, and the EntityManager persistence context tracks loaded instances, acts as an identity map, and flushes dirty state at commit. With JdbcClient, you have the transaction and the statements you execute. There is no dirty checking and no identity map. Both can commit an order insert and an outbox insert together if they share one transaction. Only JPA will write a field change you never explicitly updated.

    Q3. Where do you put @Transactional, and what does REQUIRES_NEW change?

    Put it on the application action that defines the business transaction, so every repository call joins via Propagation.REQUIRED. REQUIRES_NEW suspends the outer transaction and commits independently. That is appropriate for a failure audit that must persist when the business transaction rolls back. It is the wrong annotation for an outbox row that describes a state which might roll back.

    Q4. How do you keep a gateway call out of the unit of work without losing the outcome?

    Perform the remote call before opening the write transaction, or after a read transaction has ended. Persist the outcome in a short transaction afterward. Store the idempotency key so a retry does not double-apply. Accept the crash window between the remote success and the local commit, and reconcile it. Holding the transaction open during HTTP does not remove that window and does exhaust the connection pool.

    Q5. How does a specification differ from a new repository method per search screen?

    A specification is a composable predicate. Spring Data exposes it as Specification.toPredicate and JpaSpecificationExecutor.findAll. You add an optional filter in one factory. A new derived method per combination duplicates the tenant predicate and drifts. Specifications are for runtime-composed filters. A known lookup by id stays a single parameterized statement.

    Q6. How do you stop a specification from leaking another tenant's rows and from becoming SQL injection?

    Always conjoin a predicate on the server-side principal or organization. Do not trust a tenant id from the query string. Pass filter values through CriteriaBuilder comparison methods so the provider binds parameters. Reject a native-query builder that concatenates those values. Test with two tenants.

    Q7. Two agents apply a coupon to the same order. What do you want to happen, and how does the repository express it?

    One update commits and increments @Version or the explicit version column. The other updates zero rows or throws ObjectOptimisticLockingFailureException, which the adapter maps to OrderConflict. Neither outbox row for the loser is committed, because the failed update and the outbox share a transaction that rolls back. The loser reloads. You do not silently overwrite.

    Q8. Why can an in-memory fake repository pass while production still commits partial orders?

    The fake records calls in a list and has no transaction semantics. It will not commit the first write when the second throws unless you coded it to. A test against DataSourceTransactionManager or JpaTransactionManager that fails the second write and asserts absence of the first is the test that matches production. Keep both tests. They answer different questions.

    Q9. When would you choose pessimistic locking instead of a version column?

    When the resource is scarce and the conflict rate is the business, such as a seat counter or a parking spot, a conditional update or SELECT … FOR UPDATE inside a short transaction avoids retry storms. When the client is a human editing a form, a version plus 409 is kinder than holding a row lock while they think. Either way the lock duration is the transaction, so the transaction stays free of remote calls.

    Q10. A colleague proposes one repository interface with every table and every report. What do you argue?

    Write repositories follow aggregates so invariants stay in one type. Reporting queries are a separate read adapter and should not expose save. A single interface becomes a change magnet: dashboard work recompiles and retests place-order. Lesson 4's single-responsibility concern applies to the port, not only to classes with business if statements.

    Q11. How does this lesson relate to the enrollment store in the behavioral-patterns lesson?

    Lesson 17's handler depends on a store port and publishes after a successful save. This lesson defines that port as a repository, states that the save and any outbox share a unit of work, and moves dynamic queries to specifications in the adapter. The handler stays free of JdbcClient and Specification. Events after commit remain listeners. They are not a second commit of the same unit of work.

    Q12. What does the repository pattern not guarantee across two services?

    Atomicity. @Transactional enlists one transaction manager. A payment service and an order service need an outbox, a saga, or a deliberate eventual consistency story. Calling both databases from one method annotated @Transactional only wraps the manager you configured, unless you have a distributed transaction product you have chosen explicitly and operationally. This course's order path uses one database and an outbox.

    Hands-On Exercises

    Exercise 1: One unit of work for order and outbox

    Problem. Implement OrderRepository.save so the order update and the outbox insert commit or roll back together, with a version check.

    Tasks.

    1. Specify the failure you are preventing, in one scenario a support agent could observe.
    2. Implement the port and a JdbcClient adapter using bound parameters and a version predicate.
    3. Put @Transactional on the action. State the propagation of any repository-level annotation.
    4. Write an integration test that fails the second write and asserts the first is absent.
    5. Write a concurrency test in which two coupon commands start from the same version.
    6. Explain why the mail sender is not called inside save.

    Rubric. Requirements 15%. Transaction and version design 25%. Tests against a real transaction manager 20%. Failure and concurrency behavior 20%. Trade-offs 10%. Clarity 10%.

    Exercise 2: Tenant-safe search specification

    Problem. Implement OrderSearch and a Spring Data Specification factory for optional state and placed-after filters.

    Tasks.

    1. Make the principal predicate unconditional. Show the code path that ignores any client-supplied tenant id.
    2. Cap page size.
    3. Add a test with two principals and assert isolation.
    4. Document one query that you refuse to express as a specification, and why (fixed primary-key fetch, or an unindexed leading wildcard).
    5. Keep Specification out of the application package and show the mapping to a domain page type.

    Rubric. Same weights. A factory that can return an unconstrained query does not pass the security criterion.

    Lesson Summary

    A repository is the aggregate's collection port, implemented by JdbcClient or Spring Data behind the application, not in it. The unit of work is the transaction on the application action, plus JPA's persistence context when you use JPA. The order row and the outbox row share that boundary. REQUIRES_NEW, per-method commits, and HTTP inside the transaction each break it in a different way. A specification composes search predicates in the adapter, with the tenant always applied and values bound through CriteriaBuilder. Versions turn lost updates into OrderConflict. Fakes test the use case. A real transaction manager tests the rollback. Lesson 19 takes the constructors these ports imply and makes dependency injection the way they are supplied, including what happens when two beans require each other.

    Further Reading

    • Fowler, Patterns of Enterprise Application Architecture: Repository, Unit of Work, Identity Map, Specification (with Evans).
    • Evans, Domain-Driven Design: Repository and Specification in the context of aggregates.
    • Spring Framework reference, Data Access: transaction management (@Transactional, propagation, rollback rules), TransactionTemplate, and JdbcClient.
    • Spring Data JPA reference: Specifications and JpaSpecificationExecutor.
    • Jakarta Persistence: persistence context, flush, @Version.
    • Clean Architecture and Hexagonal Architecture and Ports & Adapters for the port the repository must remain.
    • PostgreSQL documentation on unique constraints and UPDATE row counts, for the idempotency and version predicates this lesson relies on.
    Ready to mark this lesson complete?Track your journey across the entire course.