En esta guía (4 elementos)
Claude Code Guide — Domain, Ports & Use Cases
Claude Code Guide — Domain, Ports & Use Cases
Project-agnostic. Part of the Claude Code Guide suite. Covers: Step 1 (domain model), Step 2 (inbound + outbound ports, command), Step 3 (use case).
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-adapters-and-wiring.md — Step 4 (REST/JDBC/Kafka adapters) + Step 5 (bootstrap wiring).
- ccg-bugfix-and-testing.md — bug-fix workflow + test patterns.
- ccg-reference.md — common tasks, anti-patterns, checklists.
🎯 When Adding a New Feature
Follow these steps in order. Steps 1-3 (this file) define the inside (domain + application). Steps 4-5 (next file) define the outside (adapters + wiring).
Step 1: Define Domain Model (if needed)
Location: domain/src/main/kotlin/com/example/platform/domain/
// Example: domain/Invoice.kt — regular class (NOT data class). data class would// auto-expose copy(status = PAID) and let callers skip markAsPaid's CANCELLED guard.class Invoice private constructor( val id: UUID, val organizationId: UUID, val amount: Money, val status: InvoiceStatus, val issuedAt: Instant, val dueDate: LocalDate, val version: Long,) { init { require(amount.isPositive()) { "amount must be positive" } require(version >= 0) { "version must be non-negative" } }
fun markAsPaid(): Invoice { require(status != InvoiceStatus.CANCELLED) { "Cannot pay cancelled invoice" } return Invoice(id, organizationId, amount, InvoiceStatus.PAID, issuedAt, dueDate, version + 1) }
fun cancel(): Invoice { require(status == InvoiceStatus.PENDING) { "Only pending invoices can be cancelled" } return Invoice(id, organizationId, amount, InvoiceStatus.CANCELLED, issuedAt, dueDate, version + 1) }
fun isOverdue(currentDate: LocalDate): Boolean = status == InvoiceStatus.PENDING && currentDate.isAfter(dueDate)
companion object { fun issue(id: UUID, organizationId: UUID, amount: Money, dueDate: LocalDate, clock: Clock): Invoice = Invoice(id, organizationId, amount, InvoiceStatus.PENDING, clock.instant(), dueDate, version = 0)
// For persistence rehydration only — JDBC/JPA mappers call this. fun rehydrate( id: UUID, organizationId: UUID, amount: Money, status: InvoiceStatus, issuedAt: Instant, dueDate: LocalDate, version: Long, ): Invoice = Invoice(id, organizationId, amount, status, issuedAt, dueDate, version) }}
enum class InvoiceStatus { PENDING, PAID, CANCELLED }Java equivalent:
public final class Invoice { private final UUID id; private final UUID organizationId; private final Money amount; private final InvoiceStatus status; private final Instant issuedAt; private final LocalDate dueDate; private final long version;
private Invoice(UUID id, UUID organizationId, Money amount, InvoiceStatus status, Instant issuedAt, LocalDate dueDate, long version) { if (!amount.isPositive()) throw new IllegalArgumentException("amount must be positive"); if (version < 0) throw new IllegalArgumentException("version must be non-negative"); this.id = id; this.organizationId = organizationId; this.amount = amount; this.status = status; this.issuedAt = issuedAt; this.dueDate = dueDate; this.version = version; }
public static Invoice issue(UUID id, UUID organizationId, Money amount, LocalDate dueDate, Clock clock) { return new Invoice(id, organizationId, amount, InvoiceStatus.PENDING, clock.instant(), dueDate, 0L); }
public static Invoice rehydrate(UUID id, UUID organizationId, Money amount, InvoiceStatus status, Instant issuedAt, LocalDate dueDate, long version) { return new Invoice(id, organizationId, amount, status, issuedAt, dueDate, version); }
public Invoice markAsPaid() { if (status == InvoiceStatus.CANCELLED) { throw new IllegalStateException("Cannot pay cancelled invoice"); } return new Invoice(id, organizationId, amount, InvoiceStatus.PAID, issuedAt, dueDate, version + 1); }
public Invoice cancel() { if (status != InvoiceStatus.PENDING) { throw new IllegalStateException("Only pending invoices can be cancelled"); } return new Invoice(id, organizationId, amount, InvoiceStatus.CANCELLED, issuedAt, dueDate, version + 1); }
public boolean isOverdue(LocalDate currentDate) { return status == InvoiceStatus.PENDING && currentDate.isAfter(dueDate); }
// Getters only — no setters. public UUID getId() { return id; } public UUID getOrganizationId() { return organizationId; } public Money getAmount() { return amount; } public InvoiceStatus getStatus(){ return status; } public Instant getIssuedAt() { return issuedAt; } public LocalDate getDueDate() { return dueDate; } public long getVersion() { return version; }}
public enum InvoiceStatus { PENDING, PAID, CANCELLED }See java-code-style.md for Java-specific domain modeling patterns.
Rules:
- No Spring annotations
- No @Entity or @Table
- Use
data class(Kotlin) ONLY for value objects with no invariants; entities/aggregates with behavior or guards use a regularclasswith private constructor + factory (adata classwould auto-exposecopy()and let callers skip those guards). In Java, an immutable class with getters + private constructor + factory. - Immutable by default (
valin Kotlin,finalfields in Java) - Business logic here, not in use cases
Step 2: Define Ports in Application Layer
2.1 Inbound Port (Use Case Interface)
Location: application/src/main/kotlin/com/example/platform/port/inbound/
fun interface CreateInvoicePort : ChainablePort<CreateInvoicePort> { fun execute(command: CreateInvoiceCommand): Invoice}Java equivalent:
@FunctionalInterfacepublic interface CreateInvoicePort extends ChainablePort<CreateInvoicePort> { Invoice execute(CreateInvoiceCommand command);}Use fun interface (Kotlin) or @FunctionalInterface (Java) for single-method contracts. This enables lambda syntax. Ports extend ChainablePort<T> to support the chain of responsibility pattern (see hexagonal-architecture.md).
2.2 Command (if using CQRS)
Location: application/src/main/kotlin/com/example/platform/command/
data class CreateInvoiceCommand( val organizationId: UUID, val amount: BigDecimal, val currency: String, val dueDate: LocalDate, val traceId: String)Java equivalent:
public record CreateInvoiceCommand( UUID organizationId, BigDecimal amount, String currency, LocalDate dueDate, String traceId) {}2.3 Outbound Ports (Repository only — no publisher port)
Location: application/src/main/kotlin/com/example/platform/port/outbound/
interface InvoiceRepositoryPort { fun save(invoice: Invoice): Invoice fun findById(id: UUID): Invoice? fun findByOrganizationId(organizationId: UUID): List<Invoice>}
// Outbox port: provided by codehuntersio-sdk-event-messaging — NOT redeclared per service.// Inject `OutboxRepositoryPort` + `OutboxEventFactory` directly from the SDK.// See messaging.md §4.3 + §4.3.1.Java equivalent:
public interface InvoiceRepositoryPort { Invoice save(Invoice invoice); Optional<Invoice> findById(UUID id); List<Invoice> findByOrganizationId(UUID organizationId);}
// No InvoicePublisherPort. Use cases publish via OutboxRepositoryPort from the SDK.2.3.1 Why no publisher port in use cases
A domain-specific XxxPublisherPort (e.g. InvoicePublisherPort) is an attractive nuisance: it invites the use case to call the broker — directly or via a “publish first, outbox on failure” callback. Both are dual-write. The use case has only one outbound dependency for event emission: OutboxRepositoryPort. The SDK dispatcher (SendOutboxMessagesUseCase + MessagePublisherPort) handles broker I/O on the other side of a local TX boundary. See messaging.md §4 and ArchUnit rules in hexagonal-architecture.md §ArchUnit.
Naming. Repositories follow
<Entity>RepositoryPort. The single canonical messaging port isMessagePublisherPort(provided by the SDK; consumed only by the dispatcher) — see ports-and-commands.md.
Step 3: Implement Use Case
Location: application/src/main/kotlin/com/example/platform/usecase/
class CreateInvoiceUseCase( private val transaction: TransactionPort<Invoice>, private val invoiceRepository: InvoiceRepositoryPort, private val outboxRepository: OutboxRepositoryPort, // SDK port private val outboxEventFactory: OutboxEventFactory, // SDK factory private val clock: Clock,) : CreateInvoicePort {
override fun execute(command: CreateInvoiceCommand): Invoice = transaction.run { val invoice = command.toInvoice(clock) val saved = invoiceRepository.save(invoice) outboxRepository.save(outboxEventFactory.create( topic = "platform.invoices", traceId = command.traceId, aggregate = saved.id.toString(), eventName = "InvoiceCreated", eventType = EventTypes.EVENT, payload = InvoiceCreatedPayload.from(saved), )) saved }}Java equivalent:
public class CreateInvoiceUseCase implements CreateInvoicePort {
private final TransactionPort<Invoice> transaction; private final InvoiceRepositoryPort invoiceRepository; private final OutboxRepositoryPort outboxRepository; private final OutboxEventFactory outboxEventFactory; private final Clock clock;
public CreateInvoiceUseCase(TransactionPort<Invoice> transaction, InvoiceRepositoryPort invoiceRepository, OutboxRepositoryPort outboxRepository, OutboxEventFactory outboxEventFactory, Clock clock) { this.transaction = transaction; this.invoiceRepository = invoiceRepository; this.outboxRepository = outboxRepository; this.outboxEventFactory = outboxEventFactory; this.clock = clock; }
@Override public Invoice execute(CreateInvoiceCommand command) { return transaction.run(() -> { Invoice invoice = CommandMapper.toInvoice(command, clock); Invoice saved = invoiceRepository.save(invoice); outboxRepository.save(outboxEventFactory.create( "platform.invoices", command.traceId(), saved.getId().toString(), "InvoiceCreated", EventTypes.EVENT, InvoiceCreatedPayload.from(saved) )); return saved; }); }}Rules:
- No Spring annotations (@Service, @Transactional).
- Use
TransactionPort<R>for transactional boundaries. - Publish events ONLY via
OutboxRepositoryPort.save(...)— the SDK dispatcher publishes asynchronously. NoMessagePublisherPort, noXxxPublisherPort, no callback. - Use case keeps both writes (business + outbox) in the same local transaction; either both commit or both roll back.
- Keep thin — orchestration only, business logic in domain.
- See messaging.md for envelope, factory, dispatcher and pre-merge checklist.
Mapper (Command → Domain)
Location: application/src/main/kotlin/com/example/platform/mapper/
fun CreateInvoiceCommand.toInvoice(clock: Clock): Invoice = Invoice.issue( id = UUID.randomUUID(), organizationId = this.organizationId, amount = Money(this.amount, Currency.getInstance(this.currency)), dueDate = this.dueDate, clock = clock,)Use extension functions for mapping (Kotlin).
Java equivalent:
public final class CommandMapper {
private CommandMapper() { // Utility class - prevent instantiation }
public static Invoice toInvoice(CreateInvoiceCommand command, Clock clock) { return Invoice.issue( UUID.randomUUID(), command.organizationId(), new Money(command.amount(), Currency.getInstance(command.currency())), command.dueDate(), clock ); }}In Java, use static methods in a utility class instead of Kotlin extension functions. See java-code-style.md for additional mapper patterns.