Repositories, Factories, and Domain Services

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Entities, Value Objects, and Aggregates cover the building blocks that carry a domain model’s actual state and behavior. This page covers the supporting cast around them: how an aggregate gets created in the first place, how it gets persisted and reloaded without the domain ever seeing SQL, and where a piece of business logic belongs when it is real but does not belong to any single entity or value object. None of repositories, factories, or domain services hold domain state of their own — that is precisely what tells them apart from an aggregate — but all three exist because an aggregate cannot do everything by itself. The running example stays the same e-commerce Order service used throughout this section, on the same Java 21 / Spring Boot 4.1.x / Spring Data JPA baseline Structuring the Code establishes, and this page’s code extends — rather than replaces — the PlaceOrderCommand/PlaceOrderService pair that page already defines.

Repositories

A repository gives the illusion of an in-memory collection of aggregates, one repository per aggregate root and never one per entity or table. The running example has exactly one: OrderRepository. There is no OrderLineRepository, because OrderLine is not an aggregate root — it has no existence, and no repository, of its own outside the Order it belongs to, exactly the "only the root is externally referenceable" rule Aggregates states in full.

The interface itself lives in the domain layer, com.example.order.domain.OrderRepository, not in infrastructure, and its two methods (Order findById(OrderId id) and long save(Order order), shown in full in the code section below) are deliberately collection-shaped. Reading findById/save the way a Map or a List would read them is exactly the point: nothing about this signature says "run a SELECT against the orders table" or "issue an UPDATE`" — it says "get me the order with this identity" and "put this order back," the same two operations a caller could perform on an in-memory collection if the whole aggregate universe fit in RAM. That framing is what keeps `Order, PlaceOrderService, and every other domain/application class ignorant of SQL, connection pools, or an ORM’s session lifecycle; they depend on this interface and nothing about how it is implemented.

The implementation, by contrast, belongs in infrastructure, never in the domain — OrderRepositoryImpl, shown in full below, is the only class in the Order context that imports Spring Data JPA types on the domain’s behalf. A repository’s query methods also always return whole aggregates, Order in full, never a partial row projection or a handful of selected columns; a projection tuned for a read-only screen is a query model, which is CQRS's concern, not this page’s — mixing the two is a common way for a "repository" to quietly turn back into a data-access object with dozens of ad hoc finder methods.

The Spring Data JPA mapping for OrderRepository has four pieces, all shown together in the code section below: an OrderJpaEntity (@Entity, @Table(name = "orders")) that reuses the OrderLineEmbeddable/ @ElementCollection mapping Value Objects already defines for OrderLine, and the OrderIdConverter Entities already defines for the typed OrderId; a Spring Data OrderJpaRepository interface that does the actual JPA/Hibernate work; an OrderRepositoryImpl adapter implementing the domain’s OrderRepository port and converting OrderJpaEntity to and from Order at that boundary; and an InMemoryOrderRepository test double, backed by a plain Map, for application-service unit tests that should not need a real database to verify `PlaceOrderService’s orchestration logic.

Factories

A plain constructor is enough when an object’s invariants are local to its own fields. It stops being enough the moment creating a valid instance depends on an invariant that spans several objects at once, or on a rule that only applies at the moment of creation and never again afterward — which is exactly Order’s situation. The canonical `Order in Entities keeps its constructor private for precisely this reason: nothing outside Order itself is allowed to assemble an Order by calling new Order(…​) directly, because doing so would bypass whichever invariant the real construction path enforces. A factory is the pattern name for "a method (or class) whose whole job is producing a valid instance of something, enforcing whatever multi-field or point-in-time invariant a bare constructor cannot."

Order shows two static factories that look superficially similar but do genuinely different jobs, and confusing them is one of the more common DDD-in-practice mistakes:

  • Creation — Order.place(PlaceOrderCommand) builds a brand-new order from a customer’s intent, enforces every creation-time invariant (the minimum order amount, in this running example), and raises the OrderPlaced domain event that says this creation actually happened.

  • Reconstitution — Order.reconstitute(OrderId, CustomerId, List<OrderLine>, OrderStatus, long) rebuilds an order that already exists, from data a repository has already loaded out of the database. It trusts that data was already valid when it was saved, so it re-checks nothing and raises no events — an event says something just happened, and loading a five-day-old order out of a table is not something happening now.

The two are easy to conflate because both hand back an Order, but only one of them is a creation in the domain sense; a repository implementation that accidentally called Order.place(…​) while loading rows back out of the database would silently re-publish OrderPlaced events for orders that were placed days ago — exactly the bug class this two-factory split exists to make impossible by construction. This page reuses Order.place(…​) by reference only — the one-line call site already shown inside PlaceOrderService, OrderCreation creation = Order.place(command); — without redefining the method itself; its full body, and the minimum-order invariant it enforces, live on Aggregates.

A factory can also hide a concrete type from its caller entirely: a factory method’s declared return type can be an interface, or one specific variant of a sealed hierarchy, without the calling code ever naming the concrete class it got back — useful whenever which subtype gets constructed depends on input the caller should not have to branch on itself. Neither Order.place nor Order.reconstitute needs this (Order has exactly one concrete shape), but it is worth knowing as a reason a factory earns its keep even when no multi-field invariant is involved.

Both of Order’s factories are static factory methods — ordinary static methods on the type they construct, with no collaborators of their own beyond their parameters. The alternative is a dedicated factory class, worth reaching for once construction genuinely needs its own injected dependencies — calling out to a numbering service for a human-readable order reference, or to a pricing service to compute a price at the moment of creation, before the aggregate can even be assembled. `Order does not need one: nothing about creating an order requires a collaborator beyond the data already on PlaceOrderCommand. A dedicated factory class is the pattern to reach for the day that stops being true, not a default to apply everywhere up front.

Domain services vs. application services

Two kinds of service show up throughout this section’s code, and the difference between them is the single most useful rule of thumb for deciding where a piece of logic goes.

A domain service holds business logic that is real, stateless, and does not naturally belong to any one entity or value object — it operates on more than one thing, or applies a policy that is not really "a thing an Order does to itself." A volume-discount policy is exactly this shape: it needs an order’s subtotal and its line count together, and "give a discount once an order is big enough" is a pricing rule, not something Order itself should know how to compute about its own price. PricingService, in the code below, is a domain service for exactly this reason.

An application service — PlaceOrderService — is the use-case orchestrator Structuring the Code already introduced: load the aggregate (or, for a new order, create it), invoke a behavior on it, save it, publish whatever events resulted. It contains no business rule of its own — every decision PlaceOrderService makes about what "placing an order" even means is delegated to Order.place(…​) and, now, to PricingService; the application service’s own logic never grows past sequencing calls to its collaborators in the right order inside one transaction.

Neither kind of service holds mutable state, but the reason is different for each, and both reasons matter for day-to-day safety under concurrent requests:

  • A domain service is called with everything it needs as method parameters and returns a result — Money applyVolumeDiscount(Money subtotal, int lineCount) reads exactly like a pure function, because that is what it is. Nothing about calling it twice concurrently, from two different requests, can interfere between the two calls, which is exactly what makes it safe to register as a stateless Spring @Service singleton shared across every request the process handles.

  • An application service holds only its collaborators — OrderRepository, DomainEventPublisher, PricingService — as final fields wired once at construction by Spring’s dependency injection, never a mutable field that changes per call. Whatever state actually matters for a use case lives inside the aggregate it loads and saves, not inside the service that orchestrates the call; a PlaceOrderService field that tried to remember something between two unrelated requests would be a concurrency bug waiting to happen; a stateless one cannot have that bug in the first place.

Modules

Modules are the last, coarsest-grained tactical building block, and the least eventful one to describe: a module is simply a package-level grouping with real cohesion — everything about one closely related set of concerns living together, and nothing about an unrelated concern leaking in. This section has already shown the running example’s module boundary in full on Structuring the Code's package layout — com.example.order.{domain,application,infrastructure,api} — and its Spring Modulith @ApplicationModule enforcement, which turns that layout from a convention a reviewer has to notice into a build-time check that fails if, say, com.example.inventory reaches directly into com.example.order.domain. There is nothing to add to that picture here beyond pointing back at it: every repository, factory, and domain service on this page lives inside that same com.example.order module, in the layer its own kind belongs to (domain for OrderRepository’s interface and `PricingService, infrastructure for its JPA implementation), never scattered across a repository-wide services/repositories package that spans every bounded context at once.

Code: OrderRepository, its JPA implementation, and PricingService

The repository’s domain-layer port is the same two-method interface introduced above, Order findById(OrderId id) and long save(Order order) — the latter returning the version the store now holds, which the application service stamps into every event it publishes — kept intentionally free of any JPA or Spring Data type:

package com.example.order.domain;

public interface OrderRepository {
    Order findById(OrderId id);
    /** Persists the aggregate and returns the version the store now holds for it. */
    long save(Order order);
}

The JPA entity it is backed by reuses OrderIdConverter (Entities) and OrderLineEmbeddable (Value Objects) rather than redefining either. Its own primary key column is kept a plain UUID, converted to and from the domain’s typed OrderId explicitly at the OrderRepositoryImpl boundary below, so the Spring Data repository interface can stay a direct JpaRepository<OrderJpaEntity, UUID> — OrderIdConverter’s `autoApply = true conversion is still the right choice for a foreign-key-shaped OrderId field elsewhere (say, on an outbox row that merely references an order), just not needed a second time on this entity’s own primary key:

package com.example.order.infrastructure;

import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import jakarta.persistence.Version;

import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Entity
@Table(name = "orders")
public class OrderJpaEntity {

    @Id
    private UUID id;

    // mapped the same way as OrderIdConverter, via a small CustomerIdConverter
    // (AttributeConverter<CustomerId, UUID>) -- not repeated here.
    private CustomerId customerId;

    @ElementCollection
    @CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_id"))
    private List<OrderLineEmbeddable> lines;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Version
    private long version;

    protected OrderJpaEntity() {
        // required by Hibernate
    }

    private OrderJpaEntity(UUID id, CustomerId customerId, List<OrderLineEmbeddable> lines,
                            OrderStatus status, long version) {
        this.id = id;
        this.customerId = customerId;
        this.lines = lines;
        this.status = status;
        this.version = version;
    }

    /** Flattens a domain Order down to its persisted shape -- called from save(). */
    public static OrderJpaEntity fromDomain(Order order) {
        List<OrderLineEmbeddable> lines = order.lines().stream()
                .map(OrderLineEmbeddable::fromDomain)
                .collect(Collectors.toList());
        return new OrderJpaEntity(order.id().value(), order.customerId(), lines, order.status(), order.version());
    }

    public long version() {
        return version;
    }

    /** Rebuilds the domain Order via Order.reconstitute(...) -- called from findById(). */
    public Order toDomain() {
        List<OrderLine> domainLines = lines.stream()
                .map(OrderLineEmbeddable::toDomain)
                .collect(Collectors.toList());
        return Order.reconstitute(new OrderId(id), customerId, domainLines, status, version);
    }
}

Derived from Spring Data JPA’s entity persistence and optimistic-locking support (saveAndFlush, @Version) — see Spring Data JPA reference — Persisting entities and Spring Data JPA reference — Locking, and Locking in this site’s Hibernate reference for the underlying mechanism.

@Version on the plain long version field is what turns Order.version() from a number the domain merely carries around into Hibernate’s optimistic-locking column: a concurrent save() racing against a stale in-memory Order fails fast with an OptimisticLockException instead of silently overwriting another transaction’s change, with no extra code on the domain side beyond exposing version() as entities.adoc already does. fromDomain/toDomain deliberately mirror `OrderLineEmbeddable’s own conversion methods from Value Objects — keeping the domain-to-JPA translation on the entity itself, rather than spread across a bean’s getters and setters, is what lets the adapter below stay a two-line pass-through.

Spring Data supplies the actual persistence mechanics behind a one-line interface:

package com.example.order.infrastructure;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.UUID;

public interface OrderJpaRepository extends JpaRepository<OrderJpaEntity, UUID> {
}

OrderRepositoryImpl is the adapter that makes the domain’s collection-like OrderRepository real — the only class in the Order context, besides OrderJpaEntity itself, that imports a Spring Data type on the domain’s behalf:

package com.example.order.infrastructure;

import com.example.order.domain.Order;
import com.example.order.domain.OrderId;
import com.example.order.domain.OrderRepository;
import org.springframework.stereotype.Repository;

@Repository
public class OrderRepositoryImpl implements OrderRepository {

    private final OrderJpaRepository jpaRepository;

    public OrderRepositoryImpl(OrderJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public Order findById(OrderId id) {
        return jpaRepository.findById(id.value())
                .map(OrderJpaEntity::toDomain)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Override
    public long save(Order order) {
        // saveAndFlush so the @Version increment happens now rather than at commit: the value returned
        // is the version the database actually holds, which PlaceOrderService stamps into every event.
        return jpaRepository.saveAndFlush(OrderJpaEntity.fromDomain(order)).version();
    }
}

OrderNotFoundException is an ordinary unchecked exception carrying the missing OrderId, omitted here for space; the only thing worth noting about it is that it is thrown by the adapter, not by the domain interface, which is free to declare findById as returning a plain Order rather than an Optional<Order> precisely because "the order does not exist" is treated as exceptional in this running example, not a normal outcome a caller is expected to branch on.

For application-service unit tests, InMemoryOrderRepository implements the exact same OrderRepository interface over a plain Map, with no Spring context and no database involved at all:

package com.example.order.domain;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class InMemoryOrderRepository implements OrderRepository {

    private final Map<OrderId, Order> orders = new ConcurrentHashMap<>();

    @Override
    public Order findById(OrderId id) {
        Order order = orders.get(id);
        if (order == null) {
            throw new OrderNotFoundException(id);
        }
        return order;
    }

    @Override
    public long save(Order order) {
        orders.put(order.id(), order);
        return order.version(); // the double does not simulate @Version increments
    }
}

Because OrderId is a record, it already has the value-based equals/hashCode a Map key needs, so InMemoryOrderRepository needs no extra wiring beyond the two lines of logic above — a PlaceOrderService test can inject this double instead of OrderRepositoryImpl and assert on what got saved without a database in sight.

PricingService is the new domain service this page adds: stateless, no fields, and it never touches Order’s internals directly — it only sees the `Money and int values PlaceOrderService hands it:

package com.example.order.domain;

import com.example.shared.Money;

import java.math.BigDecimal;

public class PricingService {

    private static final int VOLUME_DISCOUNT_LINE_THRESHOLD = 5;
    private static final BigDecimal VOLUME_DISCOUNT_RATE = new BigDecimal("0.05");

    public Money applyVolumeDiscount(Money subtotal, int lineCount) {
        if (lineCount < VOLUME_DISCOUNT_LINE_THRESHOLD) {
            return subtotal;
        }
        BigDecimal discountedAmount = subtotal.amount().multiply(BigDecimal.ONE.subtract(VOLUME_DISCOUNT_RATE));
        return new Money(discountedAmount, subtotal.currency());
    }
}

Finally, this is the running example’s PlaceOrderService, extended here with a PricingService collaborator — the version on Structuring the Code remains the base definition, and everything about its four-step shape (create, save, publish) is unchanged; the only addition is a third constructor-injected port, used to compute a discounted total alongside the order it places. Readers comparing the two pages should treat this one as "the base service with pricing added," not a second, competing definition:

package com.example.order.application;

import com.example.order.domain.Order;
import com.example.order.domain.OrderCreation;
import com.example.order.domain.OrderRepository;
import com.example.order.domain.PricingService;
import com.example.shared.Money;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

// PlaceOrderCommand and DomainEventPublisher are unchanged from Structuring the Code and are not repeated here.

@Service
public class PlaceOrderService {

    private static final Logger log = LoggerFactory.getLogger(PlaceOrderService.class);

    private final OrderRepository orderRepository;
    private final DomainEventPublisher domainEventPublisher;
    private final PricingService pricingService;

    public PlaceOrderService(OrderRepository orderRepository, DomainEventPublisher domainEventPublisher,
                              PricingService pricingService) {
        this.orderRepository = orderRepository;
        this.domainEventPublisher = domainEventPublisher;
        this.pricingService = pricingService;
    }

    @Transactional
    public OrderCreation placeOrder(PlaceOrderCommand command) {
        OrderCreation creation = Order.place(command);
        Order order = creation.order();
        Money discountedTotal = pricingService.applyVolumeDiscount(order.totalAmount(), order.lines().size());
        log.info("Order {} placed for customer {}: subtotal {}, after volume discount {}",
                order.id(), order.customerId(), order.totalAmount(), discountedTotal);
        long version = orderRepository.save(order);
        domainEventPublisher.publish("Order", order.id().value().toString(), version, creation.events());
        return creation;
    }
}

pricingService.applyVolumeDiscount(…​) is called between creating the aggregate and saving it, but its result is not written back onto Order itself — Order has no setter for its own total, by design, since totalAmount() is always derived from its lines. The discounted figure this call produces is exactly the kind of value a REST response DTO in the api layer would surface to the caller (a receipt showing "subtotal" and "total after discount"); this page stops at the application-service wiring that produces the number, since the DTO shape itself belongs to that layer’s own concerns, not this page’s.

The building blocks and the arrows allowed between them

Put together, the seven tactical building blocks this section covers form one small dependency graph, and the rule that keeps a domain model from decaying into a tangle is less about any single pattern and more about which arrows the graph is allowed to have at all: an application service may depend on a repository, an aggregate root, or a domain service, but nothing in the domain layer — an aggregate, an entity, a value object, a domain service — is ever allowed to depend back on an application service, and nothing outside a repository’s implementation ever depends on that implementation directly, only on the interface the domain layer owns.

Diagram of the seven tactical DDD building blocks and the dependency directions allowed between them: Application Service depends on Repository