In this guide (34 items)
Spring Boot
Spring Boot in Hexagonal Architecture
MANDATORY FOR ALL AI AGENTS: Spring Boot annotations and dependencies MUST be confined to infrastructure and bootstrap layers ONLY. The domain and application layers MUST remain 100% framework-agnostic. Use configuration classes for bean wiring, NEVER annotations on use cases. Transaction management MUST use the Transaction port pattern, NEVER
@Transactionalon use cases.
This guide covers Spring Boot-specific patterns and configurations for hexagonal architecture projects.
Core Principle
Keep Spring Boot confined to the infrastructure and bootstrap layers. Domain and application layers must remain framework-agnostic.
Module Dependencies
domain/ - No Spring dependencies - No framework dependencies
application/ - Depends on: domain only - No Spring dependencies - Defines ports (interfaces)
infrastructure/ - Depends on: application, domain - Spring Boot starters here - Implements ports
bootstrap/ - Depends on: all modules - Spring Boot Application - Configuration classesbuild.gradle.kts example:
dependencies { // No Spring dependencies}
// application/build.gradle.ktsdependencies { implementation(project(":domain")) // No Spring dependencies}
// infrastructure/driven-adapters/jdbc-repository/build.gradle.ktsdependencies { implementation(project(":application")) implementation(project(":domain")) implementation("org.springframework.boot:spring-boot-starter-data-jdbc") implementation("org.liquibase:liquibase-core")}
// bootstrap/build.gradle.ktsdependencies { implementation(project(":application")) implementation(project(":domain")) implementation(project(":infrastructure:entry-points:rest-controller")) implementation(project(":infrastructure:driven-adapters:jdbc-repository")) implementation(project(":infrastructure:driven-adapters:kafka-producer"))
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-actuator")}Configuration Strategy
Use Configuration Classes, Not Annotations on Use Cases
❌ BAD - Annotating use cases:
@Service // ❌ Framework coupling in application layer@Transactional // ❌ Framework annotation in application layerclass CreateOrganizationUseCase( private val repository: OrganizationRepositoryPort) : CreateOrganizationPort { override fun execute(command: CreateOrganizationCommand): Organization { return repository.save(command.toOrganization()) }}✅ GOOD - Configuration in bootstrap:
class CreateOrganizationUseCase( // No annotations private val transaction: TransactionPort<Organization>, private val repository: OrganizationRepositoryPort) : CreateOrganizationPort { override fun execute(command: CreateOrganizationCommand): Organization { return transaction.run { repository.save(command.toOrganization()) } }}
// bootstrap/config/UseCaseConfig.kt@Configurationclass UseCaseConfig {
@Bean fun createOrganizationUseCase( transaction: TransactionPort<Organization>, repository: OrganizationRepository ): CreateOrganizationPort { return CreateOrganizationUseCase(transaction, repository) }}Java:
public class CreateOrganizationUseCase implements CreateOrganizationPort {
private final TransactionPort<Organization> transaction; private final OrganizationRepositoryPort repository;
public CreateOrganizationUseCase(TransactionPort<Organization> transaction, OrganizationRepositoryPort repository) { this.transaction = transaction; this.repository = repository; }
@Override public Organization execute(CreateOrganizationCommand command) { return transaction.run(() -> repository.save(command.toOrganization())); }}
// bootstrap/config/UseCaseConfig.java@Configurationpublic class UseCaseConfig {
@Bean public CreateOrganizationPort createOrganizationUseCase( TransactionPort<Organization> transaction, OrganizationRepositoryPort repository) { return new CreateOrganizationUseCase(transaction, repository); }}Component Scanning Strategy
Scan by Package or Naming Convention
Option 1: Package-based scanning
⚠️ Anti-pattern in strict hexagonal: component scanning requires a stereotype on the use case (a framework annotation in the application layer). Use Option 2.
@Configuration@ComponentScan( basePackages = ["com.example.platform.usecase"], includeFilters = [ComponentScan.Filter( type = FilterType.REGEX, pattern = [".*UseCase$"] // Only classes ending with "UseCase" )], useDefaultFilters = false)class UseCaseConfigOption 2: Explicit bean definitions (recommended for clarity)
@Configurationclass UseCaseConfig( private val factory: BeanFactory) {
@Bean fun createOrganizationUseCase( transaction: TransactionPort<Organization>, repository: OrganizationRepositoryPort, outboxRepository: OutboxRepositoryPort, // from codehuntersio-sdk-event-messaging outboxEventFactory: OutboxEventFactory, // from codehuntersio-sdk-event-messaging ): CreateOrganizationPort { return CreateOrganizationUseCase(transaction, repository, outboxRepository, outboxEventFactory) }
@Bean @Primary fun organizationRepository(): OrganizationRepositoryPort { return buildChain(OrganizationRepositoryPort::class.java) }}Transaction Management Pattern
Define Transaction as Port (Application Layer)
fun interface TransactionPort<R> { fun run(callback: TransactionCallbackPort<R>): R}
fun interface TransactionCallbackPort<R> { fun call(): R}Implement with @Transactional (Infrastructure Layer)
// NOTE: no @Component on a generic parameterized class — Spring won't instantiate it as a bean.// Expose it by type via @Bean in bootstrap, or use a non-generic callback — not a global @Component on a generic class.class JdbcTransaction<R> : TransactionPort<R> {
@Transactional( propagation = Propagation.REQUIRED, rollbackFor = [Exception::class] // Rollback on ALL exceptions ) override fun run(callback: TransactionCallbackPort<R>): R { return try { callback.call() } catch (exception: SQLException) { log.error("Database error in transaction", exception) throw exception } catch (exception: Exception) { log.error("Unexpected error in transaction", exception) throw exception // re-throw original; rollbackFor=Exception handles rollback, no masking of OrderNotFoundException etc. } }}Java:
// NOTE: no @Component on a generic parameterized class — Spring won't instantiate it as a bean.// Expose it by type via @Bean in bootstrap, or use a non-generic callback — not a global @Component on a generic class.public class JdbcTransaction<R> implements TransactionPort<R> {
private static final Logger log = LoggerFactory.getLogger(JdbcTransaction.class);
@Transactional( propagation = Propagation.REQUIRED, rollbackFor = Exception.class // Rollback on ALL exceptions ) @Override public R run(TransactionCallbackPort<R> callback) { try { return callback.call(); } catch (SQLException exception) { log.error("Database error in transaction", exception); throw exception; } catch (Exception exception) { log.error("Unexpected error in transaction", exception); throw exception; // re-throw original; rollbackFor=Exception handles rollback, no masking of OrderNotFoundException etc. } }}Key Points:
rollbackFor = [Exception::class]ensures ALL exceptions trigger rollback (not just checked exceptions)- Only SQLException and RuntimeException trigger rollback by default in Spring
- Business exceptions should also rollback the transaction
Spring Data JDBC (Preferred over JPA)
Why Spring Data JDBC over JPA?
- Lighter weight, less “magic”
- No lazy loading issues
- No session/detached entity problems
- Explicit control over queries
- Better for hexagonal architecture (less coupling)
Entity Configuration
@Table("organizations")class OrganizationEntity : AuditableEntity(), Persistable<UUID> {
@Id @Column("id") private var id: UUID? = null
@Column("name") var name: String = ""
@Column("status") var status: String = ""
@Transient private var isNew: Boolean = true
override fun getId(): UUID? = id
override fun isNew(): Boolean = isNew
fun setId(id: UUID) { this.id = id }
fun markNotNew() { this.isNew = false }}Java:
@Table("organizations")public class OrganizationEntity extends AuditableEntity implements Persistable<UUID> {
@Id @Column("id") private UUID id;
@Column("name") private String name = "";
@Column("status") private String status = "";
@Transient private boolean isNew = true;
@Override public UUID getId() { return id; }
@Override public boolean isNew() { return isNew; }
public void setId(UUID id) { this.id = id; }
public void markNotNew() { this.isNew = false; }
public String getName() { return name; } public void setName(String name) { this.name = name; } public String getStatus() { return status; } public void setStatus(String status) { this.status = status; }}Auditable base class:
abstract class AuditableEntity {
@CreatedDate @Column("created_at") var createdAt: Instant? = null
@CreatedBy @Column("created_by") var createdBy: String? = null
@LastModifiedDate @Column("updated_at") var updatedAt: Instant? = null
@LastModifiedBy @Column("updated_by") var updatedBy: String? = null}Repository Configuration
@Configuration@EnableJdbcRepositories( basePackages = ["com.example.platform.infrastructure.driven.adapters.jdbc.repository"], includeFilters = [ComponentScan.Filter( type = FilterType.ANNOTATION, classes = [Repository::class] )])@EnableJdbcAuditingclass JdbcConfig {
@Bean fun transactionManager(dataSource: DataSource): PlatformTransactionManager { return DataSourceTransactionManager(dataSource) }
@Bean fun auditorProvider(currentUserPort: CurrentUserPort): AuditorAware<String> { // CurrentUserPort is defined in application; the adapter resolves the current user. return AuditorAware { Optional.ofNullable(currentUserPort.currentUserId()) } }}Java:
@Configuration@EnableJdbcRepositories( basePackages = "com.example.platform.infrastructure.driven.adapters.jdbc.repository", includeFilters = @ComponentScan.Filter( type = FilterType.ANNOTATION, classes = Repository.class ))@EnableJdbcAuditingpublic class JdbcConfig {
@Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { return new DataSourceTransactionManager(dataSource); }
@Bean public AuditorAware<String> auditorProvider(CurrentUserPort currentUserPort) { // CurrentUserPort is defined in application; the adapter resolves the current user. return () -> Optional.ofNullable(currentUserPort.currentUserId()); }}Kafka Configuration
Producer Configuration
@Configuration@EnableKafkaclass KafkaConfig {
@Bean fun producerFactory( @Value("\${spring.kafka.bootstrap-servers}") bootstrapServers: String ): ProducerFactory<String, String> { val props = mapOf( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, ProducerConfig.ACKS_CONFIG to "all", // enable.idempotence=true already guarantees ordering and safe retries (Kafka manages retries/in-flight) ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG to true ) return DefaultKafkaProducerFactory(props) }
@Bean fun kafkaTemplate(producerFactory: ProducerFactory<String, String>): KafkaTemplate<String, String> { return KafkaTemplate(producerFactory) }}⚠ Bean injection rule (ArchUnit-enforced). A
KafkaTemplate<String, String>bean defined here MAY be injected ONLY into aMessagePublisherPortadapter (e.g.KafkaMessagePublisherAdapter) underinfrastructure/driven-adapters/kafka-producer/. Injecting it into any class under..usecase..,..domain..,..port..or any controller violates the outbox-first rule and will fail the ArchUnitbrokerTemplatesShouldOnlyLiveInPublisherAdapterstest (see hexagonal-architecture.md §ArchUnit). The same applies toRabbitTemplate.
Java:
@Configuration@EnableKafkapublic class KafkaConfig {
@Bean public ProducerFactory<String, String> producerFactory( @Value("${spring.kafka.bootstrap-servers}") String bootstrapServers) { Map<String, Object> props = Map.of( ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class, ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class, ProducerConfig.ACKS_CONFIG, "all", // enable.idempotence=true already guarantees ordering and safe retries (Kafka manages retries/in-flight) ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true ); return new DefaultKafkaProducerFactory<>(props); }
@Bean public KafkaTemplate<String, String> kafkaTemplate(ProducerFactory<String, String> producerFactory) { return new KafkaTemplate<>(producerFactory); }}Consumer Configuration
@Beanfun consumerFactory( @Value("\${spring.kafka.bootstrap-servers}") bootstrapServers: String, @Value("\${spring.kafka.consumer.group-id}") groupId: String): ConsumerFactory<String, String> { val props = mapOf( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG to groupId, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "earliest", ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to false ) return DefaultKafkaConsumerFactory(props)}
@Beanfun kafkaListenerContainerFactory( consumerFactory: ConsumerFactory<String, String>): ConcurrentKafkaListenerContainerFactory<String, String> { val factory = ConcurrentKafkaListenerContainerFactory<String, String>() factory.consumerFactory = consumerFactory factory.containerProperties.ackMode = ContainerProperties.AckMode.MANUAL return factory}Java:
@Beanpublic ConsumerFactory<String, String> consumerFactory( @Value("${spring.kafka.bootstrap-servers}") String bootstrapServers, @Value("${spring.kafka.consumer.group-id}") String groupId) { Map<String, Object> props = Map.of( ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, ConsumerConfig.GROUP_ID_CONFIG, groupId, ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class, ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class, ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest", ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false ); return new DefaultKafkaConsumerFactory<>(props);}
@Beanpublic ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory( ConsumerFactory<String, String> consumerFactory) { ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setConsumerFactory(consumerFactory); factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL); return factory;}Async Configuration
@Configuration@EnableAsyncclass AsyncConfig : AsyncConfigurer {
override fun getAsyncExecutor(): Executor { val executor = ThreadPoolTaskExecutor() executor.corePoolSize = 5 executor.maxPoolSize = 10 executor.queueCapacity = 100 executor.setThreadNamePrefix("async-") executor.setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy()) executor.initialize() return executor }
override fun getAsyncUncaughtExceptionHandler(): AsyncUncaughtExceptionHandler { return AsyncUncaughtExceptionHandler { throwable, method, params -> log.error("Async execution error in method: ${method.name}", throwable) } }}Java:
@Configuration@EnableAsyncpublic class AsyncConfig implements AsyncConfigurer {
private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);
@Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }
@Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (throwable, method, params) -> log.error("Async execution error in method: " + method.getName(), throwable); }}Use @Async in infrastructure only — and NOT for event publishing.
Event publishing is decoupled by the SDK’s OutboxScheduler, not by @Async. There is no per-entity Kafka adapter; there is one broker-agnostic MessagePublisherPort:
class KafkaMessagePublisherAdapter( private val kafkaTemplate: KafkaTemplate<String, String>,) : MessagePublisherPort {
override fun publish(topic: String, routingKey: String, payload: String) { kafkaTemplate.send(topic, routingKey, payload).get(5, TimeUnit.SECONDS) }
override fun publish(topic: String, payload: String) { kafkaTemplate.send(topic, payload).get(5, TimeUnit.SECONDS) }}No @Async, no try/catch-then-outbox: the dispatcher owns the retry policy. Use @Async for genuinely fire-and-forget cross-cutting work (warming caches, sending analytics pings) — never for messaging.
Scheduler Configuration
@Configuration@EnableSchedulingclass ScheduleConfig {
@Bean fun taskScheduler(): TaskScheduler { val scheduler = ThreadPoolTaskScheduler() scheduler.poolSize = 5 scheduler.setThreadNamePrefix("scheduled-") scheduler.initialize() return scheduler }}Use @Scheduled in entry-points/scheduler:
For the outbox dispatcher specifically, do not hand-roll a scheduler — it ships in codehuntersio-sdk-event-messaging as OutboxScheduler. Hand-rolling drifts from the canonical pattern (per-service lock name, claim TTL, exponential backoff) and breaks ArchUnit checks. See messaging.md §4.3.1 for the SDK starter.
When you DO need a custom scheduled job (e.g. cache warmer, reaper) follow the same lock-name-per-service convention:
@Componentclass CacheWarmerScheduler(private val warmCache: WarmCachePort) {
@Scheduled(fixedDelayString = "\${cache.warmer.fixed-delay:60000}") @SchedulerLock( name = "cache-warmer-\${spring.application.name}", // per-service lock lockAtMostFor = "5m", lockAtLeastFor = "1m", ) fun tick() = runCatching { warmCache.execute() } .onFailure { log.error("cache warmer failed", it) }}Use ShedLock for distributed scheduling:
@Configuration@EnableSchedulerLock(defaultLockAtMostFor = "10m")class ShedLockConfig {
@Bean fun lockProvider(dataSource: DataSource): LockProvider { return JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(JdbcTemplate(dataSource)) .usingDbTime() .build() ) }}
// Usage@Scheduled(fixedDelay = 2000)@SchedulerLock(name = "publishOutboxBatch", lockAtMostFor = "5m", lockAtLeastFor = "1m")fun publishBatch() { // Only one instance will run this at a time}Java:
@Configuration@EnableSchedulerLock(defaultLockAtMostFor = "10m")public class ShedLockConfig {
@Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .usingDbTime() .build() ); }}
// Usage@Scheduled(fixedDelay = 2000)@SchedulerLock(name = "publishOutboxBatch", lockAtMostFor = "5m", lockAtLeastFor = "1m")public void publishBatch() { // Only one instance will run this at a time}Observability & Metrics
For comprehensive observability guidance including OTEL, Micrometer, Prometheus, and NewRelic, see Observability Guide.
Key points:
- Metrics are implemented as decorator adapters using the Chain of Responsibility pattern.
- Use
MetricRecordedabstract class in themetrics-publishermodule. - OTEL tracing is automatic via
micrometer-tracing-bridge-otel. - See Observability Guide for configuration, custom metrics, and step-by-step instructions.
Testing with Spring
Architecture Tests (ArchUnit)
@AnalyzeClasses(packages = ["com.example.platform"])class HexagonalArchitectureTest {
@ArchTest val domainShouldNotDependOnInfrastructure = noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAPackage("..infrastructure..") .because("Domain must be free from infrastructure dependencies")
@ArchTest val useCasesShouldNotHaveSpringAnnotations = noClasses() .that().resideInAPackage("..usecase..") .should().beAnnotatedWith("org.springframework.stereotype.Service") .orShould().beAnnotatedWith("org.springframework.transaction.annotation.Transactional") .because("Use cases should be framework-agnostic")}Integration Tests with Testcontainers
@SpringBootTest@Testcontainers@ActiveProfiles("test")class OrganizationRepositoryIntegrationTest {
companion object { @Container @ServiceConnection val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test") }
@Autowired private lateinit var repository: OrganizationRepository
@Test fun `should save and retrieve organization`() { val organization = Organization( id = UUID.randomUUID(), name = "Test Org", status = OrganizationStatus.ACTIVE )
val saved = repository.save(organization) val retrieved = repository.findById(saved.id)
assertThat(retrieved).isNotNull assertThat(retrieved?.name).isEqualTo("Test Org") }}Java:
@SpringBootTest@Testcontainers@ActiveProfiles("test")class OrganizationRepositoryIntegrationTest {
@Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine") .withDatabaseName("testdb") .withUsername("test") .withPassword("test");
@Autowired private OrganizationRepository repository;
@Test void shouldSaveAndRetrieveOrganization() { Organization organization = new Organization( UUID.randomUUID(), "Test Org", OrganizationStatus.ACTIVE );
Organization saved = repository.save(organization); Organization retrieved = repository.findById(saved.getId());
assertThat(retrieved).isNotNull(); assertThat(retrieved.getName()).isEqualTo("Test Org"); }}Configuration Management
Environment-specific Configuration
# application.yml (base)spring: application: name: platform-service profiles: active: ${SPRING_PROFILES_ACTIVE:dev}
# application-dev.ymlspring: datasource: url: jdbc:postgresql://localhost:5432/platform_dev username: dev password: dev
# application-prod.ymlspring: datasource: url: ${DATABASE_URL} username: ${DATABASE_USERNAME} password: ${DATABASE_PASSWORD}Externalized Configuration
@ConfigurationProperties(prefix = "outbox")data class OutboxProperties( val batchSize: Int = 100, val fixedDelay: Long = 2000, val maxRetries: Int = 10, val topics: Map<String, String> = emptyMap())
@Configuration@EnableConfigurationProperties(OutboxProperties::class)class OutboxConfigJSON Serialization — JsonMapper over ObjectMapper
MANDATORY: Always use
JsonMapperinstead ofObjectMapper. Jackson 3.x deprecatesObjectMapperin favor ofJsonMapperas the primary entry point.JsonMapperprovides a cleaner, type-safe builder API and is the only mapper that should be used in new code.
Bean Configuration
Kotlin:
@Configurationclass JacksonConfig {
@Bean fun jsonMapper(): JsonMapper { return JsonMapper.builder() .addModule(JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) .serializationInclusion(JsonInclude.Include.NON_NULL) .build() }}Java:
@Configurationpublic class JacksonConfig {
@Bean public JsonMapper jsonMapper() { return JsonMapper.builder() .addModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) .serializationInclusion(JsonInclude.Include.NON_NULL) .build(); }}Usage in Adapters
// ✅ GOOD — JsonMapper used in the SDK auto-config (bean name "outboxJsonMapper")@Configurationclass OutboxSerializationConfig { @Bean("outboxJsonMapper") fun outboxJsonMapper(): JsonMapper = JsonMapper.builder() .addModule(JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) .build()}
// ✅ GOOD — broker-agnostic adapter; payload already serialized by the SDKclass KafkaMessagePublisherAdapter( private val kafkaTemplate: KafkaTemplate<String, String>,) : MessagePublisherPort { override fun publish(topic: String, routingKey: String, payload: String) { kafkaTemplate.send(topic, routingKey, payload).get(5, TimeUnit.SECONDS) } override fun publish(topic: String, payload: String) { kafkaTemplate.send(topic, payload).get(5, TimeUnit.SECONDS) }}
// ❌ BAD — ObjectMapper (Jackson 3.x deprecates it; use JsonMapper)class OrganizationKafkaAdapter( private val objectMapper: ObjectMapper)
// ❌ BAD — per-entity publisher port + envelope construction in the adapterclass OrganizationKafkaAdapter( private val jsonMapper: JsonMapper, private val kafkaTemplate: KafkaTemplate<String, String>,) : OrganizationPublisher { override fun publish(organization: Organization) { kafkaTemplate.send(topic, organization.id.toString(), jsonMapper.writeValueAsString(organization)) }}// Why bad: per-entity publisher port invites dual-write from the use case;// envelope/serialization belongs to the SDK (`OutboxEventFactory` + `outboxJsonMapper`),// not the adapter. Adapter receives a `String` payload from the dispatcher.Key Differences
| Feature | ObjectMapper |
JsonMapper |
|---|---|---|
| Builder API | Mutable, error-prone | Immutable, type-safe |
| Jackson 3.x | Deprecated | Primary entry point |
| Thread safety | Requires careful config | Immutable after build |
| Module registration | Runtime mutations | Builder-time only |
Anti-Patterns to Avoid
❌ Spring Annotations in Domain/Application
// ❌ BAD — JPA leaks into domain@Entity@Table(name = "organizations")data class Organization( @Id val id: UUID, val name: String,)
// ❌ ALSO BAD — anemic domain (data bag, no behavior, no invariants).// Per DDD/clean-code: entity must own its invariants + state transitions.data class Organization( val id: UUID, val name: String, val status: OrganizationStatus,)
// ✅ GOOD — framework-free, immutable, invariants enforced, behavior on the entityclass Organization private constructor( val id: UUID, val name: OrganizationName, val status: OrganizationStatus, val version: Long,) { init { require(version >= 0) { "version must be non-negative" } }
fun rename(newName: OrganizationName): Organization = Organization(id, newName, status, version + 1)
fun transitionTo(newStatus: OrganizationStatus): Organization { require(status.canTransitionTo(newStatus)) { "Illegal transition: $status → $newStatus" } return Organization(id, name, newStatus, version + 1) }
companion object { fun create(id: UUID, name: OrganizationName): Organization = Organization(id, name, OrganizationStatus.PENDING_APPROVAL, version = 0) }}❌ @Autowired Field Injection
// ❌ BAD@Componentclass OrganizationAdapter { @Autowired private lateinit var repository: OrganizationJdbcRepository}
// ✅ GOOD@Componentclass OrganizationAdapter( private val repository: OrganizationJdbcRepository // Constructor injection)❌ Using Spring Types in Ports
// ❌ BADinterface OrganizationPublisher { fun publish(event: ApplicationEvent) // Spring type leaking into a port}
// ❌ BAD — domain-specific publisher port (invites dual-write from the use case)interface OrganizationPublisher { fun publish(organization: Organization)}
// ✅ GOOD — use case publishes via the SDK outbox port; no domain publisher port existsclass CreateOrganizationUseCase( private val transaction: TransactionPort<Organization>, private val organizations: OrganizationRepositoryPort, private val outbox: OutboxRepositoryPort, // from codehuntersio-sdk-event-messaging private val events: OutboxEventFactory, // from codehuntersio-sdk-event-messaging) : CreateOrganizationPort { /* ... */ }Outbox messaging starter — use the SDK, do not re-implement
Add the codehuntersio-sdk-event-messaging starter to consume this section’s contract end-to-end. It ships every messaging bean with @ConditionalOnMissingBean; override what you need.
# application.yml — defaults shownoutbox: enabled: true batch-size: 50 max-attempts: 5 task: fixed-delay: 5000 lock-at-most: 60000 lock-at-least: 10000 lock-name: outbox-${spring.application.name} # per-service — DO NOT collapse claim: ttl-ms: 300000 backoff: base-delay-ms: 30000 max-delay-ms: 3600000Beans the SDK provides (override any with @Bean): EventMessageFactory, OutboxEventFactory, OutboxRepositoryPort (OutboxJdbcAdapter), MessagePublisherPort (RabbitMessagePublisherAdapter), SendOutboxMessagesPort (SendOutboxMessagesUseCase), OutboxScheduler, LockProvider (JdbcTemplateLockProvider), outboxJsonMapper.
Swap broker: provide a KafkaMessagePublisherAdapter bean — the SDK Rabbit default skips itself via @ConditionalOnMissingBean. Application code is untouched.
See messaging.md §4.3.1 for the canonical reference.
Summary
Key Principles:
- Keep Spring in infrastructure and bootstrap only
- Use configuration classes, not annotations on use cases
- Define abstractions (ports) in application, implementations in infrastructure
- Prefer Spring Data JDBC over JPA for cleaner separation
- Use constructor injection everywhere
- Externalize configuration with environment variables
- Enforce architecture with ArchUnit tests
- Use Testcontainers for integration tests
This approach ensures your hexagonal architecture remains clean, testable, and framework-agnostic while still leveraging Spring Boot’s powerful features where appropriate.