Skip to content
My Guidelines
Guides
In this guide (4 items)

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-service placeholders with your project’s actual package and service name when applying patterns.

Companion files:


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

domain/Invoice.java
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 regular class with private constructor + factory (a data class would auto-expose copy() and let callers skip those guards). In Java, an immutable class with getters + private constructor + factory.
  • Immutable by default (val in Kotlin, final fields 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/

application/port/inbound/CreateInvoicePort.kt
fun interface CreateInvoicePort : ChainablePort<CreateInvoicePort> {
fun execute(command: CreateInvoiceCommand): Invoice
}

Java equivalent:

application/port/inbound/CreateInvoicePort.java
@FunctionalInterface
public 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/

application/command/CreateInvoiceCommand.kt
data class CreateInvoiceCommand(
val organizationId: UUID,
val amount: BigDecimal,
val currency: String,
val dueDate: LocalDate,
val traceId: String
)

Java equivalent:

application/command/CreateInvoiceCommand.java
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/

application/port/outbound/repository/InvoiceRepositoryPort.kt
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:

application/port/outbound/repository/InvoiceRepositoryPort.java
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 is MessagePublisherPort (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/

application/usecase/invoice/CreateInvoiceUseCase.kt
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:

application/usecase/invoice/CreateInvoiceUseCase.java
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. No MessagePublisherPort, no XxxPublisherPort, 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/

application/mapper/CommandMapper.kt
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:

application/mapper/CommandMapper.java
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.