-
Notifications
You must be signed in to change notification settings - Fork 0
Design Patterns
Catalog of patterns used across StableBridge, with implementation guidance.
| 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 |
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.
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()
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:
-
EventPublisheradapter callsoutbox.schedule(event, routingKey)with@Transactional(propagation = MANDATORY) -
OutboxHandlerclass with@OutboxHandlerannotation relays to Kafka - 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
@OutboxHandlerper service is sufficient - Event routing key = aggregate root ID (e.g.,
merchantId,paymentId)
See Event Driven Architecture for Kafka topic details.
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.
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
Used for behavior that varies by configuration or provider:
// 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 { ... }@ConditionalOnMissingBean
public class FallbackKybAdapter implements KybVerificationPort {
// Returns mock data when no real provider is configured
}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.
@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.
Separate query/command paths for read-heavy operations:
-
CommandHandlerfor writes (through aggregates) -
QueryHandlerfor reads (can use database views) - Same database — not full CQRS with event sourcing
- 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
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)@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 { ... }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 |
- Architecture Overview — Hexagonal architecture details
- Architecture Decision Records — Why these patterns
- Event Driven Architecture — Outbox and Kafka details
- Testing Standards — Testing patterns
StableBridge Platform | Source Code | CI/CD | Built with Java 25 + Spring Boot 4 + Temporal + Kafka + Base L2
StableBridge Platform
Architecture & Design
Development
- Getting Started
- Project Structure
- Coding Standards
- Testing Standards
- Database Conventions
- Event Driven Architecture
Operations & Security
Project
Reference