Files
document-haness/docs/clean-architecture-backend-template/final/evidence/raw/043-persistence-jpa-transaction-wiring.txt
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

291 lines
22 KiB
Plaintext

Command: printf '%s\n' '--- persistence root ---'; sed -n '1,115p' src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/persistencejpa/PersistenceJpaRootAutoConfiguration.java; printf '%s\n' '--- adapter component scan ---'; cat src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java; printf '%s\n' '--- runtime transaction beans ---'; sed -n '140,195p' src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java; printf '%s\n' '--- canonical/application references ---'; git grep -n -E '\b(SpringTransactionPort|PolicyTransactionPort|FullTransactionRetryCoordinator|SpringJpaTransactionExecutor)\b' -- 'src/**/src/main/java/**/*.java'
Working directory: /shared/codebase/clean-architecture-backend-template
Executed at: 2026-08-29T08:01:16Z
Source revision: a24ece9cf797f7ea647e33bf846b115208ed1ba5
Observation boundary: Shows shipped persistence root imports, narrow adapter component scan, transaction bean factories, and exact production construction/reference sites for the two transaction stacks. Static wiring does not prove runtime invocation frequency.
--- stdout/stderr ---
--- persistence root ---
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
import dev.caskeleton.adapter.outbound.persistence.config.JpaAdapterComponentsConfig;
import dev.caskeleton.adapter.outbound.persistence.config.PersistenceVendorSettings;
import dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig;
import dev.caskeleton.adapter.outbound.persistence.h2.H2PersistenceConfig;
import dev.caskeleton.adapter.outbound.persistence.notification.configuration.NotificationJpaPersistenceFacade;
import dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlPersistenceConfig;
import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaDataSourceProfileValidator;
import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration;
import dev.caskeleton.bootstrap.autoconfigure.jpa.PostgreSqlVersionPolicy;
import dev.caskeleton.bootstrap.runtime.startup.MigrationStartupConfig;
import javax.sql.DataSource;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.core.env.Environment;
/**
* The one place that decides whether this application has relational persistence.
*
* <p>Every JPA configuration is reached through this import rather than through the component scan,
* so a bean added to any of them next month is gated without anyone remembering to repeat a
* condition. That is the difference between a capability that is off and a capability whose known
* beans are individually conditioned.
*
* <p>Both vendor configurations are named here, and each is the leaf\'s real entry point: a vendor
* config imports the core JPA config, not the reverse. Inverting that to give this class a single
* import produced a package cycle inside the leaf, so the export surface admits the two vendor
* packages instead. They are mutually exclusive on {@code ca-skeleton.persistence.vendor}, so
* naming both lets the selector decide which one assembles while the decision about
* <em>whether</em> there is a database stays here, in one place.
*
* <p><b>The notification facade is named here only now that the payload is protected at rest.</b>
* Importing it makes {@code JpaNotificationRequestStore} reachable, and that store used to write
* the accepted request's template variables to {@code notification_request.variables_payload} in
* plaintext — caller content that can be a reset code, an order total or an address (NTF-INT-007).
* It was wired here once before that was true; the at-rest contract test failed on exactly that
* fact, which is what it was written to do, and the wiring was reverted rather than the test
* relaxed. The envelope came first, {@code NotificationRecordMapper} now takes the protection as a
* required constructor argument, and only then does this import become a wiring rather than an
* exposure.
*
* <p>{@code FileserverJpaPersistenceConfig} is named for a related reason. Its six entities map six
* {@code fs_*} tables that live only in {@code db/migration/jpa/fileserver}, a stream applied only
* when that capability is on, while the primary Flyway location creates none of them. They used to
* be in the unconditional entity scan, so {@code ddl-auto=validate} against real PostgreSQL failed
* on {@code fs_cleanup_item} in every deployment that had switched the capability off — which was
* every JPA-on Compose lane. The scan now carries the capability's own condition, and this is the
* only place that knows both that the capability is on and that a JPA vendor is composed. Neither
* the leaf's {@code config} package nor a component scan can register it: {@code config} may depend
* on {@code api} alone, and the whole persistence package is excluded from the root's scan.
*/
@AutoConfiguration
@ConditionalOnProperty(
prefix = "ca-skeleton.persistence-jpa",
name = "enabled",
havingValue = "true")
@Import({
JpaAdapterComponentsConfig.class,
PostgreSqlPersistenceConfig.class,
H2PersistenceConfig.class,
FileserverJpaPersistenceConfig.class,
NotificationJpaPersistenceFacade.class,
MigrationStartupConfig.class,
JpaPlatformRuntimeAutoConfiguration.class
})
public class PersistenceJpaRootAutoConfiguration {
/**
* Runs the datasource validator, which nothing used to run (JPA-INT-002).
*
* <p>{@code JpaDataSourceProfileValidator} was created as a bean and never invoked: both of its
* methods were reachable only from their own unit test. A validator nobody calls is a comment —
* and this one would have failed every deployment if called, because the namespace it read was
* supplied by nothing. The two defects cancelled, which is the only reason the application
* started.
*
* <p>Invoked from here rather than from the platform configuration so it runs exactly when JPA is
* on and never when it is off, and as an {@link InitializingBean} so the failure lands during
* startup rather than at whoever sends the first request.
*
* <p>The vendor is read from the {@link Environment} rather than injected as {@code
* PersistenceVendorSettings}, because that type is not a registered bean here: the composition
* root's {@code @ConfigurationPropertiesScan} deliberately excludes the persistence package so an
* optional capability cannot bind — or reject — its detail settings in a deployment that never
* switched it on. The default matches {@code PostgreSqlPersistenceConfig}'s {@code matchIfMissing
* = true}: an unset selector is PostgreSQL, which is the vendor this configuration was
* unconditional about before the selector existed.
*
* <p>The validator is constructed here rather than injected. It is stateless, and the bean that
* used to supply it comes from {@code JpaPlatformRuntimeAutoConfiguration}, which carries
* {@code @ConditionalOnBean(DataSource.class)} on a plain {@code @Configuration} imported by this
* root — a condition evaluated during configuration-class parsing, before the datasource bean
* definition is registered. That class therefore drops out silently in the real application,
* taking the whole JPA add-on layer with it. Depending on a bean from it would make this check
* disappear for the same reason the thing it checks disappeared. The condition-ordering defect is
* recorded separately; it is not this task's to fix quietly.
*
* @param dataSource the resolved datasource, injected so the database checked is the one in use
* @param environment the resolved environment, for the vendor selector
* @return the startup check
*/
@Bean
public InitializingBean jpaResolvedDataSourceCheck(
DataSource dataSource, Environment environment) {
JpaDataSourceProfileValidator validator =
new JpaDataSourceProfileValidator(new PostgreSqlVersionPolicy());
String vendor =
environment.getProperty(PersistenceVendorSettings.VENDOR_PROPERTY, "postgresql").trim();
return () -> validator.validateResolved(dataSource, "postgresql".equalsIgnoreCase(vendor));
}
}
--- adapter component scan ---
package dev.caskeleton.adapter.outbound.persistence.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
/**
* Registers the JPA adapter's scanned components, which nothing registered (JPA-INT-006).
*
* <p>The composition root's {@code @ComponentScan} excludes {@code
* dev.caskeleton.adapter.outbound.persistence.**} by regex, and that exclusion is correct: it is
* what makes an optional capability optional, so a deployment with JPA off assembles no persistence
* beans rather than assembling them and hoping each one remembered to carry the switch.
*
* <p>What was missing is the other half. Eight classes in this leaf are written as scanned
* components — {@code SpringTransactionPort}, {@code PersistenceExceptionTranslator}, {@code
* StandardSqlStateErrorMapping}, {@code DomainContextAuditContextPort}, the idempotency store and
* its reaper, and the outbox store and its reaper — and once the broad scan stopped reaching them,
* nothing else did. They are annotated {@code @Component} and {@code @Repository} and were beans in
* no running application: {@code TransactionPort} in particular had no implementation at all, so
* every use case that opens a transaction had no port to open it with.
*
* <p>It surfaced as an unsatisfied dependency the first time a capability that needs a transaction
* was actually assembled — the notification orchestrator, in the local-notification-ingest lane —
* rather than as anything a unit test could see, because each of these classes is constructed
* directly by its own tests.
*
* <p>So the scan is restored, narrowed to the packages it should always have covered and reachable
* only through {@code PersistenceJpaRootAutoConfiguration}, which carries the JPA master switch.
* Off is still structural.
*
* <p>Two packages are deliberately absent:
*
* <ul>
* <li>{@code ..persistence.fileserver} — gated on its own capability switch, scanned by {@link
* dev.caskeleton.adapter.outbound.persistence.fileserver.FileserverJpaPersistenceConfig};
* <li>{@code ..persistence.notification} — assembled explicitly, bean by bean, by {@code
* NotificationJpaPersistenceFacade}.
* </ul>
*
* <p>Components under these packages keep their own {@code @ConditionalOnProperty} guards; being
* scanned makes them candidates, not unconditional beans.
*
* <p>{@code ..persistence.lock} is in the list for the same reason and with the same history.
* {@code DistributedLockPersistenceConfig} owns both lock providers — the in-process registry and
* the JDBC one — and was registered by nothing but a test calling {@code ctx.register(...)}. So a
* single-instance deployment had no {@code DistributedLockPort} at all, and a multi-instance one
* could not start: the composition root's own {@code DistributedLockConfig} asks for a bean
* qualified {@code jdbcDistributedLock} that only that configuration declares.
*
* <p>Each package's {@code @ConfigurationProperties} type is enabled by a configuration inside that
* same package — {@code JpaTransactionConfig} for {@code JpaTransactionSettings}, {@code
* DistributedLockPersistenceConfig} for {@code LockSettings} — rather than from here. Enabling them
* centrally would give {@code config} an edge to {@code lock} and {@code transaction} that the
* module map does not grant it, and the map is right: this class knows which packages to scan, not
* what is inside them. They need enabling at all because {@code @ConfigurationPropertiesScan}
* excludes this tree as deliberately as {@code @ComponentScan} does.
*/
@Configuration(proxyBeanMethods = false)
@ComponentScan(
basePackages = {
"dev.caskeleton.adapter.outbound.persistence.audit",
"dev.caskeleton.adapter.outbound.persistence.failure",
"dev.caskeleton.adapter.outbound.persistence.idempotency",
"dev.caskeleton.adapter.outbound.persistence.lock",
"dev.caskeleton.adapter.outbound.persistence.outbox",
"dev.caskeleton.adapter.outbound.persistence.transaction"
})
public class JpaAdapterComponentsConfig {}
--- runtime transaction beans ---
}
/**
* The programmatic transaction boundary, over whichever manager the application installed.
*
* <p>The composed vendor's translator is taken when one exists. Without it the executor's chain
* has no SQLSTATE stage, so a serialization failure or deadlock stays a raw {@code
* DataAccessException}, never becomes the {@code JpaPersistenceException} the retry coordinator
* catches, and contention is not retried at all.
*
* @param vendorFailures the composed vendor's SQLSTATE translator, when a vendor registered one
*/
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(PlatformTransactionManager.class)
public SpringJpaTransactionExecutor jpaTransactionExecutor(
PlatformTransactionManager transactionManager,
Clock clock,
ObjectProvider<VendorFailureTranslator> vendorFailures) {
JpaTransactionAutoConfiguration composition = new JpaTransactionAutoConfiguration(clock);
VendorFailureTranslator vendor = vendorFailures.getIfAvailable();
return vendor == null
? composition.transactionExecutor(transactionManager)
: composition.transactionExecutor(transactionManager, vendor);
}
/** The whole-transaction retry coordinator. */
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(SpringJpaTransactionExecutor.class)
public FullTransactionRetryCoordinator jpaRetryCoordinator(
SpringJpaTransactionExecutor executor, RetryEventListener retryListener, Clock clock) {
return new JpaTransactionAutoConfiguration(clock)
.retryCoordinator(
executor,
dev.caskeleton.adapter.outbound.persistence.api.transaction.RetryProfile
.boundedContention("jpa-platform-default", 3),
retryListener);
}
/**
* The retry listener.
*
* <p>The interface's methods all have defaults, so an empty implementation is the honest "no
* observer wired" value; an application that wants retry telemetry supplies its own bean.
*/
@Bean
@ConditionalOnMissingBean
public RetryEventListener jpaRetryEventListener() {
return new RetryEventListener() {};
}
/** Runs the dangerous-configuration guard during refresh. */
@Bean
public InitializingBean jpaPlatformStartupCheck(
JpaDangerousConfigurationGuard guard, Environment environment) {
--- canonical/application references ---
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/JpaAdapterComponentsConfig.java:15: * components — {@code SpringTransactionPort}, {@code PersistenceExceptionTranslator}, {@code
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java:30:public final class FullTransactionRetryCoordinator {
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java:32: private final SpringJpaTransactionExecutor transactionExecutor;
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java:50: public FullTransactionRetryCoordinator(
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/FullTransactionRetryCoordinator.java:51: SpringJpaTransactionExecutor transactionExecutor,
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/JpaTransactionConfig.java:9: * <p>{@link SpringTransactionPort} takes it as a constructor argument, and the composition root's
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:21: * <p>This class does not retry. Retry lives in {@link FullTransactionRetryCoordinator}, which calls
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:25:public final class SpringJpaTransactionExecutor implements JpaTransactionExecutor {
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:32: public SpringJpaTransactionExecutor(PlatformTransactionManager transactionManager, Clock clock) {
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringJpaTransactionExecutor.java:41: public SpringJpaTransactionExecutor(
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:8:import dev.caskeleton.application.transaction.PolicyTransactionPort;
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:26: * Spring-backed {@link PolicyTransactionPort}: one pre-built {@link TransactionTemplate} per mode,
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:31:public class SpringTransactionPort implements PolicyTransactionPort {
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:39: public SpringTransactionPort(PlatformTransactionManager transactionManager) {
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:49: SpringTransactionPort(
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:61: public SpringTransactionPort(
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:77: SpringTransactionPort(
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:90: SpringTransactionPort(
src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java:105: SpringTransactionPort(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:7:import dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator;
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:8:import dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor;
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:155: public SpringJpaTransactionExecutor jpaTransactionExecutor(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:169: @ConditionalOnBean(SpringJpaTransactionExecutor.class)
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:170: public FullTransactionRetryCoordinator jpaRetryCoordinator(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaPlatformRuntimeAutoConfiguration.java:171: SpringJpaTransactionExecutor executor, RetryEventListener retryListener, Clock clock) {
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:9:import dev.caskeleton.adapter.outbound.persistence.transaction.FullTransactionRetryCoordinator;
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:13:import dev.caskeleton.adapter.outbound.persistence.transaction.SpringJpaTransactionExecutor;
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:37: * PolicyTransactionPort.inTransaction(TransactionRequest, Supplier)}; the retry coordinator below
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:66: public SpringJpaTransactionExecutor transactionExecutor(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:68: return new SpringJpaTransactionExecutor(transactionManager, clock);
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:82: public SpringJpaTransactionExecutor transactionExecutor(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:84: return new SpringJpaTransactionExecutor(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:99: public FullTransactionRetryCoordinator retryCoordinator(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:100: SpringJpaTransactionExecutor executor,
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:107: public FullTransactionRetryCoordinator retryCoordinator(
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:108: SpringJpaTransactionExecutor executor, JpaRetryPolicy policy, RetryEventListener listener) {
src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/autoconfigure/jpa/JpaTransactionAutoConfiguration.java:109: return new FullTransactionRetryCoordinator(
src/application-core/src/main/java/dev/caskeleton/application/transaction/PolicyTransactionPort.java:10:public interface PolicyTransactionPort extends TransactionPort {
src/application-core/src/main/java/dev/caskeleton/application/transaction/TransactionPort.java:8: * {@code SpringTransactionPort}) implements it over Spring's {@code PlatformTransactionManager}.
Exit code: 0