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

Claude Code Guide — Adapters & Bootstrap Wiring

Claude Code Guide — Adapters & Bootstrap Wiring

Project-agnostic. Part of the Claude Code Guide suite. Covers: Step 4 (REST controller, JDBC repository, Kafka consumer/producer, metrics adapter), Step 5 (Spring Boot configuration wiring).

Replace com.example.platform / platform-service placeholders with your project’s actual package and service name when applying patterns.

Companion files:


Step 4: Implement Adapters

4.1 Database Adapter (Outbound)

Location: infrastructure/driven-adapters/jdbc-repository/

4.1.1 Create Entity

infrastructure/driven-adapters/jdbc-repository/.../entity/InvoiceEntity.kt
@Table("invoices")
class InvoiceEntity : AuditableEntity(), Persistable<UUID> {
@Id
@Column("id")
private var id: UUID? = null
@Column("organization_id")
var organizationId: UUID? = null
@Column("amount")
var amount: BigDecimal = BigDecimal.ZERO
@Column("currency")
var currency: String = ""
@Column("status")
var status: String = ""
@Column("issued_at")
var issuedAt: Instant? = null
@Column("due_date")
var dueDate: LocalDate? = null
@Version
@Column("version")
var version: Long = 0
@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 equivalent:

infrastructure/driven-adapters/jdbc-repository/.../entity/InvoiceEntity.java
@Table("invoices")
public class InvoiceEntity extends AuditableEntity implements Persistable<UUID> {
@Id
@Column("id")
private UUID id;
@Column("organization_id")
private UUID organizationId;
@Column("amount")
private BigDecimal amount = BigDecimal.ZERO;
@Column("currency")
private String currency = "";
@Column("status")
private String status = "";
@Column("issued_at")
private Instant issuedAt;
@Column("due_date")
private LocalDate dueDate;
@Version
@Column("version")
private long version = 0;
@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; }
// Getters and setters
public UUID getOrganizationId() { return organizationId; }
public void setOrganizationId(UUID organizationId) { this.organizationId = organizationId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Instant getIssuedAt() { return issuedAt; }
public void setIssuedAt(Instant issuedAt) { this.issuedAt = issuedAt; }
public LocalDate getDueDate() { return dueDate; }
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
public long getVersion() { return version; }
public void setVersion(long version) { this.version = version; }
}

4.1.2 Create Spring Data Repository

infrastructure/driven-adapters/jdbc-repository/.../InvoiceJdbcRepository.kt
@Repository
interface InvoiceJdbcRepository : CrudRepository<InvoiceEntity, UUID> {
@Query("SELECT * FROM invoices WHERE organization_id = :organizationId")
fun findByOrganizationId(@Param("organizationId") organizationId: UUID): List<InvoiceEntity>
}

Java equivalent:

infrastructure/driven-adapters/jdbc-repository/.../InvoiceJdbcRepository.java
@Repository
public interface InvoiceJdbcRepository extends CrudRepository<InvoiceEntity, UUID> {
@Query("SELECT * FROM invoices WHERE organization_id = :organizationId")
List<InvoiceEntity> findByOrganizationId(@Param("organizationId") UUID organizationId);
}

4.1.3 Create Adapter

infrastructure/driven-adapters/jdbc-repository/.../InvoiceRepositoryAdapter.kt
@Component
@Order(100) // Lowest priority in chain
class InvoiceRepositoryAdapter(
private val jdbcRepository: InvoiceJdbcRepository,
private val mapper: EntityJdbcMapper
) : InvoiceRepositoryPort {
override fun save(invoice: Invoice): Invoice {
val entity = mapper.toEntityForInsert(invoice)
val saved = jdbcRepository.save(entity)
return mapper.toDomain(saved)
}
override fun findById(id: UUID): Invoice? {
return jdbcRepository.findById(id)
.map { mapper.toDomain(it) }
.orElse(null)
}
override fun findByOrganizationId(organizationId: UUID): List<Invoice> {
return jdbcRepository.findByOrganizationId(organizationId)
.map { mapper.toDomain(it) }
}
companion object {
private val log = LoggerFactory.getLogger(InvoiceRepositoryAdapter::class.java)
}
}

Java equivalent:

infrastructure/driven-adapters/jdbc-repository/.../InvoiceRepositoryAdapter.java
@Component
@Order(100) // Lowest priority in chain
public class InvoiceRepositoryAdapter implements InvoiceRepositoryPort {
private static final Logger log = LoggerFactory.getLogger(InvoiceRepositoryAdapter.class);
private final InvoiceJdbcRepository jdbcRepository;
private final EntityJdbcMapper mapper;
public InvoiceRepositoryAdapter(InvoiceJdbcRepository jdbcRepository, EntityJdbcMapper mapper) {
this.jdbcRepository = jdbcRepository;
this.mapper = mapper;
}
@Override
public Invoice save(Invoice invoice) {
InvoiceEntity entity = mapper.toEntityForInsert(invoice);
InvoiceEntity saved = jdbcRepository.save(entity);
return mapper.toDomain(saved);
}
@Override
public Optional<Invoice> findById(UUID id) {
return jdbcRepository.findById(id)
.map(mapper::toDomain);
}
@Override
public List<Invoice> findByOrganizationId(UUID organizationId) {
return jdbcRepository.findByOrganizationId(organizationId)
.stream()
.map(mapper::toDomain)
.collect(Collectors.toList());
}
}

4.1.4 Create Mapper

infrastructure/driven-adapters/jdbc-repository/.../mapper/EntityJdbcMapper.kt
// Domain → Entity
fun Invoice.toEntityForInsert(): InvoiceEntity = InvoiceEntity().apply {
setId(this@toEntityForInsert.id)
organizationId = this@toEntityForInsert.organizationId
amount = this@toEntityForInsert.amount.value
currency = this@toEntityForInsert.amount.currency.currencyCode
status = this@toEntityForInsert.status.name
issuedAt = this@toEntityForInsert.issuedAt
dueDate = this@toEntityForInsert.dueDate
version = this@toEntityForInsert.version
markNotNew() // It's new for insert
}
// Entity → Domain
fun InvoiceEntity.toDomain(): Invoice = Invoice.rehydrate(
id = this.id!!,
organizationId = this.organizationId!!,
amount = Money(this.amount, Currency.getInstance(this.currency)),
status = InvoiceStatus.valueOf(this.status),
issuedAt = this.issuedAt!!,
dueDate = this.dueDate!!,
version = this.version
)

Java equivalent:

infrastructure/driven-adapters/jdbc-repository/.../mapper/EntityJdbcMapper.java
public final class EntityJdbcMapper {
private EntityJdbcMapper() {}
// Domain → Entity
public static InvoiceEntity toEntityForInsert(Invoice invoice) {
InvoiceEntity entity = new InvoiceEntity();
entity.setId(invoice.getId());
entity.setOrganizationId(invoice.getOrganizationId());
entity.setAmount(invoice.getAmount().getValue());
entity.setCurrency(invoice.getAmount().getCurrency().getCurrencyCode());
entity.setStatus(invoice.getStatus().name());
entity.setIssuedAt(invoice.getIssuedAt());
entity.setDueDate(invoice.getDueDate());
entity.setVersion(invoice.getVersion());
entity.markNotNew();
return entity;
}
// Entity → Domain
public static Invoice toDomain(InvoiceEntity entity) {
return Invoice.rehydrate(
entity.getId(),
entity.getOrganizationId(),
new Money(entity.getAmount(), Currency.getInstance(entity.getCurrency())),
InvoiceStatus.valueOf(entity.getStatus()),
entity.getIssuedAt(),
entity.getDueDate(),
entity.getVersion()
);
}
}

4.1.5 Create Liquibase Migration

Location: infrastructure/driven-adapters/jdbc-repository/src/main/resources/changelog/changes/

-- V20251103120000__create_invoices_table.sql
CREATE TABLE invoices (
id UUID PRIMARY KEY,
organization_id UUID NOT NULL,
amount NUMERIC(19, 4) NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(50) NOT NULL,
issued_at TIMESTAMP NOT NULL,
due_date DATE NOT NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT now(),
created_by VARCHAR(100),
updated_at TIMESTAMP,
updated_by VARCHAR(100),
CONSTRAINT fk_organization FOREIGN KEY (organization_id) REFERENCES organizations(id)
);
CREATE INDEX idx_invoices_organization_id ON invoices(organization_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_due_date ON invoices(due_date);

Register in changelog:

infrastructure/driven-adapters/jdbc-repository/src/main/resources/changelog/db.changelog-master.yaml
databaseChangeLog:
- include:
file: changelog/changes/V20251103120000__create_invoices_table.sql

4.2 Kafka Adapter (Outbound)

Location: infrastructure/driven-adapters/kafka-producer/

4.2.1 Broker publisher adapter — single broker-agnostic MessagePublisherPort

You do NOT write a per-entity Kafka adapter. The dispatcher (SendOutboxMessagesUseCase from codehuntersio-sdk-event-messaging) consumes one broker-agnostic port: MessagePublisherPort.publish(topic, routingKey?, payload). The SDK ships a Rabbit default. To use Kafka, override the bean — every SDK bean is @ConditionalOnMissingBean.

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) {
// routingKey doubles as Kafka key (partition selector)
kafkaTemplate.send(topic, routingKey, payload).get(5, TimeUnit.SECONDS)
}
override fun publish(topic: String, payload: String) {
kafkaTemplate.send(topic, payload).get(5, TimeUnit.SECONDS)
}
}
// bootstrap/config/MessagingConfig.kt
@Configuration
class MessagingConfig {
@Bean
fun messagePublisherPort(kafkaTemplate: KafkaTemplate<String, String>): MessagePublisherPort =
KafkaMessagePublisherAdapter(kafkaTemplate)
}

Java equivalent:

infrastructure/driven-adapters/kafka-producer/.../KafkaMessagePublisherAdapter.java
public class KafkaMessagePublisherAdapter implements MessagePublisherPort {
private final KafkaTemplate<String, String> kafkaTemplate;
public KafkaMessagePublisherAdapter(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
@Override
public void publish(String topic, String routingKey, String payload) {
try {
kafkaTemplate.send(topic, routingKey, payload).get(5, TimeUnit.SECONDS);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); }
catch (ExecutionException | TimeoutException e) { throw new RuntimeException(e); }
}
@Override
public void publish(String topic, String payload) {
try {
kafkaTemplate.send(topic, payload).get(5, TimeUnit.SECONDS);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); }
catch (ExecutionException | TimeoutException e) { throw new RuntimeException(e); }
}
}

Important:

  • No @Async, no try/catch-with-outbox-fallback, no domain types. This adapter receives a serialized JSON payload string and forwards it. Failures bubble up — the dispatcher (SendOutboxMessagesUseCase) catches them and applies the retry/backoff policy.
  • Trace propagation belongs upstream: the SDK persists traceId in EventMessage.Header; the adapter may attach a traceparent header but does NOT construct envelopes.
  • This adapter is consumed ONLY by the SDK dispatcher. Use cases never see it.
  • ArchUnit forbids KafkaTemplate outside this adapter package — see hexagonal-architecture.md §ArchUnit.

4.3 REST Controller (Inbound)

Location: infrastructure/entry-points/rest-controller/

4.3.1 Create Request/Response DTOs

infrastructure/entry-points/rest-controller/.../model/request/CreateInvoiceRequest.kt
data class CreateInvoiceRequest(
@field:NotNull(message = "Organization ID is required")
val organizationId: UUID?,
@field:NotNull(message = "Amount is required")
@field:DecimalMin(value = "0.01", message = "Amount must be positive")
val amount: BigDecimal?,
@field:NotBlank(message = "Currency is required")
@field:Pattern(regexp = "[A-Z]{3}", message = "Currency must be 3-letter ISO code")
val currency: String?,
@field:NotNull(message = "Due date is required")
@field:Future(message = "Due date must be in the future")
val dueDate: LocalDate?
)
// infrastructure/entry-points/rest-controller/.../model/response/InvoiceResponse.kt
data class InvoiceResponse(
val id: UUID,
val organizationId: UUID,
val amount: BigDecimal,
val currency: String,
val status: String,
val issuedAt: Instant,
val dueDate: LocalDate
)

Java equivalent:

infrastructure/entry-points/rest-controller/.../dto/request/CreateInvoiceRequest.java
public record CreateInvoiceRequest(
@NotNull(message = "Organization ID is required")
UUID organizationId,
@NotNull(message = "Amount is required")
@DecimalMin(value = "0.01", message = "Amount must be positive")
BigDecimal amount,
@NotBlank(message = "Currency is required")
@Pattern(regexp = "[A-Z]{3}", message = "Currency must be 3-letter ISO code")
String currency,
@NotNull(message = "Due date is required")
@Future(message = "Due date must be in the future")
LocalDate dueDate
) {}
// infrastructure/entry-points/rest-controller/.../dto/response/InvoiceResponse.java
public record InvoiceResponse(
UUID id,
UUID organizationId,
BigDecimal amount,
String currency,
String status,
Instant issuedAt,
LocalDate dueDate
) {}

4.3.2 Create Mapper

infrastructure/entry-points/rest-controller/.../model/mapper/InvoiceRestMapper.kt
fun CreateInvoiceRequest.toCommand(traceId: String): CreateInvoiceCommand = CreateInvoiceCommand(
organizationId = this.organizationId!!,
amount = this.amount!!,
currency = this.currency!!,
dueDate = this.dueDate!!,
traceId = traceId
)
fun Invoice.toResponse(): InvoiceResponse = InvoiceResponse(
id = this.id,
organizationId = this.organizationId,
amount = this.amount.value,
currency = this.amount.currency.currencyCode,
status = this.status.name,
issuedAt = this.issuedAt,
dueDate = this.dueDate
)

4.3.3 Create Controller

infrastructure/entry-points/rest-controller/.../InvoiceController.kt
@RestController
@RequestMapping("/api/v1/invoices")
class InvoiceController(
private val createInvoiceUseCase: CreateInvoicePort,
private val getInvoiceByIdUseCase: GetInvoiceByIdPort,
private val mapper: InvoiceRestMapper
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun createInvoice(
@Valid @RequestBody request: CreateInvoiceRequest
): InvoiceResponse {
log.info("Creating invoice: organizationId={}", request.organizationId)
val command = request.toCommand(MDC.get("traceId") ?: UUID.randomUUID().toString())
val invoice = createInvoiceUseCase.execute(command)
return invoice.toResponse()
}
@GetMapping("/{id}")
fun getInvoice(@PathVariable id: UUID): InvoiceResponse {
log.debug("Fetching invoice: id={}", id)
val invoice = getInvoiceByIdUseCase.execute(id)
return invoice.toResponse()
}
companion object {
private val log = LoggerFactory.getLogger(InvoiceController::class.java)
}
}

Java equivalent:

infrastructure/entry-points/rest-controller/.../InvoiceController.java
@RestController
@RequestMapping("/api/v1/invoices")
public class InvoiceController {
private static final Logger log = LoggerFactory.getLogger(InvoiceController.class);
private final CreateInvoicePort createInvoiceUseCase;
private final GetInvoiceByIdPort getInvoiceByIdUseCase;
public InvoiceController(CreateInvoicePort createInvoiceUseCase,
GetInvoiceByIdPort getInvoiceByIdUseCase) {
this.createInvoiceUseCase = createInvoiceUseCase;
this.getInvoiceByIdUseCase = getInvoiceByIdUseCase;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public InvoiceResponse createInvoice(@Valid @RequestBody CreateInvoiceRequest request) {
log.info("Creating invoice: organizationId={}", request.organizationId());
String traceId = Optional.ofNullable(MDC.get("traceId")).orElseGet(() -> UUID.randomUUID().toString());
CreateInvoiceCommand command = InvoiceRestMapper.toCommand(request, traceId);
Invoice invoice = createInvoiceUseCase.execute(command);
return InvoiceRestMapper.toResponse(invoice);
}
@GetMapping("/{id}")
public InvoiceResponse getInvoice(@PathVariable UUID id) {
log.debug("Fetching invoice: id={}", id);
Invoice invoice = getInvoiceByIdUseCase.execute(id);
return InvoiceRestMapper.toResponse(invoice);
}
}

Step 5: Wire in Bootstrap Config

Location: bootstrap/src/main/kotlin/com/example/platform/config/

bootstrap/config/UseCaseConfig.kt
@Configuration
class UseCaseConfig {
@Bean
fun createInvoiceUseCase(
transaction: TransactionPort<Invoice>,
invoiceRepository: InvoiceRepositoryPort,
outboxRepository: OutboxRepositoryPort, // SDK bean
outboxEventFactory: OutboxEventFactory, // SDK bean
clock: Clock,
): CreateInvoicePort {
return CreateInvoiceUseCase(transaction, invoiceRepository, outboxRepository, outboxEventFactory, clock)
}
@Bean
@Primary
fun invoiceRepository(): InvoiceRepositoryPort {
return buildChain(InvoiceRepositoryPort::class.java)
}
// buildChain method already exists in UseCaseConfig
}