Skip to content

Design Patterns

Puneethkumar CK edited this page Mar 17, 2026 · 1 revision

Design Patterns

Catalog of patterns used across StableBridge, with implementation guidance.


Pattern Summary

Pattern Criticality Where Used
Hexagonal Architecture Core Every service
State Machine Core Aggregates with lifecycle (Payment, Merchant)
Transactional Outbox Core All event publishing
Saga (Temporal) Core S1 Payment Orchestrator
Command Pattern High All write operations
Strategy Pattern High Feature flags, provider selection
MapStruct Mapping Standard Every layer boundary
Builder Pattern Standard All models (75+ classes)
CQRS-lite Medium Read-heavy queries
DDD Aggregates High Domain modeling
Factory Methods Standard Exception construction
Conditional Beans Medium Feature toggles

1. Hexagonal Architecture

Every service follows Ports & Adapters. See Architecture Overview for the full layer model.

  • Domain defines port interfaces (*Repository, *Port, *Provider)
  • Infrastructure implements adapters (*RepositoryAdapter, *Adapter)
  • Enforced by ArchUnit — 5 rules per service

12+ port/adapter pairs across the platform.


2. State Machine

Generic StateMachine<S, T> framework in the domain layer. Aggregates implement StateProvider<Status>.

private static final StateMachine<Status, Payment> stateMachine =
    StateMachine.<Status, Payment>builder()
        .withExceptionProvider(PaymentStateMachineException::new)
        .withTransition(INITIATED, COMPLIANCE_CHECK, emitEvent())
        .withTransition(COMPLIANCE_CHECK, FX_RATE_LOCKING, emitEvent())
        .withTransition(COMPLIANCE_CHECK, COMPLIANCE_FAILED, emitEvent())
        // ... 15+ transitions
        .build();

Key rules:

  • Transitions are declarative — defined in the aggregate's static field
  • Invalid transitions throw StateMachineException
  • Each transition can emit a domain event
  • Domain methods trigger transitions: payment.startComplianceCheck()

3. Transactional Outbox

Events are written to the outbox table in the same database transaction as the domain state change. Namastack polls and publishes to Kafka.

Service method (TX) ──► outbox.schedule(event, key) ──► DB commit
                                                             │
                        namastack scheduler polls ◄──────────┘
                                   │
                             @OutboxHandler
                                   │
                          KafkaTemplate.send()

Implementation per service:

  1. EventPublisher adapter calls outbox.schedule(event, routingKey) with @Transactional(propagation = MANDATORY)
  2. OutboxHandler class with @OutboxHandler annotation relays to Kafka
  3. Flyway migration creates 3 outbox tables: {prefix}outbox_record, {prefix}outbox_instance, {prefix}outbox_partition

Key rules:

  • Propagation.MANDATORY — guarantees outbox write is always part of a business transaction
  • One @OutboxHandler per service is sufficient
  • Event routing key = aggregate root ID (e.g., merchantId, paymentId)

See Event Driven Architecture for Kafka topic details.


4. Saga Orchestration

The Payment Orchestrator (S1) uses Temporal for durable workflow execution with LIFO compensation.

PaymentWorkflow {
    compensationStack = []

    // Step 1: Compliance check
    complianceResult = activity.checkCompliance(paymentId)
    compensationStack.push(voidCompliance)

    // Step 2: Lock FX rate
    fxLock = activity.lockFxRate(quoteId, paymentId)
    compensationStack.push(releaseFxLock)

    // Step 3: Collect fiat
    activity.initiateFiatCollection(paymentId)
    await signal: fiat.collected
    compensationStack.push(refundFiat)

    // ... more steps

    ON FAILURE AT ANY STEP:
        while (compensationStack.isNotEmpty()) {
            compensationStack.pop().execute()
        }
        publish: payment.failed
}

See Payment Flow for the full lifecycle.


5. Command Pattern

All write operations are encapsulated as command records with dedicated handlers.

// Command (domain layer)
public record ApplyMerchantCommand(String legalName, String registrationNumber, ...) {}

// Handler (domain layer)
@Service
@Transactional
@RequiredArgsConstructor
public class MerchantCommandHandler {
    private final MerchantRepository merchantRepository;

    public Merchant apply(ApplyMerchantCommand command) {
        var merchant = Merchant.createNew(command.legalName(), ...);
        return merchantRepository.save(merchant);
    }
}

Key rules:

  • Command handlers live in the domain layer (not application)
  • Handlers are @Service @Transactional
  • Controllers are thin DTO-mapping shells
  • Commands accept/return domain objects — never API DTOs

6. Strategy Pattern

Used for behavior that varies by configuration or provider:

Provider Selection (Runtime)

// Multiple adapters implement the same port
@ConditionalOnProperty(name = "app.kyb.provider", havingValue = "onfido")
public class OnfidoKybAdapter implements KybVerificationPort { ... }

@ConditionalOnProperty(name = "app.kyb.provider", havingValue = "companies-house")
public class CompaniesHouseAdapter implements KybVerificationPort { ... }

Mock Adapters for Testing

@ConditionalOnMissingBean
public class FallbackKybAdapter implements KybVerificationPort {
    // Returns mock data when no real provider is configured
}

7. MapStruct Mapping

MapStruct generates compile-time mappers at every layer boundary:

Boundary Direction Example
REST → Domain Inbound Request DTO → Command record
Domain → REST Outbound Domain model → Response DTO
Domain ↔ JPA Bidirectional Aggregate ↔ Entity
Domain → Event Outbound Aggregate → Kafka event

Key rule: Never do manual field-by-field mapping. MapStruct catches mismatches at compile time.


8. Builder Pattern

@Builder(toBuilder = true) on all records and entities (75+ classes).

Restricted builder on aggregate roots:

@Builder(toBuilder = true, access = PACKAGE)
public record Payment(...) { ... }

Forces external code to use domain methods (Payment.initiate(...)) rather than constructing directly.


9. CQRS-lite

Separate query/command paths for read-heavy operations:

  • CommandHandler for writes (through aggregates)
  • QueryHandler for reads (can use database views)
  • Same database — not full CQRS with event sourcing

10. DDD Aggregates

  • Aggregate roots with restricted builders
  • Rich domain methods for state transitions
  • Immutable value objects as Java records
  • Domain events emitted on state changes
  • Repository ports in domain, adapters in infrastructure

11. Factory Methods

Static factory methods on exceptions and aggregates:

// Exceptions
MerchantNotFoundException.withId(merchantId)
CurrencyMismatchException.withCurrencies(source, target)

// Aggregates
Merchant.createNew(legalName, registrationNumber, ...)
Payment.initiate(merchantId, amount, corridor)

12. Conditional Beans

@ConditionalOnProperty for feature flags (not @Profile):

@ConditionalOnProperty(name = "app.security.enabled", havingValue = "true", matchIfMissing = true)
public class SecurityConfig { ... }

@ConditionalOnProperty(name = "app.security.enabled", havingValue = "false")
public class NoOpSecurityConfig { ... }

External Provider Adapter Pattern

When integrating external APIs (Stripe, Fireblocks, Onfido, etc.):

Concern Decision
ACL DTOs Package-private records in infrastructure/provider/<name>/ — never leak to domain
HttpClient Force HTTP_1_1 — HTTP/2 causes EOF with WireMock
Jackson DTOs Wrapper types (Long, Integer) not primitives
Circuit breaker @CircuitBreaker(name = "xxx", fallbackMethod = "xxxFallback")
Properties @ConfigurationProperties(prefix = "app.<domain>.<provider>")
Tests WireMock-based unit tests in src/test
Redis cache StringRedisTemplate + JsonMapper for serialization

Related Pages

Clone this wiki locally