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

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-service placeholders 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 on
Ports (Interfaces)
↑ implemented by
Adapters (Infrastructure)

Example:

// Port (Application Layer)
interface OrganizationRepositoryPort {
fun save(organization: Organization): Organization
}
// Adapter (Infrastructure Layer)
@Component
class 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 Entity

Example:

// Command
data 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)
@Component
class 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 → Database

Example:

// Base Port
interface ChainablePort<T> {
fun setDelegate(delegate: T)
}
// Concrete Port
interface OrganizationRepositoryPort : ChainablePort<OrganizationRepositoryPort> {
fun findById(id: UUID): Organization?
}
// Cache Decorator
@Component
class 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
@Component
class 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)
@Configuration
class 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)
↑
Controller

Benefits:

  • 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):

@Component
class 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; FAILED rows 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 port
class 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)
@Configuration
class 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