-
Notifications
You must be signed in to change notification settings - Fork 0
Domain Model
Aggregates, value objects, invariants and lifecycle state machines for FinCore Engine. This is the source of truth for what the system models. Code in
domain/packages must match this. Database schema (see Data-Model) must support these invariants.All terms here are defined in Domain-Glossary.
-
Aggregate = consistency boundary. One DB transaction can mutate at most one aggregate (modular monolith caveat: in
Payment, we cross-mutateTransactionaggregates from the same DB schema, traded against simplicity - see ADR-0001). - Entity = has identity, mutable.
- Value object = immutable, defined by attributes.
- Invariant = property always true for valid state. Enforced in domain layer + DB constraints.
-
State machine = explicit allowed transitions. Implemented as guards in
*ServiceImpl. Illegal transitions throwIllegalStateException. - All identifiers are UUID v7 (time-ordered, k-sortable). Generated in code (
UUID.randomUUID()in tests, time-based UUID generator in production). -
Moneyis always(BigDecimal amount, Currency currency)- never naked numbers.
FinCore is split into bounded contexts, each with its own aggregates and DB schema:
flowchart LR
subgraph Ledger
Account
Transaction
Entry
end
subgraph Payments
Payment
end
subgraph Compliance
KycSession
AmlAlert
ComplianceCase
end
subgraph Decision
DecisionRule
DecisionLog
end
subgraph Platform
IdempotencyRecord
OutboxEvent
ProcessedEvent
WebhookSubscription
WebhookDelivery
end
Payment --uses--> Transaction
Payment --asks--> DecisionRule
AmlAlert --references--> Transaction
ComplianceCase --references--> AmlAlert
DecisionLog --references--> DecisionRule
Cross-context references are by ID only - never by direct entity association in JPA. Cross-context joins live in queries, not in @OneToMany.
class Account(
val id: AccountId, // UUID v7, immutable
var name: String, // 1..255 chars, mutable
val type: AccountType, // immutable
val currency: Currency, // immutable (ISO 4217)
var status: AccountStatus, // ACTIVE → FROZEN → CLOSED
val metadata: Map<String, String>, // free-form, max 16 keys, JSONB
val createdAt: Instant, // immutable
var updatedAt: Instant,
var version: Long = 0, // optimistic locking
)
@JvmInline value class AccountId(val value: UUID)
enum class AccountType { ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE, USER_WALLET, FEE, RESERVE, SUSPENSE }
enum class AccountStatus { ACTIVE, FROZEN, CLOSED }-
idis immutable once set -
typeandcurrencyare immutable - to change either, close the account and open a new one (audit trail) name.length in 1..255-
metadatasize ≤ 16 entries, each key matches[a-z][a-z0-9_]{0,63}, value ≤ 256 chars -
statusfollows the state machine below
stateDiagram-v2
[*] --> ACTIVE: Create
ACTIVE --> FROZEN: Freeze (compliance hold)
FROZEN --> ACTIVE: Unfreeze
ACTIVE --> CLOSED: Close (zero balance required)
FROZEN --> CLOSED: Close (zero balance required)
CLOSED --> [*]
- Closing requires balance == 0 in all currencies (single-currency account just zero in its own).
- A closed account is read-only forever. Re-opening means new account.
Balance(account, currency) = SUM(entry.amount WHERE entry.account_id = account.id AND entry.currency = currency)
Materialized in account_balances view, refreshed after each posting.
A Transaction is the aggregate root; Entry rows are part of it. You never insert an Entry independently - only as a child of a Transaction write.
class Transaction(
val id: TransactionId,
val reference: String, // unique externally-supplied id
val description: String?, // optional, ≤2048 chars
var status: TransactionStatus, // POSTED → REVERSED, terminal
val reversesId: TransactionId?, // if this is a reversing transaction
val metadata: Map<String, String>,
val postedAt: Instant,
val createdAt: Instant,
val entries: List<Entry>, // 2..1000 entries
)
class Entry(
val id: EntryId,
val transactionId: TransactionId,
val accountId: AccountId,
val amount: BigDecimal, // signed, NUMERIC(38,18)
val currency: Currency,
val direction: EntryDirection,
val createdAt: Instant,
)
@JvmInline value class TransactionId(val value: UUID)
@JvmInline value class EntryId(val value: UUID)
enum class TransactionStatus { POSTED, REVERSED }
enum class EntryDirection { DEBIT, CREDIT }-
SUM(entries.amount WHERE currency = X) = 0for every currency X - the double-entry invariant. Enforced via deferred Postgres trigger. entries.size in 2..1000-
All entries reference accounts in
ACTIVEstatus (checked viaSELECT FOR UPDATEat posting time) -
All entries with the same
accountId+directionmust have non-zero amount (no zero-value entries) referenceis unique across all transactions-
statusonly transitions POSTED → REVERSED, never reversed → posted, never any update to other fields -
entriesis immutable once the transaction is committed -
Reversing transaction (
reversesId != null): the original must bePOSTEDand not already reversed. The new transaction's entries must be the additive inverse of the original's. - Currency consistency: an Entry's currency must match its referenced Account's currency (no mixed-currency posting)
-
Sign convention: for an account of type
ASSETorEXPENSE, aDEBITentry has positiveamountand aCREDITentry has negative. ForLIABILITY/EQUITY/REVENUE/USER_WALLET/FEE, opposite. The trigger doesn't enforce sign convention - application service does.
stateDiagram-v2
[*] --> POSTED: Post
POSTED --> REVERSED: Reverse (creates compensating tx)
POSTED --> [*]: (terminal - never deleted)
REVERSED --> [*]: (terminal)
The journal is immutable. REVERSED doesn't undo the row - it links to a new compensating transaction.
Two equivalent representations exist:
-
Signed amount:
amount = -100for debit,+100for credit (or v.v.) -
Unsigned amount + direction enum:
amount = 100, direction = DEBIT
We store both: signed amount for fast invariant checks (SUM = 0), and explicit direction for clarity in queries and reports. Trigger validates direction matches sign sense per account type.
class Payment(
val id: PaymentId,
val idempotencyKey: IdempotencyKey,
val fromAccountId: AccountId?, // nullable for inbound funding
val toAccountId: AccountId?, // nullable for outbound to external
val externalCounterparty: ExternalCounterparty?, // for outbound to bank
val amount: Money,
var state: PaymentState,
var ledgerTransactionId: TransactionId?, // set when ledger posted
val providerRef: ProviderReference?, // bank provider's id
var attempts: Int = 0,
val maxAttempts: Int = 6,
val retryable: Boolean,
var nextRetryAt: Instant?,
var lastError: String?,
val rejectReason: String?,
val complianceCaseId: ComplianceCaseId?, // set if PENDING_REVIEW
val createdAt: Instant,
var updatedAt: Instant,
var version: Long = 0,
val events: List<PaymentEvent>, // append-only audit
)
class PaymentEvent(
val id: UUID,
val paymentId: PaymentId,
val type: PaymentEventType,
val payload: JsonObject,
val occurredAt: Instant,
val actor: Actor,
)
class ExternalCounterparty(
val name: String,
val iban: String?,
val bic: String?,
val accountNumber: String?, // for non-IBAN regions
val country: CountryCode,
)
@JvmInline value class PaymentId(val value: UUID)
@JvmInline value class IdempotencyKey(val value: String) // ≤ 255 chars
@JvmInline value class ProviderReference(val value: String)
@JvmInline value class CountryCode(val value: String) // ISO 3166-1 alpha-2
enum class PaymentState {
CREATED, PROCESSING, COMPLETED, FAILED, PERMANENTLY_FAILED, REJECTED, PENDING_REVIEW
}
enum class PaymentEventType {
CREATED, DECISION_APPROVED, DECISION_REJECTED, DECISION_REVIEW_REQUIRED,
LEDGER_POSTED, SENT_TO_PROVIDER, PROVIDER_ACK, PROVIDER_TIMEOUT,
COMPLETED, FAILED, RETRY_SCHEDULED, PERMANENTLY_FAILED, REVERSED, MANUAL_OVERRIDE
}- State machine must be respected (see below).
-
amount.amount > 0always. -
amount.currencymust match both involved accounts' currencies (no FX in OSS). -
Exactly one of (
fromAccountId,externalCounterparty) is set on outbound from internal. -
idempotencyKeyis unique across all payments. -
ledgerTransactionIdis null until the payment moves toPROCESSING(or directly toCOMPLETEDfor synchronous internal-internal). -
attempts <= maxAttempts. -
eventsis append-only - old events never modified. -
PENDING_REVIEWalways hascomplianceCaseId != null.
stateDiagram-v2
[*] --> CREATED: initiate
CREATED --> REJECTED: decision = REJECT
CREATED --> PENDING_REVIEW: decision = REVIEW
CREATED --> PROCESSING: decision = APPROVE & internal
CREATED --> PROCESSING: decision = APPROVE & external sent
PENDING_REVIEW --> CREATED: officer approves
PENDING_REVIEW --> REJECTED: officer rejects
PROCESSING --> COMPLETED: provider ack OR internal posting
PROCESSING --> FAILED: provider error (retryable)
PROCESSING --> PERMANENTLY_FAILED: provider rejects
FAILED --> PROCESSING: scheduled retry
FAILED --> PERMANENTLY_FAILED: max attempts
COMPLETED --> [*]
PERMANENTLY_FAILED --> [*]
REJECTED --> [*]
Reversals: a COMPLETED payment can be reversed via UC-05 - this creates a new compensating Transaction, but the Payment's state stays COMPLETED. A new event REVERSED is appended to its events list.
Manual override: an operator with role PAYMENTS_ADMIN can transition states explicitly (with audit reason). Allowed manual transitions:
-
PENDING_REVIEW → CREATED(UC-12 manual approval) -
PENDING_REVIEW → REJECTED(manual reject) -
FAILED → PERMANENTLY_FAILED(give up) -
PERMANENTLY_FAILED → COMPLETED(only if proof of out-of-band settlement; rare)
class KycSession(
val id: KycSessionId,
val userId: UserId, // external - FinCore doesn't manage users itself
val provider: KycProvider, // sandbox | external provider (out of OSS scope)
val providerSessionId: String?,
val hostedUrl: String?,
var status: KycSessionStatus,
val evidence: KycEvidence?,
val rejectionReason: String?,
val expiresAt: Instant,
val createdAt: Instant,
var updatedAt: Instant,
var version: Long = 0,
)
class KycEvidence(
val provider: KycProvider,
val externalRef: String,
val verifiedAt: Instant,
val confidence: BigDecimal, // 0.00..1.00
val sanctionsHit: Boolean,
val pepHit: Boolean,
val adverseMediaHit: Boolean,
)
@JvmInline value class KycSessionId(val value: UUID)
@JvmInline value class UserId(val value: UUID)
@JvmInline value class KycProvider(val value: String)
enum class KycSessionStatus {
PENDING, AWAITING_DOCUMENTS, IN_REVIEW, APPROVED, REJECTED, EXPIRED, MANUAL_REVIEW
}-
expiresAt > createdAt(default 30 days) - Once
APPROVEDorREJECTED, no further state changes - PII never persisted in this aggregate - only
evidence.externalRefpointing to the provider
stateDiagram-v2
[*] --> PENDING: createSession
PENDING --> AWAITING_DOCUMENTS: provider session created
AWAITING_DOCUMENTS --> IN_REVIEW: user uploads
IN_REVIEW --> APPROVED: provider says clean
IN_REVIEW --> REJECTED: provider says no
IN_REVIEW --> MANUAL_REVIEW: provider unsure
MANUAL_REVIEW --> APPROVED: officer ok
MANUAL_REVIEW --> REJECTED: officer reject
PENDING --> EXPIRED: TTL exceeded
AWAITING_DOCUMENTS --> EXPIRED: TTL exceeded
APPROVED --> [*]
REJECTED --> [*]
EXPIRED --> [*]
class AmlAlert(
val id: AmlAlertId,
val transactionId: TransactionId,
val ruleSet: String,
val matchedRules: List<MatchedRule>,
val riskScore: Int, // 0..100
var status: AmlAlertStatus,
val createdAt: Instant,
)
class MatchedRule(
val ruleId: String,
val ruleVersion: Int,
val priority: Int,
val explanation: String,
)
@JvmInline value class AmlAlertId(val value: UUID)
enum class AmlAlertStatus { OPEN, LINKED_TO_CASE, CLOSED_NO_ACTION }riskScore in 0..100matchedRules.size > 0- Once
CLOSED_NO_ACTION, immutable
class ComplianceCase(
val id: ComplianceCaseId,
val alertId: AmlAlertId?, // null if manually opened
val paymentId: PaymentId?,
val kycSessionId: KycSessionId?,
var status: ComplianceCaseStatus,
var decision: ComplianceCaseDecision?,
var resolvedBy: UserId?,
var resolvedAt: Instant?,
var resolutionReason: String?, // free-text + structured code
val priority: ComplianceCasePriority,
val assignedTo: UserId?,
val notes: List<CaseNote>,
val attachments: List<EvidenceRef>, // pointers to objects, never PII inline
val aiExplanation: String?, // from AmlCopilot plug-in (optional)
val draftReport: String?, // from AmlCopilot
val createdAt: Instant,
var updatedAt: Instant,
var version: Long = 0,
)
@JvmInline value class ComplianceCaseId(val value: UUID)
enum class ComplianceCaseStatus { OPEN, IN_REVIEW, RESOLVED, ESCALATED }
enum class ComplianceCaseDecision { APPROVED, REJECTED, ESCALATED_TO_REGULATOR }
enum class ComplianceCasePriority { LOW, MEDIUM, HIGH, CRITICAL }-
At least one of
(alertId, paymentId, kycSessionId)is set - Resolution requires
resolutionReason - Resolved cases are immutable - re-opening creates a new case linked to the previous
stateDiagram-v2
[*] --> OPEN: created (auto from alert OR manually)
OPEN --> IN_REVIEW: officer claims
IN_REVIEW --> RESOLVED: officer decides
IN_REVIEW --> ESCALATED: officer escalates
RESOLVED --> [*]
ESCALATED --> RESOLVED: regulator/management decision
Side-effects on resolution:
-
RESOLVED & decision=APPROVED→ unfreeze payment / proceed transaction -
RESOLVED & decision=REJECTED→ reverse the linked transaction (UC-05) if exists -
RESOLVED & decision=ESCALATED_TO_REGULATOR→ trigger SAR/STR draft generation
class DecisionRule(
val id: DecisionRuleId,
val ruleSetId: RuleSetId,
val name: String,
var status: DecisionRuleStatus,
val currentVersion: Int,
val createdAt: Instant,
var updatedAt: Instant,
val versions: List<DecisionRuleVersion>,
)
class DecisionRuleVersion(
val id: UUID,
val ruleId: DecisionRuleId,
val version: Int,
val priority: Int, // higher = evaluated first
val terminate: Boolean, // stop ruleset evaluation if matched
val definitionJson: JsonObject, // the DSL rule body
val createdAt: Instant,
val createdBy: UserId,
val activeFrom: Instant?, // set when activated
val activeUntil: Instant?, // set when superseded
)
@JvmInline value class DecisionRuleId(val value: UUID)
@JvmInline value class RuleSetId(val value: String)
enum class DecisionRuleStatus { DRAFT, ACTIVE, DEPRECATED }-
versions.size > 0- rule must have at least one version -
Exactly one version is "active" at any time per
(ruleSetId, ruleId)- others haveactiveUntil != null - DSL definition is validated against schema at insert time (no invalid rules ever stored)
- Versions are immutable - to change a rule, create a new version
-
Rule never deleted - only deprecated.
decision_logsmay reference any historical version
stateDiagram-v2
[*] --> DRAFT: create
DRAFT --> ACTIVE: activate
ACTIVE --> DEPRECATED: deprecate (new version replaces, OR rule retired)
DRAFT --> [*]: discard (only DRAFT can be deleted; no logs reference it)
DEPRECATED --> [*]
class DecisionLog(
val id: UUID,
val ruleSetId: RuleSetId,
val inputPayload: JsonObject,
val matchedRules: List<MatchedRule>, // snapshot - references frozen ruleVersionId
val decision: Decision,
val explanation: String, // human-readable composite
val latencyMs: Long,
val invokedBy: ServiceName,
val correlationId: UUID,
val createdAt: Instant,
)
enum class Decision { APPROVE, REJECT, REVIEW }- Append-only - never updated, never deleted (regulatory requirement)
-
Reproducibility - given
inputPayload+ matchedruleVersionIds, re-evaluation produces samedecision - Retention - minimum 7 years (regulatory)
class IdempotencyRecord(
val key: IdempotencyKey,
val requestHash: String, // SHA-256 of normalized request body
val responseStatus: Int,
val responseBody: JsonObject,
val createdAt: Instant,
val expiresAt: Instant, // default created + 24h
)Invariants:
-
keyis primary key -
requestHashmismatch with same key → 409 Conflict expiresAt > createdAt
class OutboxEvent(
val id: UUID,
val aggregateType: String,
val aggregateId: String,
val eventType: String, // e.g. "transaction.posted"
val payload: JsonObject, // EventEnvelope serialized
var status: OutboxEventStatus, // PENDING | PUBLISHED | FAILED
var attempts: Int = 0,
val createdAt: Instant,
var publishedAt: Instant?,
var lastError: String?,
)
enum class OutboxEventStatus { PENDING, PUBLISHED, FAILED }Invariants:
- Inserted in same DB transaction as business state
- Dispatcher publishes → marks
PUBLISHED(idempotent on Kafka side via consumer dedup) - After 5 publish failures →
FAILED, alert fires
class ProcessedEvent(
val eventId: UUID, // unique event id from EventEnvelope
val processedAt: Instant,
)Invariants:
-
eventIdis primary key - duplicate insert = no-op - Inserted in same DB transaction as business writes triggered by the event
- Used by Kafka consumers for dedup
class WebhookSubscription(
val id: WebhookSubscriptionId,
val url: HttpUrl, // HTTPS only
val events: Set<String>, // event_type names
val secret: EncryptedSecret, // HMAC key, encrypted at rest
var status: WebhookSubscriptionStatus,
val createdAt: Instant,
var updatedAt: Instant,
val deliveryStats: DeliveryStats,
)
enum class WebhookSubscriptionStatus { ACTIVE, PAUSED, DISABLED_DEAD }class WebhookDelivery(
val id: UUID,
val subscriptionId: WebhookSubscriptionId,
val eventId: UUID,
var status: DeliveryStatus,
var attempts: Int,
var nextRetryAt: Instant?,
val httpStatus: Int?,
val httpResponseBodyExcerpt: String?,
val latencyMs: Long?,
val firstAttemptAt: Instant,
var lastAttemptAt: Instant,
)
enum class DeliveryStatus { PENDING, DELIVERED, FAILED, PERMANENTLY_FAILED }These hold across multiple aggregates - enforced via a combination of DB constraints + service-layer guards.
Whenever
Payment.state == COMPLETEDandPayment.ledgerTransactionId != null, the correspondingTransaction.status == POSTED(and not reversed).
If the transaction is reversed (e.g., chargeback), a new PaymentEvent.REVERSED must be appended.
Whenever
ComplianceCase.decision == REJECTEDandComplianceCase.paymentId != null, the linked Payment must move toREJECTEDand any posted ledger transaction is reversed within the same DB transaction as case resolution.
Whenever a customer's
KycSession.status != APPROVED, attempts to initiate a Payment from that customer's USER_WALLET fail at the decision step. Decision Engine has a built-in rulekyc-requiredto enforce this.
No
DecisionRuleVersioncan be hard-deleted while aDecisionLogreferences it. (ON DELETE RESTRICT.)
Whenever a request comes in with an existing
IdempotencyKey, the responseStatus and responseBody returned byte-for-byte equal what was returned the first time. No "but with updated timestamp" - the original is replayed.
| Step | Aggregate | State change | Side effect |
|---|---|---|---|
| 1 | Payment | created CREATED
|
event CREATED
|
| 2 | DecisionLog | new row | - |
| 3 | Payment |
CREATED → PROCESSING
|
event DECISION_APPROVED, LEDGER_POSTED
|
| 4 | Transaction | new row POSTED with 2 entries |
account_balances refreshed |
| 5 | Payment |
PROCESSING → COMPLETED (synchronous internal) |
event COMPLETED
|
| 6 | OutboxEvent | new row payment.completed
|
dispatched to Kafka |
All in one DB transaction.
| Step | Aggregate | State | Comment |
|---|---|---|---|
| 1 | Payment | CREATED |
initiated |
| 2 | Payment | CREATED → PROCESSING |
sent to bank |
| 3 | Payment | PROCESSING → FAILED |
bank timeout, retryable, attempts=1 |
| 4 | (retry job, 1 min later) | FAILED → PROCESSING |
re-sent with same idempotencyKey |
| 5 | Payment | PROCESSING → COMPLETED |
bank ack received via webhook |
Throughout: the same ledger Transaction is used (idempotent by Payment.idempotencyKey → Transaction.reference).
| Step | Aggregate | State |
|---|---|---|
| 1 | Payment | CREATED |
| 2 | DecisionEngine | returns REVIEW (high-risk country rule matched) |
| 3 | Payment |
CREATED → PENDING_REVIEW, complianceCaseId set |
| 4 | ComplianceCase | new OPEN
|
| 5 | Officer | claims case, status IN_REVIEW
|
| 6 | Officer | approves, status RESOLVED, decision APPROVED
|
| 7 | Payment |
PENDING_REVIEW → CREATED (re-enters pipeline; bypasses the same rule via flag bypassDecision=true) |
| 8 | Payment | CREATED → PROCESSING → COMPLETED |
- FX rates: not in OSS - delegated to bank provider
-
User aggregate: customers/users live in the adopter's identity service, FinCore stores only
userIdreferences - Multi-tenancy: each adopter handles tenancy themselves
-
Settlement / reconciliation aggregates: planned for v0.4+ (
ReconciliationRun,BankStatementaggregates) - Card-specific aggregates (Authorization, Capture, Refund): out of OSS scope, delegated to card processor adapters
- Crypto/digital assets: out of OSS scope
For the database-side reflection of each domain aggregate (DDL, indexes, constraints), see Data-Model. One general rule:
- Domain types are
class(notdata class) for entities - required by Hibernate proxying - Value objects (
Money,IdempotencyKey,AccountId) aredata classor@JvmInline value class - All persisted enums are
@Enumerated(EnumType.STRING)- neverORDINAL - All
Instantcolumns areTIMESTAMPTZin Postgres
- Overview
- Services
- Data Model
- Domain Model
- Event Flow
- Security
- Observability
- Resilience
- SLA / SLI / SLO