En esta guía (4 elementos)
Design Patterns Catalog
Design Patterns Catalog
Project-agnostic catalog. Part of the Diagram Catalog. Covers: architectural patterns (hexagonal, CQRS, outbox), behavioral patterns (CoR, strategy, command), structural patterns (decorator, adapter, facade).
Replace
com.example.platform/platform-serviceplaceholders with your project’s actual package and service name when applying patterns.
7. Design Patterns Catalog
7.1 Architectural Patterns
1. Hexagonal Architecture (Ports & Adapters)
Intent: Decouple business logic from external concerns
Structure:
Core (Domain + Application) ↓ depends onPorts (Interfaces) ↑ implemented byAdapters (Infrastructure)Example:
// Port (Application Layer)interface OrganizationRepositoryPort { fun save(organization: Organization): Organization}
// Adapter (Infrastructure Layer)@Componentclass OrganizationJdbcAdapter : OrganizationRepositoryPort { override fun save(organization: Organization): Organization { // JDBC implementation details (map to entity, persist, map back) return organization }}Benefits:
- Domain logic isolated from frameworks
- Easy to swap implementations (JDBC → MongoDB)
- Highly testable (mock ports)
2. CQRS (Command Query Responsibility Segregation)
Intent: Separate reads from writes
Implementation:
- Commands (Writes):
CreateOrganizationPort,ChangeOrganizationStatusPort - Queries (Reads):
GetOrganizationByIdPort
Example:
// Command Side (Mutates state)fun interface CreateOrganizationPort { fun execute(command: CreateOrganizationCommand): Organization}
// Query Side (Returns data)fun interface GetOrganizationByIdPort { fun execute(id: UUID): Organization}Benefits:
- Clear separation of concerns
- Optimized read/write models
- Scalability (separate read/write databases possible)
3. Domain-Driven Design (DDD)
Intent: Model software based on business domain
Implementation:
- Aggregates:
Organization(aggregate root) - Entities:
Organization,User - Value Objects: (Future:
Email,Address) - Domain Events: Persisted via
OutboxRepositoryPort(SDK) inside the same TX as the aggregate write; published asynchronously by the SDK dispatcher.
Example:
// Aggregate Root — regular class (NOT data class), private ctor + factory,// invariants in `init`, state transitions own their guard rules.class Organization private constructor( val id: UUID, val name: OrganizationName, val status: OrganizationStatus, 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)) { "Illegal transition from $status to $newStatus" } return Organization(id, name, newStatus, clock.instant(), version + 1) }
fun isActive(): Boolean = status == OrganizationStatus.ACTIVE
companion object { fun create(id: UUID, name: OrganizationName, clock: Clock): Organization = Organization(id, name, OrganizationStatus.PENDING_APPROVAL, clock.instant(), version = 0) }}
// State-transition rules live on the enum — cohesive with the type.enum class OrganizationStatus { PENDING_APPROVAL, ACTIVE, INACTIVE, SUSPENDED;
fun canTransitionTo(target: OrganizationStatus): Boolean = when (this) { PENDING_APPROVAL -> target == ACTIVE || target == INACTIVE ACTIVE -> target == INACTIVE || target == SUSPENDED INACTIVE -> target == ACTIVE SUSPENDED -> target == ACTIVE || target == INACTIVE }}Why NOT data class here: a data class auto-exposes copy(...) which lets callers build a new Organization skipping init invariants and the transition guard — the entity loses control of its own lifecycle. Regular class + private ctor + factory + transitionTo keeps every state change going through the rules. Pass Clock so timestamps stay testable (no Instant.now() hidden in domain).
7.2 Behavioral Patterns
4. Command Pattern
Intent: Encapsulate request as an object
Structure:
Request DTO → Command → Use Case → Domain EntityExample:
// Commanddata class CreateOrganizationCommand( val name: String, val contactEmail: String, // ... other fields)
// Command Handler (Use Case)class CreateOrganizationUseCase : CreateOrganizationPort { override fun execute(command: CreateOrganizationCommand): Organization { val organization = command.toOrganization() return repository.save(organization) }}Benefits:
- Decouples sender from receiver
- Commands are serializable (audit log, undo/redo)
- Easy to add new commands
5. Repository Pattern
Intent: Mediate between domain and data mapping layers
Structure:
// Port (Application)interface OrganizationRepositoryPort { fun save(organization: Organization): Organization fun findById(id: UUID): Organization? fun update(organization: Organization): Organization}
// Adapter (Infrastructure)@Componentclass OrganizationRepositoryAdapter( private val jdbcRepo: OrganizationJdbcRepository) : OrganizationRepositoryPort { override fun save(organization: Organization): Organization { val entity = organization.toDatabaseEntity() val saved = jdbcRepo.save(entity) return saved.toDomainModel() }}Benefits:
- Abstracts persistence technology
- Domain entities remain pure
- Easy to test with in-memory implementations
6. Decorator Pattern (ChainablePort)
Intent: Add responsibilities to objects dynamically
Structure:
Request → Cache → Metrics → DatabaseExample:
// Base Portinterface ChainablePort<T> { fun setDelegate(delegate: T)}
// Concrete Portinterface OrganizationRepositoryPort : ChainablePort<OrganizationRepositoryPort> { fun findById(id: UUID): Organization?}
// Cache Decorator@Componentclass OrganizationCacheAdapter( private val redisTemplate: RedisTemplate<String, Organization>) : OrganizationRepositoryPort { private lateinit var delegate: OrganizationRepositoryPort
override fun setDelegate(delegate: OrganizationRepositoryPort) { this.delegate = delegate }
override fun findById(id: UUID): Organization? { val cacheKey = "org:$id"
// Try cache first return redisTemplate.opsForValue().get(cacheKey) ?: delegate.findById(id)?.also { org -> // Cache miss → delegate → cache result redisTemplate.opsForValue().set(cacheKey, org) } }}
// Metrics Decorator@Componentclass OrganizationMetricsAdapter( private val meterRegistry: MeterRegistry) : OrganizationRepositoryPort { private lateinit var delegate: OrganizationRepositoryPort
override fun setDelegate(delegate: OrganizationRepositoryPort) { this.delegate = delegate }
override fun findById(id: UUID): Organization? { val sample = Timer.start(meterRegistry) return try { delegate.findById(id) } finally { sample.stop(meterRegistry.timer("repository.findById.duration", Tags.of("entity", "organization"))) } }}
// Wiring (Bootstrap)@Configurationclass UseCaseConfig { @Bean fun organizationRepository( jdbc: OrganizationJdbcAdapter, cache: OrganizationCacheAdapter, metrics: OrganizationMetricsAdapter ): OrganizationRepositoryPort { // Chain: Request → Cache → Metrics → JDBC metrics.setDelegate(jdbc) cache.setDelegate(metrics) return cache }}Call Flow:
Controller ↓Cache (checks Redis) ↓ (miss)Metrics (records timing) ↓JDBC (queries database) ↑ (returns Organization)Metrics (records timing) ↑Cache (stores in Redis) ↑ControllerBenefits:
- Cross-cutting concerns separated (caching, metrics, logging)
- Easy to add/remove decorators
- No modification to core repository logic
7. Transactional Outbox Pattern
Intent: Reliable event publishing in distributed systems — eliminate the dual-write problem.
Problem: Save-to-DB + publish-to-broker cannot be atomic. Either operation may succeed alone, leaving inconsistent state (event lost OR phantom event).
Solution: Use cases write the business state AND a row in outbox_message in one local transaction. A separate dispatcher reads the outbox, publishes to the broker, and retries on failure with exponential backoff. There is no fast path — the use case never touches the broker; the dispatcher never touches the domain.
Canonical pattern source: messaging.md. Reference implementation:
codehuntersio-sdk-event-messaging. This entry is a summary; do not duplicate.
Structure:
USE CASE (synchronous, one local TX): Transaction { repo.save(aggregate) outbox.save(factory.create(...)) ← same TX, atomic } ← commit OR rollback both
DISPATCHER (asynchronous, separate process / scheduled tick): ShedLock(name=outbox-${app}) { claim batch (PENDING || stale IN_FLIGHT) ← atomic UPDATE → IN_FLIGHT for each: publish via MessagePublisherPort ok → status=PUBLISHED err → attempts++, status=PENDING, next_attempt_at = now+backoff err & attempts>=max → status=FAILED }Domain model (matches codehuntersio-sdk-event-messaging):
data class OutboxMessage<T>( val id: UUID, val origin: String, // spring.application.name of producer val routingKey: String?, val topic: String, val key: String?, // aggregate id (Kafka partition key) val message: T, // EventMessage<T> serialized as String at rest val status: OutboxStatus = OutboxStatus.PENDING, val attempts: Int = 0, val error: String? = null, val occurredAt: Instant, // set by the caller (inject Clock; no now() in domain) val nextAttemptAt: LocalDateTime? = null, val publishedAt: LocalDateTime? = null, val claimedAt: LocalDateTime? = null, // claim-based dispatch val claimedBy: String? = null, // instanceId = appName@pid)
enum class OutboxStatus { PENDING, IN_FLIGHT, PUBLISHED, FAILED }Use case call site (outbox-only — no publisher port):
class CreateOrganizationUseCase( private val transaction: TransactionPort, private val organizations: OrganizationRepositoryPort, private val outbox: OutboxRepositoryPort, private val events: OutboxEventFactory,) : CreateOrganizationPort {
override fun execute(command: CreateOrganizationCommand): Organization = transaction.run { val org = organizations.save(command.toOrganization()) outbox.save(events.create( topic = "platform.organizations", traceId = command.traceId, aggregate = org.id.toString(), eventName = "OrganizationCreated", eventType = EventTypes.EVENT, payload = OrganizationCreatedPayload.from(org), )) org }}Dispatcher (provided by SDK — SendOutboxMessagesUseCase):
@Componentclass OutboxScheduler(private val sendOutboxMessages: SendOutboxMessagesPort) {
@Scheduled(fixedDelayString = "\${outbox.task.fixed-delay:5000}") @SchedulerLock( name = "\${outbox.task.lock-name:outbox-\${spring.application.name:unknown}}", lockAtMostFor = "\${outbox.task.lock-at-most:60000}", lockAtLeastFor = "\${outbox.task.lock-at-least:10000}", ) fun tick() = runCatching { sendOutboxMessages.execute() } .onFailure { log.error("outbox tick failed", it) }}Lock name MUST be per-service (outbox-${spring.application.name}). Sharing one global lock serializes every service in the platform.
Backoff (matches SDK): delay = baseBackoffMs << (attempts-1), capped at maxBackoffMs. Defaults 30s → 1h, maxAttempts=5.
Claim-based dispatch with stale recovery: atomic UPDATE transitions PENDING (next_attempt_at <= now) OR stale IN_FLIGHT (claimed_at <= now - claimTtl) → IN_FLIGHT, stamping claimed_by. If a worker crashes mid-publish, another instance reclaims the row after claimTtl (default 5 min). See messaging.md §5.2 for SQL.
Flow Diagram:
┌──────────────────────────────────────────────────────────┐│ USE CASE (synchronous) ││ ││ Controller / Listener ││ ↓ ││ Transaction { ││ repo.save(aggregate) ✓ ││ outbox.save(factory.create(...)) ✓ ││ } ← atomic; both or neither ││ ↓ ││ Return to caller — broker NOT contacted yet │└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐│ DISPATCHER (asynchronous, ticks every 5s) ││ ││ ShedLock(outbox-${app}) acquired ││ ↓ ││ Atomic claim: PENDING + stale IN_FLIGHT → IN_FLIGHT ││ ↓ ││ For each row: MessagePublisherPort.publish(...) ││ ok → status=PUBLISHED, clear claim ││ err → attempts++, backoff ││ attempts >= max → status=FAILED ││ ↓ ││ Lock released │└──────────────────────────────────────────────────────────┘Benefits:
- Atomicity: Business write + outbox insert commit together.
- Guaranteed delivery: Retried until PUBLISHED or FAILED (audited).
- Crash safety: Stale IN_FLIGHT recovered via
claimTtl. - HA-safe: Multiple instances coordinate via per-service ShedLock; no double publish.
- Broker-agnostic: Swap RabbitMQ ↔ Kafka by overriding
MessagePublisherPort— use case unchanged. - Audit trail: All events persisted in
outbox_message;FAILEDrows triaged from there.
Anti-pattern this REPLACES: “Publish to broker first; outbox as fallback in catch.” That is still dual-write — the fast path can succeed while the DB rolls back, leaving a phantom event. Outbox-only always.
7.3 Structural Patterns
8. Dependency Injection
Intent: Decouple object creation from usage
Implementation: Spring Boot with constructor injection
// Use Case depends on abstractions (ports) — outbox-first; no publisher portclass CreateOrganizationUseCase( private val transaction: TransactionPort<Organization>, private val repository: OrganizationRepositoryPort, private val outbox: OutboxRepositoryPort, // SDK private val events: OutboxEventFactory, // SDK) : CreateOrganizationPort { // Use case implementation}
// Wiring (Bootstrap)@Configurationclass UseCaseConfig { @Bean fun createOrganizationPort( transaction: TransactionPort<Organization>, repository: OrganizationRepositoryPort, outbox: OutboxRepositoryPort, events: OutboxEventFactory, ): CreateOrganizationPort = CreateOrganizationUseCase(transaction, repository, outbox, events)}Benefits:
- Loose coupling
- Easy testing (inject mocks)
- Configuration centralized