- #spring boot
- #distributed systems
- #jdbc
persistence layers from traditional Java EE monoliths to modern distributed, microservices-based architectures
Updating a single product review should not require rewriting hundreds of others. This article explores how aggregate boundaries affect Spring Data JDBC’s persistence behavior, why child IDs do not enable dirty tracking, and how modeling reviews as separate aggregate roots can reduce unnecessary writes while preserving consistency.
Oct 1, 2026 • 4 min read

Lets take a simple example, In a large e-commerce monolithic application a product entity may look like this.
@Table("product")
public Class Product{
@Id
String productId;
Manufacturer manufacturer;
String title;
/* here reviews are childs of products, which means
without products reviews are not exist. */
List<ProductReview> reviews;
}
@Table("product_reviews")
public class ProductReview {
@Id
private Long id;
private String comment;
private int rating;
}
sounds logical right, but the thing is , Product and product reviews have different life-cycles.
- A buyer can update Product review may updated after sometime.
- Manufacturer exists independently and a particular product may have several manufacturers.
In here, if someone later wants to update an review of the product , the code is written as follows.
productRepository.save(product);
In above case , under the hood spring JDBC executes following.
-- 1. Updates root
UPDATE product SET title = 'New Product Title' WHERE id = 1;
-- 2. DELETES ALL 500 REVIEWS
DELETE FROM product_reviews WHERE product_id = 1;
-- 3. RE-INSERTS ALL 500 REVIEWS
INSERT INTO product_reviews (talk_id, comment, rating) VALUES (1, '...', 5);
-- ... (499 more inserts)
Because Spring Data JDBC does not track individual field changes, it treats the entire aggregate as a single atomic unit. On save(), it simply overwrites everything it manages
Here this is a huge performance bottleneck occurs, the write amplification is => 1 update + 1 delete + 500 inserts
and locked the contention : all the 500 rows and the parent table, starving concurrent transactions.
This can be easily avoided by separating the aggregates: If there were no strict business requirement, this can be updated as follows inside a single Atomic ACID transaction by splitting into two independent roots linked by id.
// Aggregate Root 1
@Table("products")
public class Products {
@Id
private Long id;
private String title;
public Product(String title) {
this.title = title;
}
}
// Aggregate Root 2: Manages its own lifecycle and table independently
@Table("product_reviews")
public class ProductReview {
@Id
private String id;
private Long productId; // Direct reference by ID
private String comment;
private int rating;
public ProductReview(Long productId, String comment, int rating) {
this.productId = productId;
this.comment = comment;
this.rating = rating;
}
}
and can have dedicated repositories for each one,
public interface ProductRepository extends CrudRepository<Product,String> {}
public interface ProductReviewRepository extends CrudRepository<ProductReview, Long> {
List<ProductReview> findByProductId(Long productId);
}
Here now adding or updating new review only operates on that specific row.
@Service
public class ReviewService {
private final ProductReviewRepository reviewRepository;
public ReviewService(ProductReviewRepository reviewRepository) {
this.reviewRepository = reviewRepository;
}
@Transactional
public void submitReview(Long productId, String comment, int rating) {
// Only touches 1 row in product_reviews, no locks on the parent 'products' table
ProductReview review = new ProductReview(productId, comment, rating);
reviewRepository.save(review);
}
}
under the hood, SQL execution happens as follows.
INSERT INTO product_reviews (product_id, comment, rating) VALUES (1, 'Great Product!', 5);
Here there are no cascading deletes, no massive # of inserts, and no lock on parent Product table.
Here is another way to design this .
// Without an ID, this is treated as a pure Value Object
public record ReviewValue(String comment, int rating) {}
@Table("products")
public class Product {
@Id
private String id;
// Lacks identity: the framework cannot pinpoint which item changed
private Set<ReviewValue> reviews;
}
Since ReviewValue has no id, updating rating required wiping entire set and replacing every child record in memory and storage.
Here now , it has id values:
@Table("product_reviews")
public class ProductReviewChild {
@Id
private String id; // Distinct identity lets the runtime track it
private String comment;
private int rating;
// Getters, setters, constructors
}
Having an explicit identity prevents ambiguity and allows future migration paths (like moving it out into its own aggregate root) without breaking data consistency.
References & further reads:
