Saltar al contenido
Mis Guidelines
Guías
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-service placeholders 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 Port suffix
  • Keep ports in application layer
  • Define single responsibility per port
  • Use fun interface for 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 command package
  • Create mapper extensions (Command.toEntity())
// ✅ Good Command
data 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 Command
data 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 class with val + private ctor + factory; do NOT use data class for 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 class for 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
@Component
class 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)
// Before
data 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 class
class 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 class
data 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 definition
enum 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 enum
fun 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 versioning
data class CreateOrganizationCommandV1(...)
data class CreateOrganizationCommandV2(...) // Extended fields
// Mapper compatibility
fun 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 coordinator
class 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:

@Component
class 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.send outside 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.

@Component
class 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 store
interface EventStore {
fun append(aggregateId: UUID, events: List<DomainEvent>)
fun loadEvents(aggregateId: UUID): List<DomainEvent>
}
// Domain events
sealed class OrganizationEvent : DomainEvent {
data class Created(...) : OrganizationEvent()
data class StatusChanged(...) : OrganizationEvent()
data class ContactUpdated(...) : OrganizationEvent()
}
// Rebuild state from events
fun 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)
@Component
class 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-messaging

Outbound 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.kt

Domain

domain/src/main/kotlin/.../domain/
├── Organization.kt
├── User.kt
├── enums/
│ ├── OrganizationStatus.kt
│ └── Roles.kt
├── exception/
│ └── OrganizationNotFoundException.kt
└── util/
├── MdcUtil.kt
└── TraceContextUtil.kt

Infrastructure - Entry Points

infrastructure/entry-points/
├── rest-controller/
│ └── .../controller/organization/
│ ├── OrganizationController.kt
│ └── model/
│ ├── request/
│ │ └── CreateOrganizationRequest.kt
│ └── response/
│ ├── CreatedOrganizationResponse.kt
│ └── OrganizationResponse.kt
└── scheduler-task/
└── OutboxTaskScheduler.kt

Infrastructure - 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.kt

Appendix 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