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-serviceplaceholders with your project’s actual package and service name when applying patterns.
Companion files:
- claude-code-guide.md — index + required reading order.
- ccg-domain-and-ports.md — Steps 1-3 (domain, ports, use case).
- ccg-bugfix-and-testing.md — bug-fix workflow + test patterns.
- ccg-reference.md — common tasks, anti-patterns, checklists.
Step 4: Implement Adapters
4.1 Database Adapter (Outbound)
Location: infrastructure/driven-adapters/jdbc-repository/
4.1.1 Create Entity
@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:
@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
@Repositoryinterface InvoiceJdbcRepository : CrudRepository<InvoiceEntity, UUID> {
@Query("SELECT * FROM invoices WHERE organization_id = :organizationId") fun findByOrganizationId(@Param("organizationId") organizationId: UUID): List<InvoiceEntity>}Java equivalent:
@Repositorypublic 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
@Component@Order(100) // Lowest priority in chainclass 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:
@Component@Order(100) // Lowest priority in chainpublic 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
// Domain → Entityfun 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 → Domainfun 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:
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.sqlCREATE 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:
databaseChangeLog: - include: file: changelog/changes/V20251103120000__create_invoices_table.sql4.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.
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@Configurationclass MessagingConfig { @Bean fun messagePublisherPort(kafkaTemplate: KafkaTemplate<String, String>): MessagePublisherPort = KafkaMessagePublisherAdapter(kafkaTemplate)}Java equivalent:
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
traceIdinEventMessage.Header; the adapter may attach atraceparentheader but does NOT construct envelopes. - This adapter is consumed ONLY by the SDK dispatcher. Use cases never see it.
- ArchUnit forbids
KafkaTemplateoutside 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
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.ktdata 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:
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.javapublic record InvoiceResponse( UUID id, UUID organizationId, BigDecimal amount, String currency, String status, Instant issuedAt, LocalDate dueDate) {}4.3.2 Create Mapper
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
@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:
@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/
@Configurationclass 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}