domainType, Pageable pageable);
+
+ /**
+ * Deletes a single Aggregate including all entities contained in that aggregate.
+ *
+ * Since no version attribute is provided this method will never throw a
+ * {@link org.springframework.dao.OptimisticLockingFailureException}. If no rows match the generated delete operation
+ * this fact will be silently ignored.
+ *
+ *
+ * @param id the id of the aggregate root of the aggregate to be deleted. Must not be {@code null}.
+ * @param domainType the type of the aggregate root.
+ * @param the type of the aggregate root.
+ */
+ void deleteById(Object id, Class domainType);
+
+ /**
+ * Deletes all aggregates identified by their aggregate root ids.
+ *
+ * Since no version attribute is provided this method will never throw a
+ * {@link org.springframework.dao.OptimisticLockingFailureException}. If no rows match the generated delete operation
+ * this fact will be silently ignored.
+ *
+ *
+ * @param ids the ids of the aggregate roots of the aggregates to be deleted. Must not be {@code null}.
+ * @param domainType the type of the aggregate root.
+ * @param the type of the aggregate root.
+ */
+ void deleteAllById(Iterable> ids, Class domainType);
+
+ /**
+ * Delete an aggregate identified by its aggregate root.
+ *
+ * @param aggregateRoot to delete. Must not be {@code null}.
+ * @param the type of the aggregate root.
+ */
+ void delete(T aggregateRoot);
+
+ /**
+ * Delete all aggregates of a given type.
+ *
+ * @param domainType type of the aggregate roots to be deleted. Must not be {@code null}.
+ */
+ void deleteAll(Class> domainType);
+
+ /**
+ * Delete all aggregates identified by their aggregate roots.
+ *
+ * @param aggregateRoots to delete. Must not be {@code null}.
+ * @param the type of the aggregate roots.
+ */
+ void deleteAll(Iterable extends T> aggregateRoots);
+}
diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java
new file mode 100644
index 0000000000..928a18fcd6
--- /dev/null
+++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/JdbcAggregateTemplate.java
@@ -0,0 +1,686 @@
+/*
+ * Copyright 2017-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.data.jdbc.core;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
+
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jdbc.core.convert.DataAccessStrategy;
+import org.springframework.data.jdbc.core.convert.JdbcConverter;
+import org.springframework.data.mapping.IdentifierAccessor;
+import org.springframework.data.mapping.callback.EntityCallbacks;
+import org.springframework.data.relational.core.EntityLifecycleEventDelegate;
+import org.springframework.data.relational.core.conversion.AggregateChange;
+import org.springframework.data.relational.core.conversion.BatchingAggregateChange;
+import org.springframework.data.relational.core.conversion.DeleteAggregateChange;
+import org.springframework.data.relational.core.conversion.MutableAggregateChange;
+import org.springframework.data.relational.core.conversion.RelationalEntityDeleteWriter;
+import org.springframework.data.relational.core.conversion.RelationalEntityInsertWriter;
+import org.springframework.data.relational.core.conversion.RelationalEntityUpdateWriter;
+import org.springframework.data.relational.core.conversion.RelationalEntityVersionUtils;
+import org.springframework.data.relational.core.conversion.RootAggregateChange;
+import org.springframework.data.relational.core.mapping.RelationalMappingContext;
+import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
+import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
+import org.springframework.data.relational.core.mapping.event.*;
+import org.springframework.data.relational.core.query.Query;
+import org.springframework.data.support.PageableExecutionUtils;
+import org.springframework.data.util.Streamable;
+import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+
+/**
+ * {@link JdbcAggregateOperations} implementation, storing aggregates in and obtaining them from a JDBC data store.
+ *
+ * @author Jens Schauder
+ * @author Mark Paluch
+ * @author Thomas Lang
+ * @author Christoph Strobl
+ * @author Milan Milanov
+ * @author Myeonghyeon Lee
+ * @author Chirag Tailor
+ * @author Diego Krupitza
+ * @author Sergey Korotaev
+ */
+public class JdbcAggregateTemplate implements JdbcAggregateOperations {
+
+ private final EntityLifecycleEventDelegate eventDelegate = new EntityLifecycleEventDelegate();
+ private final RelationalMappingContext context;
+
+ private final RelationalEntityDeleteWriter jdbcEntityDeleteWriter;
+
+ private final DataAccessStrategy accessStrategy;
+ private final AggregateChangeExecutor executor;
+ private final JdbcConverter converter;
+
+ private EntityCallbacks entityCallbacks = EntityCallbacks.create();
+
+ /**
+ * Creates a new {@link JdbcAggregateTemplate} given {@link ApplicationContext}, {@link RelationalMappingContext} and
+ * {@link DataAccessStrategy}.
+ *
+ * @param publisher must not be {@literal null}.
+ * @param context must not be {@literal null}.
+ * @param dataAccessStrategy must not be {@literal null}.
+ * @since 1.1
+ */
+ public JdbcAggregateTemplate(ApplicationContext publisher, RelationalMappingContext context, JdbcConverter converter,
+ DataAccessStrategy dataAccessStrategy) {
+
+ Assert.notNull(publisher, "ApplicationContext must not be null");
+ Assert.notNull(context, "RelationalMappingContext must not be null");
+ Assert.notNull(converter, "RelationalConverter must not be null");
+ Assert.notNull(dataAccessStrategy, "DataAccessStrategy must not be null");
+
+ this.eventDelegate.setPublisher(publisher);
+ this.context = context;
+ this.accessStrategy = dataAccessStrategy;
+ this.converter = converter;
+
+ this.jdbcEntityDeleteWriter = new RelationalEntityDeleteWriter(context);
+
+ this.executor = new AggregateChangeExecutor(converter, accessStrategy);
+
+ setEntityCallbacks(EntityCallbacks.create(publisher));
+ }
+
+ /**
+ * Creates a new {@link JdbcAggregateTemplate} given {@link ApplicationEventPublisher},
+ * {@link RelationalMappingContext} and {@link DataAccessStrategy}.
+ *
+ * @param publisher must not be {@literal null}.
+ * @param context must not be {@literal null}.
+ * @param dataAccessStrategy must not be {@literal null}.
+ */
+ public JdbcAggregateTemplate(ApplicationEventPublisher publisher, RelationalMappingContext context,
+ JdbcConverter converter, DataAccessStrategy dataAccessStrategy) {
+
+ Assert.notNull(publisher, "ApplicationEventPublisher must not be null");
+ Assert.notNull(context, "RelationalMappingContext must not be null");
+ Assert.notNull(converter, "RelationalConverter must not be null");
+ Assert.notNull(dataAccessStrategy, "DataAccessStrategy must not be null");
+
+ this.eventDelegate.setPublisher(publisher);
+ this.context = context;
+ this.accessStrategy = dataAccessStrategy;
+ this.converter = converter;
+
+ this.jdbcEntityDeleteWriter = new RelationalEntityDeleteWriter(context);
+ this.executor = new AggregateChangeExecutor(converter, accessStrategy);
+ }
+
+ /**
+ * Sets the callbacks to be invoked on life cycle events.
+ *
+ * @param entityCallbacks must not be {@literal null}.
+ * @since 1.1
+ */
+ public void setEntityCallbacks(EntityCallbacks entityCallbacks) {
+
+ Assert.notNull(entityCallbacks, "Callbacks must not be null");
+
+ this.entityCallbacks = entityCallbacks;
+ }
+
+ /**
+ * Configure whether lifecycle events such as {@link AfterSaveEvent}, {@link BeforeSaveEvent}, etc. should be
+ * published or whether emission should be suppressed. Enabled by default.
+ *
+ * @param enabled {@code true} to enable entity lifecycle events; {@code false} to disable entity lifecycle events.
+ * @since 3.0
+ * @see AbstractRelationalEvent
+ */
+ public void setEntityLifecycleEventsEnabled(boolean enabled) {
+ this.eventDelegate.setEventsEnabled(enabled);
+ }
+
+ @Override
+ public T save(T instance) {
+
+ Assert.notNull(instance, "Aggregate instance must not be null");
+
+ verifyIdProperty(instance);
+
+ return performSave(new EntityAndChangeCreator<>(instance, changeCreatorSelectorForSave(instance)));
+ }
+
+ @Override
+ public List saveAll(Iterable instances) {
+ return doInBatch(instances, (first) -> (second -> changeCreatorSelectorForSave(first).apply(second)));
+ }
+
+ /**
+ * Dedicated insert function to do just the insert of an instance of an aggregate, including all the members of the
+ * aggregate.
+ *
+ * @param instance the aggregate root of the aggregate to be inserted. Must not be {@code null}.
+ * @return the saved instance.
+ */
+ @Override
+ public T insert(T instance) {
+
+ Assert.notNull(instance, "Aggregate instance must not be null");
+
+ return performSave(
+ new EntityAndChangeCreator<>(instance, entity -> createInsertChange(prepareVersionForInsert(entity))));
+ }
+
+ @Override
+ public List insertAll(Iterable instances) {
+ return doInBatch(instances, (__) -> (entity -> createInsertChange(prepareVersionForInsert(entity))));
+ }
+
+ /**
+ * Dedicated update function to do just an update of an instance of an aggregate, including all the members of the
+ * aggregate.
+ *
+ * @param instance the aggregate root of the aggregate to be inserted. Must not be {@code null}.
+ * @return the saved instance.
+ */
+ @Override
+ public T update(T instance) {
+
+ Assert.notNull(instance, "Aggregate instance must not be null");
+
+ return performSave(
+ new EntityAndChangeCreator<>(instance, entity -> createUpdateChange(prepareVersionForUpdate(entity))));
+ }
+
+ @Override
+ public List updateAll(Iterable instances) {
+ return doInBatch(instances, (__) -> (entity -> createUpdateChange(prepareVersionForUpdate(entity))));
+ }
+
+ private List doInBatch(Iterable instances,Function>> changeCreatorFunction) {
+
+ Assert.notNull(instances, "Aggregate instances must not be null");
+
+ if (!instances.iterator().hasNext()) {
+ return Collections.emptyList();
+ }
+
+ List> entityAndChangeCreators = new ArrayList<>();
+ for (T instance : instances) {
+ verifyIdProperty(instance);
+ entityAndChangeCreators.add(new EntityAndChangeCreator(instance, changeCreatorFunction.apply(instance)));
+ }
+ return performSaveAll(entityAndChangeCreators);
+ }
+
+ @Override
+ public long count(Class> domainType) {
+
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ return accessStrategy.count(domainType);
+ }
+
+ @Override
+ public long count(Query query, Class domainType) {
+ return accessStrategy.count(query, domainType);
+ }
+
+ @Override
+ public boolean exists(Query query, Class domainType) {
+ return accessStrategy.exists(query, domainType);
+ }
+
+ @Override
+ public boolean existsById(Object id, Class domainType) {
+
+ Assert.notNull(id, "Id must not be null");
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ return accessStrategy.existsById(id, domainType);
+ }
+
+ @Override
+ public T findById(Object id, Class domainType) {
+
+ Assert.notNull(id, "Id must not be null");
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ T entity = accessStrategy.findById(id, domainType);
+ if (entity == null) {
+ return null;
+ }
+ return triggerAfterConvert(entity);
+ }
+
+ @Override
+ public List findAll(Class domainType, Sort sort) {
+
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ Iterable all = accessStrategy.findAll(domainType, sort);
+ return triggerAfterConvert(all);
+ }
+
+ @Override
+ public Stream streamAll(Class domainType, Sort sort) {
+
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ Stream allStreamable = accessStrategy.streamAll(domainType, sort);
+
+ return allStreamable.map(this::triggerAfterConvert);
+ }
+
+ @Override
+ public Page findAll(Class domainType, Pageable pageable) {
+
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ Iterable items = triggerAfterConvert(accessStrategy.findAll(domainType, pageable));
+ List content = StreamSupport.stream(items.spliterator(), false).collect(Collectors.toList());
+
+ return PageableExecutionUtils.getPage(content, pageable, () -> accessStrategy.count(domainType));
+ }
+
+ @Override
+ public Optional findOne(Query query, Class domainType) {
+ return accessStrategy.findOne(query, domainType).map(this::triggerAfterConvert);
+ }
+
+ @Override
+ public List findAll(Query query, Class domainType) {
+
+ Iterable all = accessStrategy.findAll(query, domainType);
+
+ return triggerAfterConvert(all);
+ }
+
+ @Override
+ public Stream streamAll(Query query, Class domainType) {
+ return accessStrategy.streamAll(query, domainType).map(this::triggerAfterConvert);
+ }
+
+ @Override
+ public Page findAll(Query query, Class domainType, Pageable pageable) {
+
+ Iterable items = triggerAfterConvert(accessStrategy.findAll(query, domainType, pageable));
+ List content = StreamSupport.stream(items.spliterator(), false).collect(Collectors.toList());
+
+ return PageableExecutionUtils.getPage(content, pageable, () -> accessStrategy.count(query, domainType));
+ }
+
+ @Override
+ public List findAll(Class domainType) {
+
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ Iterable all = accessStrategy.findAll(domainType);
+ return triggerAfterConvert(all);
+ }
+
+ @Override
+ public Stream streamAll(Class domainType) {
+
+ Iterable items = triggerAfterConvert(accessStrategy.findAll(domainType));
+ return StreamSupport.stream(items.spliterator(), false).map(this::triggerAfterConvert);
+ }
+
+ @Override
+ public List findAllById(Iterable> ids, Class domainType) {
+
+ Assert.notNull(ids, "Ids must not be null");
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ Iterable allById = accessStrategy.findAllById(ids, domainType);
+ return triggerAfterConvert(allById);
+ }
+
+ @Override
+ public Stream streamAllByIds(Iterable> ids, Class domainType) {
+
+ Assert.notNull(ids, "Ids must not be null");
+ Assert.notNull(domainType, "Domain type must not be null");
+
+ Stream allByIdStreamable = accessStrategy.streamAllByIds(ids, domainType);
+
+ return allByIdStreamable.map(this::triggerAfterConvert);
+ }
+
+ @Override
+ public void delete(S aggregateRoot) {
+
+ Assert.notNull(aggregateRoot, "Aggregate root must not be null");
+
+ Class domainType = (Class