En esta guía (26 elementos)
Best Practices & Recommendations
Best Practices & Recommendations
Project-agnostic catalog. Part of the Diagram Catalog. Covers: design best practices (ports, commands, domain entities, use cases, adapters), short/medium/long-term improvements, appendices (file locations, decision guide, troubleshooting).
Replace
com.example.platform/platform-serviceplaceholders with your project’s actual package and service name when applying patterns.
9. Best Practices
9.1 Port Design
✅ DO:
- End all port interfaces with
Portsuffix - Keep ports in application layer
- Define single responsibility per port
- Use
fun interfacefor single-method ports - Document port contracts with KDoc
/** * Port for creating new organizations. * Receives a command with organization details and returns the created entity. */fun interface CreateOrganizationPort { /** * Creates a new organization. * @param command The organization creation command * @return The created organization entity with generated ID and default status * @throws ValidationException if command data is invalid */ fun execute(command: CreateOrganizationCommand): Organization}❌ DON’T:
- Mix business logic in ports (keep them as interfaces only)
- Reference infrastructure concerns in port definitions
- Create overly generic ports (
GenericCrudPort<T>)
9.2 Command Design
✅ DO:
- Use immutable data classes for commands
- Include only user-provided fields
- Name commands with action + entity (
CreateOrganization) - Group commands in
commandpackage - Create mapper extensions (
Command.toEntity())
// ✅ Good Commanddata class CreateOrganizationCommand( val name: String, val contactEmail: String, val address: String)
fun CreateOrganizationCommand.toOrganization(): Organization { return Organization.create( id = UUID.randomUUID(), name = this.name, contactEmail = this.contactEmail, address = this.address )}❌ DON’T:
- Include system-generated fields in commands (id, timestamps)
- Add validation logic in commands
- Make commands mutable
// ❌ Bad Commanddata class CreateOrganizationCommand( val id: UUID = UUID.randomUUID(), // ❌ System field var name: String, // ❌ Mutable val createdAt: Instant = Instant.now() // ❌ System field) { init { require(name.isNotBlank()) { "Name required" } // ❌ Validation here }}9.3 Domain Entity Design
✅ DO:
- Keep entities in domain layer
- Make entities immutable (regular
classwithval+ private ctor + factory; do NOT usedata classfor aggregate roots — only for value objects) - Include business behavior methods
- Enforce invariants in methods
- Use meaningful domain types
// Regular class (NOT data class): private ctor + factory + transition method// keep every state change going through invariants. data class would expose copy()// and let callers bypass canTransitionTo.class Organization private constructor( val id: UUID, val name: OrganizationName, val status: OrganizationStatus, val createdAt: Instant, val updatedAt: Instant, val version: Long,) { init { require(version >= 0) { "version must be non-negative" } }
fun transitionTo(newStatus: OrganizationStatus, clock: Clock): Organization { require(status.canTransitionTo(newStatus)) { "Cannot transition from $status to $newStatus" } return Organization(id, name, newStatus, createdAt, clock.instant(), version + 1) }
companion object { fun create(id: UUID, name: OrganizationName, clock: Clock): Organization { val now = clock.instant() return Organization(id, name, OrganizationStatus.PENDING_APPROVAL, now, now, version = 0) } }}❌ DON’T:
- Expose mutable state
- Include infrastructure concerns (JPA annotations in domain)
- Create anemic entities (data bags without behavior) — see ddd.md §Anemic Domain and clean-code.md
- Use
data classfor aggregate roots —copy(...)lets callers bypass the entity’s invariants and transition guards - Reference fields in
copy(...)that don’t exist on the class (silent compile-time bug); prefer explicit factory methods that name every field
9.4 Use Case Design
✅ DO:
- One use case per port
- Keep use cases focused (single responsibility)
- Use dependency injection via constructor
- Leverage transactions explicitly
- Log important business events
class CreateOrganizationUseCase( private val transaction: TransactionPort<Organization>, private val repository: OrganizationRepositoryPort, private val outbox: OutboxRepositoryPort, // SDK private val events: OutboxEventFactory, // SDK) : CreateOrganizationPort {
companion object { private val log = LoggerFactory.getLogger(CreateOrganizationUseCase::class.java) }
override fun execute(command: CreateOrganizationCommand): Organization = transaction.run { val saved = repository.save(command.toOrganization()) outbox.save(events.create( topic = "platform.organizations", traceId = command.traceId, aggregate = saved.id.toString(), eventName = "OrganizationCreated", eventType = EventTypes.EVENT, payload = OrganizationCreatedPayload.from(saved), )) log.info("Created organization: id={}", saved.id) saved }}❌ DON’T:
- Include HTTP/REST logic in use cases
- Directly use infrastructure classes (databases, Kafka)
- Create god classes handling multiple responsibilities
9.5 Adapter Design
✅ DO:
- Implement one port per adapter
- Handle infrastructure exceptions
- Map between domain and infrastructure models
- Add logging for debugging
- Use configuration externalization
@Componentclass OrganizationJdbcAdapter( private val jdbcRepository: OrganizationJdbcRepository) : OrganizationRepositoryPort {
private val log = LoggerFactory.getLogger(OrganizationJdbcAdapter::class.java)
override fun save(organization: Organization): Organization { try { val entity = organization.toDatabaseEntity() val saved = jdbcRepository.save(entity) log.debug("Saved organization ${organization.id} to database") return saved.toDomainModel() } catch (ex: DataAccessException) { log.error("Failed to save organization ${organization.id}", ex) throw PersistenceException("Could not save organization", ex) } }}❌ DON’T:
- Leak infrastructure details to application layer
- Catch exceptions and suppress them
- Mix multiple port implementations in one adapter
10. Recommendations
10.1 Short-Term Improvements
1. Complete Value Objects
Current: Primitives used for domain concepts Recommendation: Create value objects for:
Email(with validation)OrganizationName(with business rules)Address(structured)
// Beforedata class Organization( val contactEmail: String, // ❌ Primitive obsession val name: String, // ❌ No validation val address: String // ❌ Unstructured)
// After — aggregate root: regular class (private ctor + factory), NOT data classclass Organization private constructor( val contactEmail: Email, // ✅ Value object val name: OrganizationName, // ✅ Value object val address: Address // ✅ Value object) { companion object { fun create(contactEmail: Email, name: OrganizationName, address: Address): Organization = Organization(contactEmail, name, address) }}
// Value objects stay as data classdata class Email(val value: String) { init { require(value.matches(EMAIL_REGEX)) { "Invalid email format: $value" } }
companion object { private val EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$".toRegex() }}2. Implement Workflow Module
Current: WorkflowRepositoryPort is empty
Recommendation: Define workflow state machine for organization lifecycle
// Workflow definitionenum class WorkflowState { SUBMITTED, UNDER_REVIEW, APPROVED, REJECTED, ACTIVE, SUSPENDED}
// Aggregate root — the state machine owns its transition rules. NOT a data class:// each `advance(...)` returns a new instance only if the transition is legal.class Workflow private constructor( val id: UUID, val organizationId: UUID, val currentState: WorkflowState, val history: List<StateTransition>, val version: Long,) { init { require(version >= 0) { "version must be non-negative" } }
fun advance(target: WorkflowState, reason: String, clock: Clock): Workflow { require(currentState.canTransitionTo(target)) { "Illegal workflow transition: $currentState → $target" } val transition = StateTransition(currentState, target, reason, clock.instant()) return Workflow(id, organizationId, target, history + transition, version + 1) }
fun isTerminal(): Boolean = currentState == WorkflowState.REJECTED
companion object { fun start(id: UUID, organizationId: UUID, clock: Clock): Workflow = Workflow(id, organizationId, WorkflowState.SUBMITTED, emptyList(), version = 0) }}
// State-transition rules colocated with the enumfun WorkflowState.canTransitionTo(target: WorkflowState): Boolean = when (this) { WorkflowState.SUBMITTED -> target == WorkflowState.UNDER_REVIEW WorkflowState.UNDER_REVIEW -> target == WorkflowState.APPROVED || target == WorkflowState.REJECTED WorkflowState.APPROVED -> target == WorkflowState.ACTIVE WorkflowState.ACTIVE -> target == WorkflowState.SUSPENDED WorkflowState.SUSPENDED -> target == WorkflowState.ACTIVE WorkflowState.REJECTED -> false}3. Add API Versioning Strategy
Current: Single version (/api/v1/...)
Recommendation: Prepare for evolution
// Controller versioning@RestController@RequestMapping("/api/v1/organizations")class OrganizationControllerV1 { ... }
@RestController@RequestMapping("/api/v2/organizations")class OrganizationControllerV2 { ... }
// Command versioningdata class CreateOrganizationCommandV1(...)data class CreateOrganizationCommandV2(...) // Extended fields
// Mapper compatibilityfun CreateOrganizationCommandV2.toV1(): CreateOrganizationCommandV1 { return CreateOrganizationCommandV1( name = this.name, contactEmail = this.contactEmail // New fields omitted for V1 compatibility )}10.2 Medium-Term Enhancements
4. Implement Saga Pattern
Use Case: Complex multi-service operations Example: Organization creation with user provisioning
// Saga coordinatorclass CreateOrganizationSaga( private val orgService: OrganizationService, private val userService: UserService, private val notificationService: NotificationService) { fun execute(command: CreateOrganizationCommand): SagaResult { val sagaId = UUID.randomUUID()
try { // Step 1: Create organization val org = orgService.create(command)
// Step 2: Create admin user val user = userService.createAdmin(org.id)
// Step 3: Send notification notificationService.sendWelcome(user.email)
return SagaResult.Success(org) } catch (ex: Exception) { // Compensating transactions compensate(sagaId, ex) return SagaResult.Failure(ex) } }
private fun compensate(sagaId: UUID, error: Exception) { // Rollback steps in reverse order // Log compensation for audit }}5. Add Circuit Breaker (where applicable — NOT for event publishing)
Use Case: Resilience for synchronous external service calls (e.g. third-party REST APIs). Implementation: Resilience4j on the outbound adapter.
⚠ Anti-pattern to avoid: “circuit-breaker + outbox fallback” on a publisher. Wrapping a broker call in a circuit breaker and falling back to the outbox is the same dual-write problem in a fancier shape — the fast path can publish while the DB rolls back, and the slow path runs different code, different ordering. Always go through the outbox. The SDK dispatcher (
SendOutboxMessagesUseCase) IS the resilience layer for event publishing: it owns the retry policy, the backoff, the claim TTL and the DLT recipe (see messaging.md §6).
Use a circuit breaker for synchronous outbound HTTP/gRPC calls — never as a substitute for outbox dispatch:
@Componentclass PaymentGatewayClient(private val webClient: WebClient) {
private val cb = CircuitBreaker.ofDefaults("payment-gateway")
fun authorize(req: AuthRequest): AuthResult = cb.executeSupplier { webClient.post().uri("/authorize").bodyValue(req) .retrieve().bodyToMono(AuthResult::class.java) .block(Duration.ofSeconds(5)) ?: throw PaymentGatewayException("empty response") }}6. Implement Health Checks
Use Case: Monitor adapter health Implementation: Spring Boot Actuator
Single legitimate exception to “no
kafkaTemplate.sendoutside the dispatcher adapter”: a liveness probe. Even so, prefer a producer-metrics check (kafkaTemplate.metrics()) over actually sending a message — synthetic health-check messages pollute topics and consumer logs.
@Componentclass KafkaHealthIndicator( private val kafkaTemplate: KafkaTemplate<String, Any>) : HealthIndicator {
override fun health(): Health = try { // Prefer this — no synthetic message hitting the topic val producerCount = kafkaTemplate.metrics().size Health.up().withDetail("kafka.producer.metrics", producerCount).build() } catch (ex: Exception) { Health.down(ex).withDetail("kafka", "disconnected").build() }}10.3 Long-Term Strategic Initiatives
7. Event Sourcing (Optional)
Use Case: Full audit trail of all state changes Benefits:
- Complete event history
- Time-travel debugging
- Event replay capabilities
// Event storeinterface EventStore { fun append(aggregateId: UUID, events: List<DomainEvent>) fun loadEvents(aggregateId: UUID): List<DomainEvent>}
// Domain eventssealed class OrganizationEvent : DomainEvent { data class Created(...) : OrganizationEvent() data class StatusChanged(...) : OrganizationEvent() data class ContactUpdated(...) : OrganizationEvent()}
// Rebuild state from eventsfun loadOrganization(id: UUID, eventStore: EventStore): Organization { val events = eventStore.loadEvents(id) return events.fold(Organization.empty()) { org, event -> org.apply(event) }}8. CQRS with Separate Read Models
Use Case: Optimize for read-heavy workloads Implementation:
- Write model: PostgreSQL (transactions)
- Read model: Elasticsearch (fast queries)
// Write side (commands)interface OrganizationWriteRepositoryPort { fun save(organization: Organization): Organization}
// Read side (queries)interface OrganizationReadRepositoryPort { fun findById(id: UUID): OrganizationReadModel? fun search(query: SearchQuery): List<OrganizationReadModel>}
// Projection (keeps read model in sync) — MUST be idempotent (see messaging.md §7)@Componentclass OrganizationProjection( private val elasticsearchRepo: ElasticsearchRepository, private val processed: ProcessedEventPort, // idempotency-key table) { @KafkaListener(topics = ["organization-events"]) @Transactional fun project(@Payload event: EventMessage<OrganizationEvent>) { val eventId = event.header.traceId // or a dedicated event id field if (!processed.tryRecord(consumer = "organization-projection", eventId = eventId)) { return // duplicate — skip silently } when (val body = event.body.payload) { is OrganizationCreated -> elasticsearchRepo.index(body.toReadModel()) is OrganizationUpdated -> elasticsearchRepo.update(body.id, body.toReadModel()) } }}Appendix A: File Locations Reference
Inbound Ports
application/src/main/kotlin/.../port/inbound/├── CreateOrganizationPort.kt├── GetOrganizationByIdPort.kt├── ChangeOrganizationStatusPort.kt└── SendOutboxMessagesPort.kt # provided by codehuntersio-sdk-event-messagingOutbound Ports
application/src/main/kotlin/.../port/outbound/├── repository/│ ├── OrganizationRepositoryPort.kt│ ├── UserRepositoryPort.kt│ ├── OutboxRepositoryPort.kt│ └── support/│ ├── TransactionPort.kt│ ├── TransactionCallbackPort.kt│ └── ChainablePort.kt└── # NO publisher/ folder — outbox port + factory provided by codehuntersio-sdk-event-messaging # (OutboxRepositoryPort, MessagePublisherPort, SendOutboxMessagesPort, OutboxEventFactory)Use Cases
application/src/main/kotlin/.../usecase/└── organization/ ├── CreateOrganizationUseCase.kt ├── GetOrganizationByIdUseCase.kt ├── ChangeOrganizationStatusUseCase.kt └── command/ ├── CreateOrganizationCommand.kt └── mapper/ └── CommandMapper.ktDomain
domain/src/main/kotlin/.../domain/├── Organization.kt├── User.kt├── enums/│ ├── OrganizationStatus.kt│ └── Roles.kt├── exception/│ └── OrganizationNotFoundException.kt└── util/ ├── MdcUtil.kt └── TraceContextUtil.ktInfrastructure - Entry Points
infrastructure/entry-points/├── rest-controller/│ └── .../controller/organization/│ ├── OrganizationController.kt│ └── model/│ ├── request/│ │ └── CreateOrganizationRequest.kt│ └── response/│ ├── CreatedOrganizationResponse.kt│ └── OrganizationResponse.kt└── scheduler-task/ └── OutboxTaskScheduler.ktInfrastructure - Driven Adapters
infrastructure/driven-adapters/├── jdbc-repository/│ ├── OrganizationRepositoryAdapter.kt│ └── repository/transaction/│ └── JdbcTransaction.kt├── kafka-producer/│ ├── OrganizationKafkaPublisherAdapter.kt│ └── publisher/properties/│ └── KafKaTopicProperties.kt├── redis-cache/│ └── OrganizationCacheAdapter.kt└── metrics-publisher/ └── OrganizationMetricsAdapter.ktAppendix B: Quick Decision Guide
”Should I use a Command or Domain Entity directly?”
Use Command when:
- ✅ Receiving input from external source (REST, GraphQL, CLI)
- ✅ Input needs validation before domain creation
- ✅ Input is partial (missing system fields like id, timestamps)
- ✅ You want API evolution independence
Use Domain Entity when:
- ✅ Internal communication between use cases
- ✅ Query operations (reads don’t need commands)
- ✅ Publishing events (entity has complete state)
“Should I create a new port or extend existing?”
Create new port when:
- ✅ Different responsibility (SRP)
- ✅ Different transaction boundary
- ✅ Different client type (REST vs Message vs Scheduler)
Extend existing port when:
- ✅ Same aggregate, same operation type (read/write)
- ✅ Optional variation of existing method
”Should I use decorator (ChainablePort) or direct implementation?”
Use decorator when:
- ✅ Cross-cutting concern (caching, metrics, logging)
- ✅ Functionality can be added/removed independently
- ✅ Multiple decorators can be chained
Use direct implementation when:
- ✅ Core business logic
- ✅ Single responsibility adapter
- ✅ No need for layered behavior
Appendix C: Troubleshooting
Build fails with “Unresolved reference: Port”
Problem: Missing port import after refactoring
Solution: Add import: import com.example.platform.port.inbound.[YourPort]
Tests fail with “lateinit property has not been initialized”
Problem: Port dependency not injected
Solution: Check UseCaseConfig bean definition
Event never reaches the broker
Problem: Business row committed but no event published — usually a use case publishing directly (dual-write) instead of appending to the outbox, or outbox rows stuck in PENDING / IN_FLIGHT
Solution: Use cases inject only OutboxRepositoryPort + OutboxEventFactory and save the event in the same transaction as the business write — never a broker call. Then inspect the outbox: rows stuck in PENDING mean the dispatcher is not ticking (ShedLock lock name, scheduler enabled); stale IN_FLIGHT rows are re-claimed after the claim TTL; FAILED rows carry the broker error. See messaging.md §5
Transaction not rolling back
Problem: Exception caught and swallowed Solution: Ensure transaction.run not wrapped in try-catch