Saltar al contenido
Mis Guidelines
Guías
En esta guía (34 elementos)

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 @Transactional on 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 classes

build.gradle.kts example:

domain/build.gradle.kts
dependencies {
// No Spring dependencies
}
// application/build.gradle.kts
dependencies {
implementation(project(":domain"))
// No Spring dependencies
}
// infrastructure/driven-adapters/jdbc-repository/build.gradle.kts
dependencies {
implementation(project(":application"))
implementation(project(":domain"))
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.liquibase:liquibase-core")
}
// bootstrap/build.gradle.kts
dependencies {
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:

application/usecase/CreateOrganizationUseCase.kt
@Service // ❌ Framework coupling in application layer
@Transactional // ❌ Framework annotation in application layer
class CreateOrganizationUseCase(
private val repository: OrganizationRepositoryPort
) : CreateOrganizationPort {
override fun execute(command: CreateOrganizationCommand): Organization {
return repository.save(command.toOrganization())
}
}

✅ GOOD - Configuration in bootstrap:

application/usecase/CreateOrganizationUseCase.kt
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
@Configuration
class UseCaseConfig {
@Bean
fun createOrganizationUseCase(
transaction: TransactionPort<Organization>,
repository: OrganizationRepository
): CreateOrganizationPort {
return CreateOrganizationUseCase(transaction, repository)
}
}

Java:

application/usecase/CreateOrganizationUseCase.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
@Configuration
public 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 UseCaseConfig

Option 2: Explicit bean definitions (recommended for clarity)

@Configuration
class 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)

application/port/outbound/repository/support/TransactionPort.kt
fun interface TransactionPort<R> {
fun run(callback: TransactionCallbackPort<R>): R
}
fun interface TransactionCallbackPort<R> {
fun call(): R
}

Implement with @Transactional (Infrastructure Layer)

infrastructure/driven-adapters/jdbc-repository/src/.../transaction/JdbcTransaction.kt
// 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:

infrastructure/driven-adapters/jdbc-repository/src/.../transaction/JdbcTransaction.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]
)]
)
@EnableJdbcAuditing
class 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
)
)
@EnableJdbcAuditing
public 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
@EnableKafka
class 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 a MessagePublisherPort adapter (e.g. KafkaMessagePublisherAdapter) under infrastructure/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 ArchUnit brokerTemplatesShouldOnlyLiveInPublisherAdapters test (see hexagonal-architecture.md §ArchUnit). The same applies to RabbitTemplate.

Java:

@Configuration
@EnableKafka
public 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

@Bean
fun 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)
}
@Bean
fun 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:

@Bean
public 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);
}
@Bean
public 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
@EnableAsync
class 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
@EnableAsync
public 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:

infrastructure/driven-adapters/kafka-producer/KafkaMessagePublisherAdapter.kt
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
@EnableScheduling
class 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:

infrastructure/entry-points/scheduler-task/CacheWarmerScheduler.kt
@Component
class 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 MetricRecorded abstract class in the metrics-publisher module.
  • 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)

bootstrap/src/test/kotlin/architecture/HexagonalArchitectureTest.kt
@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.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/platform_dev
username: dev
password: dev
# application-prod.yml
spring:
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 OutboxConfig

JSON Serialization — JsonMapper over ObjectMapper

MANDATORY: Always use JsonMapper instead of ObjectMapper. Jackson 3.x deprecates ObjectMapper in favor of JsonMapper as the primary entry point. JsonMapper provides a cleaner, type-safe builder API and is the only mapper that should be used in new code.

Bean Configuration

Kotlin:

@Configuration
class 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:

@Configuration
public 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")
@Configuration
class 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 SDK
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)
}
}
// ❌ BAD — ObjectMapper (Jackson 3.x deprecates it; use JsonMapper)
class OrganizationKafkaAdapter(
private val objectMapper: ObjectMapper
)
// ❌ BAD — per-entity publisher port + envelope construction in the adapter
class 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 entity
class 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
@Component
class OrganizationAdapter {
@Autowired
private lateinit var repository: OrganizationJdbcRepository
}
// ✅ GOOD
@Component
class OrganizationAdapter(
private val repository: OrganizationJdbcRepository // Constructor injection
)

❌ Using Spring Types in Ports

// ❌ BAD
interface 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 exists
class 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 shown
outbox:
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: 3600000

Beans 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:

  1. Keep Spring in infrastructure and bootstrap only
  2. Use configuration classes, not annotations on use cases
  3. Define abstractions (ports) in application, implementations in infrastructure
  4. Prefer Spring Data JDBC over JPA for cleaner separation
  5. Use constructor injection everywhere
  6. Externalize configuration with environment variables
  7. Enforce architecture with ArchUnit tests
  8. 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.