
Spring Boot Data Ddd
- 1 installs
- 21 repo stars
- Updated August 5, 2026
- joaquimscosta/arkhe-claude-plugins
Implements the Spring Boot 4 data layer for DDD using Spring Data JPA or JDBC aggregates, repositories, projections, and N+1 prevention.
About
Implements DDD tactical patterns in Spring Boot 4 with Spring Data JPA and JDBC, covering aggregate roots, value objects, repositories, and EntityGraph N+1 prevention. A developer uses it when building the persistence layer of a DDD Spring Boot app.
- JPA vs JDBC selection guidance for DDD
- AbstractAggregateRoot, projections, JSpecify null-safety
Spring Boot Data Ddd by the numbers
- 1 all-time installs (skills.sh)
- Ranked #79 of 89 Java & JVM skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/joaquimscosta/arkhe-claude-plugins --skill spring-boot-data-dddAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 5, 2026 |
| Repository | joaquimscosta/arkhe-claude-plugins ↗ |
What it does
Implements the Spring Boot 4 data layer for DDD using Spring Data JPA or JDBC aggregates, repositories, projections, and N+1 prevention.
Files
Spring Boot Data Layer for DDD
Implements DDD tactical patterns with Spring Data JPA and Spring Data JDBC in Spring Boot 4.
Technology Selection
| Choose | When |
|---|---|
| Spring Data JPA | Complex queries, existing Hibernate expertise, need lazy loading |
| Spring Data JDBC | DDD-first design, simpler mapping, aggregate-per-table, no lazy loading |
Spring Data JDBC enforces aggregate boundaries naturally—recommended for new DDD projects.
Core Workflow
1. Define aggregate root → 2. Map value objects → 3. Create repository → 4. Implement service layer → 5. Add projections
See WORKFLOW.md for detailed step-by-step instructions with code examples.
Quick Patterns
See EXAMPLES.md for complete working examples including:
- Aggregate Root with AbstractAggregateRoot and domain events (Java + Kotlin)
- Repository with EntityGraph for N+1 prevention
- Transactional Service with proper boundaries
- Value Objects (Strongly-typed IDs, Money pattern)
- Projections for efficient read operations
- Auditing with automatic timestamps
Spring Boot 4 Specifics
- JSpecify null-safety:
@NullMarkedand@Nullableannotations - AOT Repository Compilation: Enabled by default for faster startup
- Jakarta EE 11: All imports use
jakarta.*namespace - ListCrudRepository: New interface returning
List<T>instead ofIterable<T>
ListCrudRepository (Spring Data 3.1+)
New repository interface returning List<T> for better API ergonomics:
// OLD: CrudRepository returns Iterable<T>
public interface UserRepository extends CrudRepository<User, Long> {
Iterable<User> findAll(); // Requires conversion to List
}
// NEW: ListCrudRepository returns List<T>
public interface UserRepository extends ListCrudRepository<User, Long> {
List<User> findAll(); // Direct List return
List<User> findAllById(Iterable<Long> ids); // Also List
}
// Can also extend both for full functionality
public interface UserRepository extends
ListCrudRepository<User, Long>,
ListPagingAndSortingRepository<User, Long> {
}Benefits: No more StreamSupport.stream(iterable.spliterator(), false).toList() conversions.
Detailed References
- Workflow: See WORKFLOW.md for detailed step-by-step data layer implementation
- Examples: See EXAMPLES.md for complete working code examples
- Troubleshooting: See TROUBLESHOOTING.md for common issues and Boot 4 migration
- Aggregates & Entities: See references/AGGREGATES.md for complete patterns with value objects, typed IDs, auditing
- Repositories & Queries: See references/REPOSITORIES.md for custom queries, projections, specifications
- Transactions: See references/TRANSACTIONS.md for propagation, isolation, cross-aggregate consistency
Related Skills
| Need | Skill |
|---|---|
| DDD concepts and design | domain-driven-design |
| REST API for aggregates | spring-boot-web-api |
| Module boundaries | spring-boot-modulith |
| Repository testing | spring-boot-testing |
Anti-Pattern Checklist
| Anti-Pattern | Fix |
|---|---|
FetchType.EAGER on associations | Use LAZY + @EntityGraph when needed |
| Returning entities from controllers | Convert to DTOs in service layer |
@Transactional on private methods | Use public methods (proxy limitation) |
Missing readOnly = true on queries | Add for read operations (performance) |
| Direct aggregate-to-aggregate references | Reference by ID only |
| Multiple aggregates in one transaction | Use domain events for eventual consistency |
Critical Reminders
1. One aggregate per transaction — Cross-aggregate changes via domain events 2. Repository per aggregate root — Never for child entities 3. Value objects are immutable — No setters, return new instances 4. Flush before events — Call repository.save() before events dispatch 5. Test with `@DataJpaTest` — Use TestEntityManager for setup
Spring Boot Data Layer Examples
Complete working examples for DDD patterns with Spring Data.
Aggregate Root with Domain Events
JPA entity extending AbstractAggregateRoot for domain event publishing.
Java
@Entity
public class Order extends AbstractAggregateRoot<Order> {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private CustomerId customerId; // Value object
@Enumerated(EnumType.STRING)
private OrderStatus status = OrderStatus.DRAFT;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id")
private Set<OrderLine> lines = new HashSet<>();
public void submit() {
if (lines.isEmpty()) throw new IllegalStateException("Empty order");
this.status = OrderStatus.SUBMITTED;
registerEvent(new OrderSubmitted(this.id));
}
}Kotlin
@Entity
class Order : AbstractAggregateRoot<Order>() {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
private set
@Embedded
lateinit var customerId: CustomerId
private set
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true)
@JoinColumn(name = "order_id")
private val _lines: MutableSet<OrderLine> = mutableSetOf()
val lines: Set<OrderLine> get() = _lines.toSet()
fun submit(): Order {
check(_lines.isNotEmpty()) { "Empty order" }
status = OrderStatus.SUBMITTED
registerEvent(OrderSubmitted(id!!))
return this
}
}Key points:
- Extend
AbstractAggregateRoot<T>for domain event support - Use
@Embeddedfor value objects - Register events with
registerEvent()- published on save
---
Repository with EntityGraph
Repository interface with N+1 prevention patterns.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"lines", "lines.product"})
Optional<Order> findWithLinesById(Long id);
List<OrderSummary> findByStatus(OrderStatus status); // Projection
@Query("SELECT o FROM Order o WHERE o.customerId.value = :customerId")
List<Order> findByCustomerId(@Param("customerId") String customerId);
}Key points:
- Use
@EntityGraphto eager fetch specific associations - Return projections for read-only queries
- Query value objects by their internal field
---
Transactional Service
Service with proper transaction boundaries.
@Service
@Transactional
public class OrderService {
private final OrderRepository orders;
@Transactional(readOnly = true)
public OrderDto findById(Long id) {
return orders.findById(id)
.map(OrderDto::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
public OrderDto submit(Long orderId) {
Order order = orders.findWithLinesById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
order.submit();
return OrderDto.from(orders.save(order));
}
}Key points:
- Class-level
@Transactionalfor default behavior - Override with
readOnly = truefor queries (performance) - Fetch with associations before modifying
---
Value Object Implementation
Strongly-typed ID and Money patterns.
Strongly-Typed ID
@Embeddable
public record CustomerId(
@Column(name = "customer_id")
String value
) {
public CustomerId {
Objects.requireNonNull(value, "Customer ID required");
}
public static CustomerId generate() {
return new CustomerId(UUID.randomUUID().toString());
}
}Money Value Object
@Embeddable
public record Money(
@Column(name = "amount", precision = 19, scale = 4)
BigDecimal amount,
@Column(name = "currency", length = 3)
String currency
) {
public Money {
Objects.requireNonNull(amount, "Amount required");
Objects.requireNonNull(currency, "Currency required");
if (amount.scale() > 4) {
throw new IllegalArgumentException("Amount scale exceeds 4");
}
}
public static Money of(BigDecimal amount, String currency) {
return new Money(amount, currency);
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
}Key points:
- Use
@Embeddablefor JPA mapping - Records are immutable by default
- Validate in constructor (compact constructor for records)
---
Projection for Read Operations
Interface-based projection for efficient queries.
// Interface projection - only fetches needed columns
public interface OrderSummary {
Long getId();
String getStatus();
Instant getCreatedAt();
@Value("#{target.lines.size()}")
int getLineCount();
}
// Record projection - explicit mapping
public record OrderDto(
Long id,
String status,
BigDecimal totalAmount,
List<OrderLineDto> lines,
Instant createdAt
) {
public static OrderDto from(Order order) {
return new OrderDto(
order.getId(),
order.getStatus().name(),
order.getTotal().amount(),
order.getLines().stream().map(OrderLineDto::from).toList(),
order.getCreatedAt()
);
}
}Key points:
- Interface projections auto-generate SQL with only needed columns
- Use
@Valuefor computed properties in interface projections - Record DTOs for explicit conversion from entities
---
Auditing Configuration
Automatic created/modified timestamps.
@Configuration
@EnableJpaAuditing
public class JpaConfig {}
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate
@Column(updatable = false)
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
// Usage
@Entity
public class Order extends AuditableEntity {
// ...
}Key points:
- Enable with
@EnableJpaAuditing - Use
@MappedSuperclassfor common audit fields - Implement
AuditorAware<String>for@CreatedBy/@LastModifiedBy
Aggregates, Entities & Value Objects
Complete implementation patterns for DDD building blocks in Spring Data.
Table of Contents
- Strongly-Typed IDs
- Java
- Kotlin
- Value Objects
- Java - Money Value Object
- Kotlin - Money Value Object
- Complex Value Object with Converter
- Java
- Complete Aggregate Root
- Java
- Kotlin
- Child Entity (within aggregate)
- Domain Events
- Enable Auditing
- Spring Data JDBC Alternative
Strongly-Typed IDs
Wrap primitive IDs to prevent parameter mixups and add domain meaning.
Java
@Embeddable
public record CustomerId(String value) {
public CustomerId {
Objects.requireNonNull(value, "CustomerId cannot be null");
if (value.isBlank()) throw new IllegalArgumentException("CustomerId cannot be blank");
}
public static CustomerId generate() {
return new CustomerId(UUID.randomUUID().toString());
}
}Kotlin
@Embeddable
data class CustomerId(val value: String) {
init {
require(value.isNotBlank()) { "CustomerId cannot be blank" }
}
companion object {
fun generate() = CustomerId(UUID.randomUUID().toString())
}
}Value Objects
Immutable, equality by attributes, encapsulate validation.
Java - Money Value Object
@Embeddable
public record Money(
@Column(name = "amount") BigDecimal amount,
@Column(name = "currency") String currency
) {
public static final Money ZERO = new Money(BigDecimal.ZERO, "USD");
public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
if (amount.scale() > 2) {
amount = amount.setScale(2, RoundingMode.HALF_UP);
}
}
public Money add(Money other) {
requireSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(int quantity) {
return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
}
private void requireSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Currency mismatch");
}
}
}Kotlin - Money Value Object
@Embeddable
data class Money(
@Column(name = "amount") val amount: BigDecimal,
@Column(name = "currency") val currency: String = "USD"
) {
init {
require(amount.scale() <= 2) { "Amount scale must be <= 2" }
}
operator fun plus(other: Money): Money {
require(currency == other.currency) { "Currency mismatch" }
return Money(amount + other.amount, currency)
}
operator fun times(quantity: Int) = Money(amount * quantity.toBigDecimal(), currency)
companion object {
val ZERO = Money(BigDecimal.ZERO)
}
}Complex Value Object with Converter
For value objects that don't map cleanly to columns.
Java
// Value object
public record Address(String street, String city, String postalCode, String country) {
public Address {
Objects.requireNonNull(street);
Objects.requireNonNull(city);
Objects.requireNonNull(postalCode);
Objects.requireNonNull(country);
}
public String formatted() {
return String.join(", ", street, city, postalCode, country);
}
}
// JPA Converter
@Converter
public class AddressConverter implements AttributeConverter<Address, String> {
private static final String DELIMITER = "|||";
@Override
public String convertToDatabaseColumn(Address address) {
if (address == null) return null;
return String.join(DELIMITER,
address.street(), address.city(),
address.postalCode(), address.country());
}
@Override
public Address convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
String[] parts = dbData.split("\\|\\|\\|");
return new Address(parts[0], parts[1], parts[2], parts[3]);
}
}
// Usage in entity
@Entity
public class Customer {
@Convert(converter = AddressConverter.class)
@Column(name = "shipping_address")
private Address shippingAddress;
}Complete Aggregate Root
Full pattern with auditing, versioning, and domain events.
Java
@Entity
@Table(name = "orders")
@EntityListeners(AuditingEntityListener.class)
public class Order extends AbstractAggregateRoot<Order> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "customer_id"))
private CustomerId customerId;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount", column = @Column(name = "total_amount")),
@AttributeOverride(name = "currency", column = @Column(name = "total_currency"))
})
private Money total = Money.ZERO;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status = OrderStatus.DRAFT;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Set<OrderLine> lines = new HashSet<>();
@Version
private Long version;
@CreatedDate
@Column(updatable = false)
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
protected Order() {} // JPA
public Order(CustomerId customerId) {
this.customerId = Objects.requireNonNull(customerId);
}
// Domain behavior
public void addLine(ProductId productId, int quantity, Money unitPrice) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify non-draft order");
}
lines.add(new OrderLine(productId, quantity, unitPrice));
recalculateTotal();
}
public void submit() {
if (lines.isEmpty()) {
throw new IllegalStateException("Cannot submit empty order");
}
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Order already submitted");
}
this.status = OrderStatus.SUBMITTED;
registerEvent(new OrderSubmitted(this.id, this.customerId, this.total));
}
private void recalculateTotal() {
this.total = lines.stream()
.map(OrderLine::lineTotal)
.reduce(Money.ZERO, Money::add);
}
// Getters only - no setters
public Long getId() { return id; }
public CustomerId getCustomerId() { return customerId; }
public Money getTotal() { return total; }
public OrderStatus getStatus() { return status; }
public Set<OrderLine> getLines() { return Collections.unmodifiableSet(lines); }
}Kotlin
@Entity
@Table(name = "orders")
@EntityListeners(AuditingEntityListener::class)
class Order private constructor() : AbstractAggregateRoot<Order>() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
private set
@Embedded
@AttributeOverride(name = "value", column = Column(name = "customer_id"))
lateinit var customerId: CustomerId
private set
@Embedded
@AttributeOverrides(
AttributeOverride(name = "amount", column = Column(name = "total_amount")),
AttributeOverride(name = "currency", column = Column(name = "total_currency"))
)
var total: Money = Money.ZERO
private set
@Enumerated(EnumType.STRING)
@Column(nullable = false)
var status: OrderStatus = OrderStatus.DRAFT
private set
@OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private val _lines: MutableSet<OrderLine> = mutableSetOf()
val lines: Set<OrderLine> get() = _lines.toSet()
@Version
var version: Long? = null
private set
@CreatedDate
@Column(updatable = false)
var createdAt: Instant? = null
private set
@LastModifiedDate
var updatedAt: Instant? = null
private set
constructor(customerId: CustomerId) : this() {
this.customerId = customerId
}
fun addLine(productId: ProductId, quantity: Int, unitPrice: Money) {
check(status == OrderStatus.DRAFT) { "Cannot modify non-draft order" }
_lines.add(OrderLine(productId, quantity, unitPrice))
recalculateTotal()
}
fun submit(): Order {
check(_lines.isNotEmpty()) { "Cannot submit empty order" }
check(status == OrderStatus.DRAFT) { "Order already submitted" }
status = OrderStatus.SUBMITTED
registerEvent(OrderSubmitted(id!!, customerId, total))
return this
}
private fun recalculateTotal() {
total = _lines.map { it.lineTotal() }.fold(Money.ZERO) { acc, m -> acc + m }
}
}Child Entity (within aggregate)
@Entity
@Table(name = "order_lines")
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "product_id"))
private ProductId productId;
private int quantity;
@Embedded
private Money unitPrice;
protected OrderLine() {}
OrderLine(ProductId productId, int quantity, Money unitPrice) {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
this.productId = Objects.requireNonNull(productId);
this.quantity = quantity;
this.unitPrice = Objects.requireNonNull(unitPrice);
}
public Money lineTotal() {
return unitPrice.multiply(quantity);
}
}Domain Events
// Immutable event record
public record OrderSubmitted(
Long orderId,
CustomerId customerId,
Money totalAmount,
Instant occurredAt
) {
public OrderSubmitted(Long orderId, CustomerId customerId, Money totalAmount) {
this(orderId, customerId, totalAmount, Instant.now());
}
}Enable Auditing
@Configuration
@EnableJpaAuditing
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}Spring Data JDBC Alternative
For simpler, DDD-native mapping without JPA overhead:
// No @Entity - just a class
public class Order {
@Id
private Long id;
private CustomerId customerId;
private Money total;
private OrderStatus status;
// Child entities automatically managed as aggregate members
private Set<OrderLine> lines = new HashSet<>();
// Same domain behavior methods...
}
// Repository
public interface OrderRepository extends CrudRepository<Order, Long> {
List<Order> findByStatus(OrderStatus status);
}Spring Data JDBC benefits:
- No lazy loading surprises
- Automatic cascade delete of children
- Simpler SQL generation
- Natural aggregate boundaries
Repositories & Query Patterns
Spring Data repository patterns for aggregate persistence.
Table of Contents
- Repository Hierarchy
- Complete Repository Example
- Java
- Kotlin
- Projections
- Interface-Based Projection
- Class-Based Projection (Record)
- Kotlin Data Class Projection
- Specifications (Dynamic Queries)
- Java
- Kotlin
- EntityGraph Strategies
- Annotation-Based
- Programmatic EntityGraph
- Batch Operations
- Pagination
- Custom Repository Implementation
Repository Hierarchy
| Interface | Use When |
|---|---|
Repository<T, ID> | Marker only, define all methods yourself |
CrudRepository<T, ID> | Basic CRUD, returns Iterable |
ListCrudRepository<T, ID> | CRUD with List returns |
PagingAndSortingRepository<T, ID> | Add pagination/sorting |
JpaRepository<T, ID> | Full JPA features: flush, batch, examples |
Complete Repository Example
Java
public interface OrderRepository extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> {
// Derived query
List<Order> findByStatus(OrderStatus status);
// With sorting
List<Order> findByStatusOrderByCreatedAtDesc(OrderStatus status);
// Pagination
Page<Order> findByCustomerIdValue(String customerId, Pageable pageable);
// Optional for single results
Optional<Order> findByIdAndStatus(Long id, OrderStatus status);
// EntityGraph - solve N+1
@EntityGraph(attributePaths = {"lines", "lines.product"})
Optional<Order> findWithLinesById(Long id);
// JPQL query
@Query("SELECT o FROM Order o WHERE o.customerId.value = :customerId AND o.status = :status")
List<Order> findByCustomerAndStatus(
@Param("customerId") String customerId,
@Param("status") OrderStatus status
);
// Native query
@Query(value = "SELECT * FROM orders WHERE total_amount > :amount", nativeQuery = true)
List<Order> findHighValueOrders(@Param("amount") BigDecimal amount);
// Modifying query
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :before")
int archiveOldOrders(@Param("status") OrderStatus status, @Param("before") Instant before);
// Projection
List<OrderSummary> findSummaryByStatus(OrderStatus status);
// Dynamic projection
<T> List<T> findByStatus(OrderStatus status, Class<T> type);
// Exists check (efficient)
boolean existsByCustomerIdValueAndStatus(String customerId, OrderStatus status);
// Count
long countByStatus(OrderStatus status);
// Delete
void deleteByStatusAndCreatedAtBefore(OrderStatus status, Instant before);
}Kotlin
interface OrderRepository : JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> {
fun findByStatus(status: OrderStatus): List<Order>
@EntityGraph(attributePaths = ["lines", "lines.product"])
fun findWithLinesById(id: Long): Order?
@Query("SELECT o FROM Order o WHERE o.customerId.value = :customerId")
fun findByCustomerId(@Param("customerId") customerId: String): List<Order>
fun findSummaryByStatus(status: OrderStatus): List<OrderSummary>
fun <T> findByStatus(status: OrderStatus, type: Class<T>): List<T>
}Projections
Interface-Based Projection
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
Money getTotal();
Instant getCreatedAt();
// Nested projection
CustomerInfo getCustomer();
interface CustomerInfo {
String getName();
String getEmail();
}
// Computed value (SpEL)
@Value("#{target.total.amount.multiply(1.1)}")
BigDecimal getTotalWithTax();
}Class-Based Projection (Record)
Better performance - no proxy overhead:
public record OrderDto(
Long id,
String status,
BigDecimal totalAmount,
Instant createdAt
) {
// Constructor must match SELECT order
public static OrderDto from(Order order) {
return new OrderDto(
order.getId(),
order.getStatus().name(),
order.getTotal().amount(),
order.getCreatedAt()
);
}
}
// In repository
@Query("SELECT new com.example.OrderDto(o.id, o.status, o.total.amount, o.createdAt) " +
"FROM Order o WHERE o.status = :status")
List<OrderDto> findDtoByStatus(@Param("status") OrderStatus status);Kotlin Data Class Projection
data class OrderDto(
val id: Long,
val status: String,
val totalAmount: BigDecimal,
val createdAt: Instant
) {
companion object {
fun from(order: Order) = OrderDto(
id = order.id!!,
status = order.status.name,
totalAmount = order.total.amount,
createdAt = order.createdAt!!
)
}
}Specifications (Dynamic Queries)
Java
public class OrderSpecifications {
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
public static Specification<Order> belongsToCustomer(CustomerId customerId) {
return (root, query, cb) -> cb.equal(root.get("customerId"), customerId);
}
public static Specification<Order> createdAfter(Instant date) {
return (root, query, cb) -> cb.greaterThan(root.get("createdAt"), date);
}
public static Specification<Order> totalGreaterThan(Money amount) {
return (root, query, cb) -> cb.greaterThan(
root.get("total").get("amount"),
amount.amount()
);
}
}
// Usage
List<Order> orders = orderRepository.findAll(
hasStatus(SUBMITTED)
.and(belongsToCustomer(customerId))
.and(createdAfter(lastWeek))
);Kotlin
object OrderSpecifications {
fun hasStatus(status: OrderStatus) = Specification<Order> { root, _, cb ->
cb.equal(root.get<OrderStatus>("status"), status)
}
fun belongsToCustomer(customerId: CustomerId) = Specification<Order> { root, _, cb ->
cb.equal(root.get<CustomerId>("customerId"), customerId)
}
fun createdAfter(date: Instant) = Specification<Order> { root, _, cb ->
cb.greaterThan(root.get("createdAt"), date)
}
}
// Usage with Kotlin and()
val orders = orderRepository.findAll(
hasStatus(SUBMITTED) and belongsToCustomer(customerId) and createdAfter(lastWeek)
)EntityGraph Strategies
Annotation-Based
@EntityGraph(attributePaths = {"lines", "lines.product"})
Optional<Order> findWithLinesById(Long id);
// Named graph defined on entity
@NamedEntityGraph(
name = "Order.withLines",
attributeNodes = @NamedAttributeNode(value = "lines", subgraph = "lines-product"),
subgraphs = @NamedSubgraph(name = "lines-product", attributeNodes = @NamedAttributeNode("product"))
)
@Entity
public class Order { ... }
// Usage
@EntityGraph(value = "Order.withLines")
Optional<Order> findWithGraphById(Long id);Programmatic EntityGraph
@Repository
public class OrderRepositoryCustomImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public Optional<Order> findWithDynamicGraph(Long id, String... attributePaths) {
EntityGraph<Order> graph = em.createEntityGraph(Order.class);
for (String path : attributePaths) {
graph.addAttributeNodes(path);
}
Map<String, Object> hints = Map.of("jakarta.persistence.fetchgraph", graph);
return Optional.ofNullable(em.find(Order.class, id, hints));
}
}Batch Operations
// Batch insert - configure in properties
// spring.jpa.properties.hibernate.jdbc.batch_size=50
// spring.jpa.properties.hibernate.order_inserts=true
@Transactional
public void createOrders(List<Order> orders) {
for (int i = 0; i < orders.size(); i++) {
repository.save(orders.get(i));
if (i % 50 == 0) {
repository.flush();
entityManager.clear();
}
}
}
// Bulk update (bypasses entity lifecycle)
@Modifying(clearAutomatically = true)
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int bulkUpdateStatus(@Param("ids") List<Long> ids, @Param("status") OrderStatus status);Pagination
// Controller
@GetMapping
public Page<OrderSummary> list(
@RequestParam(defaultValue = "SUBMITTED") OrderStatus status,
@PageableDefault(size = 20, sort = "createdAt", direction = DESC) Pageable pageable
) {
return orderRepository.findSummaryByStatus(status, pageable);
}
// Custom pageable
Pageable pageable = PageRequest.of(0, 20, Sort.by(DESC, "createdAt", "id"));
// Slice (no count query - more efficient for infinite scroll)
Slice<Order> findSliceByStatus(OrderStatus status, Pageable pageable);Custom Repository Implementation
// Custom interface
public interface OrderRepositoryCustom {
List<Order> findWithComplexCriteria(OrderSearchCriteria criteria);
}
// Implementation (naming convention: {RepositoryName}Impl)
@Repository
public class OrderRepositoryImpl implements OrderRepositoryCustom {
@PersistenceContext
private EntityManager em;
@Override
public List<Order> findWithComplexCriteria(OrderSearchCriteria criteria) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> root = query.from(Order.class);
List<Predicate> predicates = new ArrayList<>();
if (criteria.status() != null) {
predicates.add(cb.equal(root.get("status"), criteria.status()));
}
if (criteria.minTotal() != null) {
predicates.add(cb.ge(root.get("total").get("amount"), criteria.minTotal()));
}
query.where(predicates.toArray(new Predicate[0]));
query.orderBy(cb.desc(root.get("createdAt")));
return em.createQuery(query)
.setMaxResults(criteria.limit())
.getResultList();
}
}
// Main repository extends custom
public interface OrderRepository extends JpaRepository<Order, Long>, OrderRepositoryCustom {
// ...
}Transaction Management
Transaction patterns for aggregate consistency in DDD.
Table of Contents
- Core Principle
- @Transactional Basics
- Propagation Levels
- Propagation Examples
- Isolation Levels
- Rollback Behavior
- Cross-Aggregate Consistency
- Java
- Kotlin
- TransactionalEventListener Phases
- Optimistic Locking
- Testing Transactions
- Common Mistakes
- Self-Invocation Fix
- Programmatic Transactions
Core Principle
One aggregate = one transaction. Cross-aggregate consistency achieved via domain events.
@Transactional Basics
@Service
@Transactional // Class-level default
public class OrderService {
// Inherits class-level @Transactional (REQUIRED)
public Order createOrder(CreateOrderCommand cmd) {
Order order = new Order(cmd.customerId());
return orderRepository.save(order);
}
// Override for read-only (performance optimization)
@Transactional(readOnly = true)
public OrderDto findById(Long id) {
return orderRepository.findById(id)
.map(OrderDto::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
// Explicit propagation for nested calls
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(Long orderId, PaymentDetails payment) {
// Runs in separate transaction
}
}Propagation Levels
| Level | Behavior | Use When |
|---|---|---|
REQUIRED (default) | Join existing or create new | Standard operations |
REQUIRES_NEW | Always new, suspend existing | Independent operation (audit log, payment) |
SUPPORTS | Use if exists, otherwise non-tx | Read that's often called from tx |
NOT_SUPPORTED | Non-transactional, suspend existing | Long-running reads |
MANDATORY | Require existing, fail otherwise | Must be called within tx |
NEVER | Fail if tx exists | Validation that must not be in tx |
NESTED | Nested tx with savepoint | Partial rollback (JDBC only) |
Propagation Examples
@Service
public class OrderService {
private final PaymentService paymentService;
private final AuditService auditService;
@Transactional
public void submitOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.submit();
orderRepository.save(order);
// Payment in separate tx - if it fails, order still submitted
paymentService.processPayment(order.getId(), order.getTotal());
// Audit always succeeds independently
auditService.log("ORDER_SUBMITTED", order.getId());
}
}
@Service
public class PaymentService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(Long orderId, Money amount) {
// Independent transaction
// Failure here doesn't rollback the order
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String action, Long entityId) {
// Always succeeds, never affects caller's tx
}
}Isolation Levels
| Level | Dirty Read | Non-Repeatable Read | Phantom Read | Performance |
|---|---|---|---|---|
READ_UNCOMMITTED | ✓ | ✓ | ✓ | Fastest |
READ_COMMITTED | ✗ | ✓ | ✓ | Default (most DBs) |
REPEATABLE_READ | ✗ | ✗ | ✓ | Good |
SERIALIZABLE | ✗ | ✗ | ✗ | Slowest |
// Use higher isolation for financial operations
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transferFunds(AccountId from, AccountId to, Money amount) {
// Consistent reads within transaction
}
// Use serializable for inventory checks
@Transactional(isolation = Isolation.SERIALIZABLE)
public void reserveStock(ProductId productId, int quantity) {
// Prevents phantom reads during stock check
}Rollback Behavior
@Transactional
public class OrderService {
// Default: rollback on RuntimeException, commit on checked
public void defaultBehavior() {
throw new RuntimeException("Rolls back");
}
// Explicit rollback on checked exception
@Transactional(rollbackFor = PaymentFailedException.class)
public void withCheckedRollback() throws PaymentFailedException {
throw new PaymentFailedException("Rolls back");
}
// No rollback on specific runtime exception
@Transactional(noRollbackFor = OptimisticLockingFailureException.class)
public void withNoRollback() {
// Retry logic handles this, don't rollback
}
}Cross-Aggregate Consistency
Use domain events for eventual consistency across aggregates.
Java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public void submitOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.submit(); // Registers OrderSubmitted event
Order saved = orderRepository.save(order);
// Events dispatched after save, within same tx
// AbstractAggregateRoot handles this automatically
}
}
// Event handler in different module
@Component
public class InventoryEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderSubmitted(OrderSubmitted event) {
// Runs after order tx commits
// In separate tx (eventual consistency)
inventoryService.reserveStock(event.orderLines());
}
}Kotlin
@Service
class OrderService(
private val orderRepository: OrderRepository
) {
@Transactional
fun submitOrder(orderId: Long) {
val order = orderRepository.findById(orderId)
.orElseThrow { OrderNotFoundException(orderId) }
order.submit()
orderRepository.save(order)
}
}
@Component
class InventoryEventHandler(private val inventoryService: InventoryService) {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun handle(event: OrderSubmitted) {
inventoryService.reserveStock(event.orderLines)
}
}TransactionalEventListener Phases
| Phase | When | Use Case |
|---|---|---|
BEFORE_COMMIT | Before tx commits | Validation, last-minute changes |
AFTER_COMMIT | After successful commit | Notifications, external calls |
AFTER_ROLLBACK | After tx rollback | Cleanup, alerts |
AFTER_COMPLETION | After commit or rollback | Logging |
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendNotification(OrderSubmitted event) {
// Only runs if order saved successfully
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleFailure(OrderSubmitted event) {
// Cleanup if tx rolled back
log.warn("Order submission failed: {}", event.orderId());
}Optimistic Locking
Prevent lost updates with version field.
@Entity
public class Order {
@Version
private Long version;
// ...
}
// Handle in service
@Transactional
public void updateOrder(Long orderId, UpdateOrderCommand cmd) {
try {
Order order = orderRepository.findById(orderId).orElseThrow();
order.update(cmd);
orderRepository.save(order);
} catch (OptimisticLockingFailureException e) {
throw new ConcurrentModificationException("Order was modified by another user");
}
}Testing Transactions
@DataJpaTest
@Transactional // Each test runs in tx, rolled back after
class OrderRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private OrderRepository orderRepository;
@Test
void savesOrderWithLines() {
Order order = new Order(CustomerId.generate());
order.addLine(ProductId.generate(), 2, Money.of(10));
orderRepository.save(order);
entityManager.flush();
entityManager.clear(); // Force reload from DB
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertThat(found.getLines()).hasSize(1);
}
}Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
@Transactional on private method | Proxy can't intercept | Use public methods |
| Self-invocation within class | Bypasses proxy | Inject self or extract to another service |
| Long-running tx with user input | Connection held, locks held | Fetch, return, then start new tx |
Missing readOnly = true | Unnecessary flush checks | Add to read operations |
| Catching exception inside tx | Swallows rollback trigger | Let exception propagate or explicit rollback |
Self-Invocation Fix
@Service
public class OrderService {
@Lazy
@Autowired
private OrderService self; // Inject proxy
@Transactional
public void processOrders(List<Long> orderIds) {
for (Long id : orderIds) {
self.processOrder(id); // Through proxy, tx works
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processOrder(Long orderId) {
// Each order in its own tx
}
}Programmatic Transactions
When annotation-based isn't flexible enough:
@Service
public class OrderService {
private final TransactionTemplate txTemplate;
public OrderService(PlatformTransactionManager txManager) {
this.txTemplate = new TransactionTemplate(txManager);
this.txTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
}
public Order processWithRetry(Long orderId) {
return txTemplate.execute(status -> {
try {
Order order = orderRepository.findById(orderId).orElseThrow();
order.process();
return orderRepository.save(order);
} catch (OptimisticLockingFailureException e) {
status.setRollbackOnly();
throw e;
}
});
}
}Spring Boot Data Layer Troubleshooting
Common issues and solutions for DDD with Spring Data.
Common Issues
Issue: Aggregate Root Not Persisting Child Entities
Symptom: Child entities (OrderLine) not saved when saving aggregate root
Cause: Missing cascade configuration or incorrect mapping
Solution:
// Wrong - no cascade
@OneToMany
private Set<OrderLine> lines;
// Correct - cascade and orphan removal
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "order_id") // Unidirectional from aggregate root
private Set<OrderLine> lines = new HashSet<>();For bidirectional (if needed):
// Parent side
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<OrderLine> lines = new HashSet<>();
// Child side
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
// Helper method in parent
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}---
Issue: N+1 Query Problems with Lazy Loading
Symptom: Multiple SELECT queries for each associated entity
Cause: Accessing lazy-loaded collections outside transaction or in loop
Solution:
1. Use @EntityGraph for known fetch patterns:
@EntityGraph(attributePaths = {"lines", "lines.product"})
Optional<Order> findWithLinesById(Long id);2. Or use JPQL JOIN FETCH:
@Query("SELECT o FROM Order o JOIN FETCH o.lines WHERE o.id = :id")
Optional<Order> findWithLinesById(@Param("id") Long id);3. Or use @BatchSize for bulk fetching:
@OneToMany(cascade = CascadeType.ALL)
@BatchSize(size = 25) // Fetch 25 at a time
private Set<OrderLine> lines;---
Issue: Value Object Mapping Issues
Symptom: @Embedded value object fields not mapped correctly
Cause: Column name conflicts or null handling
Solution:
1. Override column names if needed:
@Embedded
@AttributeOverride(name = "value", column = @Column(name = "customer_id"))
private CustomerId customerId;2. Handle null embedded objects:
// By default, if all fields are null, embedded object is null
// Force instantiation with @Embeddable defaults:
@Embeddable
public record Address(
@Column(name = "street") String street,
@Column(name = "city") String city
) {
public Address() {
this("", ""); // Default constructor for JPA
}
}3. For multiple value objects of same type:
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount", column = @Column(name = "subtotal_amount")),
@AttributeOverride(name = "currency", column = @Column(name = "subtotal_currency"))
})
private Money subtotal;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount", column = @Column(name = "total_amount")),
@AttributeOverride(name = "currency", column = @Column(name = "total_currency"))
})
private Money total;---
Issue: Transaction Boundary Errors
Symptom: LazyInitializationException after method returns
Cause: Accessing lazy-loaded data outside transaction scope
Solution:
1. Fetch everything needed within transaction:
@Transactional
public OrderDto findById(Long id) {
Order order = orders.findWithLinesById(id).orElseThrow();
// Convert to DTO while still in transaction
return OrderDto.from(order); // Access all lazy collections here
}2. Use Open Session in View (not recommended for APIs):
# Only for traditional web apps, NOT REST APIs
spring.jpa.open-in-view: true # Default, causes N+1 issues
spring.jpa.open-in-view: false # Recommended for APIs3. Initialize lazy collections explicitly:
@Transactional
public Order findById(Long id) {
Order order = orders.findById(id).orElseThrow();
Hibernate.initialize(order.getLines()); // Force loading
return order;
}---
Issue: @Transactional on Private Methods Not Working
Symptom: Transaction not applied, no rollback on exception
Cause: Spring AOP proxy limitation
Solution:
// Wrong - private method, proxy can't intercept
@Transactional
private void processOrder(Long id) { ... } // Transaction NOT applied!
// Wrong - internal call bypasses proxy
@Service
public class OrderService {
public void process(Long id) {
// Direct call - no proxy!
doProcess(id);
}
@Transactional
public void doProcess(Long id) { ... } // NOT transactional when called internally!
}
// Correct - public method, called through proxy
@Service
@Transactional
public class OrderService {
public void process(Long id) { ... } // Transactional
}
// Or inject self for internal calls
@Service
public class OrderService {
@Autowired
private OrderService self; // Proxy-aware reference
public void process(Long id) {
self.doProcess(id); // Goes through proxy
}
@Transactional
public void doProcess(Long id) { ... }
}---
Issue: Domain Events Not Published
Symptom: registerEvent() called but event handlers not triggered
Cause: Entity not saved through repository, or events not flushed
Solution:
@Service
@Transactional
public class OrderService {
public void submit(Long orderId) {
Order order = orders.findById(orderId).orElseThrow();
order.submit(); // Calls registerEvent()
// MUST save through repository to publish events
orders.save(order); // Events published here!
}
}Events are published when: 1. save() or saveAndFlush() is called 2. Transaction commits 3. @TransactionalEventListener phase matches (default: AFTER_COMMIT)
---
Spring Boot 4 Migration Issues
JSpecify Null-Safety
// Boot 4 supports JSpecify annotations
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked // All params/returns non-null by default
@Service
public class OrderService {
public @Nullable Order findByIdOrNull(Long id) {
return orders.findById(id).orElse(null);
}
}Jakarta EE 11 Namespaces
// Before (Boot 3.x / Jakarta EE 10)
import jakarta.persistence.*;
import jakarta.validation.*;
// Same in Boot 4.x / Jakarta EE 11 - no change needed
import jakarta.persistence.*;
import jakarta.validation.*;AOT Repository Compilation
Enabled by default in Boot 4. No configuration needed, but be aware:
- Query methods compile to source code at build time
- Faster startup, but changes require rebuild
- Some dynamic query features may behave differently
Spring Boot Data Layer Workflow
Detailed step-by-step process for implementing DDD patterns with Spring Data.
---
Step 1: Define Aggregate Root
Design and implement the aggregate root entity.
1a. Choose Technology
| Choose | When |
|---|---|
| Spring Data JPA | Complex queries, existing Hibernate expertise, lazy loading needed |
| Spring Data JDBC | DDD-first design, simpler mapping, no lazy loading surprises |
Spring Data JDBC enforces aggregate boundaries naturally — recommended for new DDD projects.
1b. Create the Aggregate Root
Extend AbstractAggregateRoot<T> for domain event support:
@Entity
public class Order extends AbstractAggregateRoot<Order> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private CustomerId customerId; // Value object reference by ID
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
@Enumerated(EnumType.STRING)
private OrderStatus status;
// Domain method that registers an event
public void place() {
if (this.lines.isEmpty()) {
throw new IllegalStateException("Cannot place empty order");
}
this.status = OrderStatus.PLACED;
registerEvent(new OrderPlaced(this.id));
}
}1c. Apply Aggregate Design Rules
- [ ] Root entity controls all access to child entities
- [ ] External references use IDs only (not object references)
- [ ] Invariants are enforced within the aggregate
- [ ] Keep aggregates small (~70% should be root + value objects)
Output: Aggregate root entity with domain methods and event registration.
---
Step 2: Map Value Objects
Implement immutable value objects for domain concepts.
2a. Simple Value Objects with @Embedded
@Embeddable
public record Money(
@Column(name = "amount") BigDecimal amount,
@Column(name = "currency") String currency
) {
public Money {
Objects.requireNonNull(amount, "Amount cannot be null");
Objects.requireNonNull(currency, "Currency cannot be null");
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(this.amount.add(other.amount), this.currency);
}
}2b. Complex Value Objects with @Converter
For value objects that need custom serialization:
@Converter(autoApply = true)
public class EmailConverter implements AttributeConverter<Email, String> {
@Override
public String convertToDatabaseColumn(Email email) {
return email == null ? null : email.value();
}
@Override
public Email convertToEntityAttribute(String value) {
return value == null ? null : new Email(value);
}
}2c. Strongly-Typed IDs
Prevent primitive obsession:
@Embeddable
public record OrderId(@Column(name = "id") Long value) {
public OrderId {
Objects.requireNonNull(value, "OrderId cannot be null");
}
}Output: Immutable value objects mapped to database columns.
---
Step 3: Create Repository Interface
Define one repository per aggregate root.
3a. Choose Base Interface
| Interface | Returns | Use When |
|---|---|---|
ListCrudRepository<T, ID> | List<T> | Standard CRUD (recommended for Boot 4) |
ListPagingAndSortingRepository<T, ID> | List<T> | Need pagination and sorting |
JpaRepository<T, ID> | List<T> | Need JPA-specific features (flush, batch) |
3b. Define Repository
public interface OrderRepository extends ListCrudRepository<Order, Long> {
@EntityGraph(attributePaths = {"lines", "lines.product"})
Optional<Order> findById(Long id);
List<Order> findByCustomerIdAndStatus(CustomerId customerId, OrderStatus status);
}3c. AOT Compilation Notes (Spring Boot 4)
- Repository proxies are generated at build time by default
- Custom query methods work with AOT
@Queryannotations are fully supported
Output: Repository interface with optimized queries.
---
Step 4: Implement Service Layer
Create application services with proper transactional boundaries.
4a. Define Service
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Order placeOrder(PlaceOrderCommand command) {
Order order = new Order(command.customerId());
command.items().forEach(item ->
order.addLine(item.productId(), item.quantity(), item.price())
);
order.place(); // Registers domain event
return orderRepository.save(order); // Saves and dispatches events
}
@Transactional(readOnly = true)
public Optional<Order> findOrder(Long id) {
return orderRepository.findById(id);
}
}4b. Transaction Rules
| Rule | Implementation |
|---|---|
| One aggregate per transaction | Each service method modifies ONE aggregate root |
| Read-only for queries | @Transactional(readOnly = true) on read methods |
| Events after commit | Domain events dispatch after transaction commits |
| Cross-aggregate via events | Use @ApplicationModuleListener for cross-aggregate work |
4c. Domain Event Flow
Service.save(aggregate)
-> Repository.save()
-> JPA flush
-> Transaction commits
-> Domain events dispatched
-> @ApplicationModuleListener handlers executeOutput: Application service with transactional boundaries and event dispatch.
---
Step 5: Add Projections
Create read-optimized views for query operations.
5a. Interface-Based Projections
public interface OrderSummary {
Long getId();
OrderStatus getStatus();
@Value("#{target.lines.size()}")
int getLineCount();
}
// In repository
List<OrderSummary> findByStatus(OrderStatus status);5b. Record-Based DTOs
public record OrderDto(Long id, String status, List<OrderLineDto> lines) {
public static OrderDto from(Order order) {
return new OrderDto(
order.getId(),
order.getStatus().name(),
order.getLines().stream().map(OrderLineDto::from).toList()
);
}
}5c. EntityGraph for N+1 Prevention
@EntityGraph(attributePaths = {"lines", "lines.product"})
List<Order> findByCustomerId(Long customerId);Use @EntityGraph instead of FetchType.EAGER — it is explicit and query-specific.
Output: Read-optimized projections for different use cases.
---
Verification Checklist
After implementing the data layer:
- [ ] One repository per aggregate root (never for child entities)
- [ ] Value objects are immutable (records or final fields)
- [ ]
@Transactionalon public service methods - [ ]
readOnly = trueon query methods - [ ]
@EntityGraphused instead ofFetchType.EAGER - [ ] Domain events registered in aggregate methods
- [ ] Tests with
@DataJpaTestcover repository queries