Skip to content

Domain Model

Tiana_ edited this page May 30, 2026 · 1 revision

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.


Conventions

  • Aggregate = consistency boundary. One DB transaction can mutate at most one aggregate (modular monolith caveat: in Payment, we cross-mutate Transaction aggregates 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 throw IllegalStateException.
  • All identifiers are UUID v7 (time-ordered, k-sortable). Generated in code (UUID.randomUUID() in tests, time-based UUID generator in production).
  • Money is always (BigDecimal amount, Currency currency) - never naked numbers.

Bounded contexts

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
Loading

Cross-context references are by ID only - never by direct entity association in JPA. Cross-context joins live in queries, not in @OneToMany.


Bounded context: Ledger

Aggregate: Account

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 }

Invariants

  1. id is immutable once set
  2. type and currency are immutable - to change either, close the account and open a new one (audit trail)
  3. name.length in 1..255
  4. metadata size ≤ 16 entries, each key matches [a-z][a-z0-9_]{0,63}, value ≤ 256 chars
  5. status follows the state machine below

Lifecycle state machine

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 --> [*]
Loading
  • 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.

Computed fields (not persisted on 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.


Aggregate: Transaction (with Entries)

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 }

Invariants (the most important in the entire system)

  1. SUM(entries.amount WHERE currency = X) = 0 for every currency X - the double-entry invariant. Enforced via deferred Postgres trigger.
  2. entries.size in 2..1000
  3. All entries reference accounts in ACTIVE status (checked via SELECT FOR UPDATE at posting time)
  4. All entries with the same accountId+direction must have non-zero amount (no zero-value entries)
  5. reference is unique across all transactions
  6. status only transitions POSTED → REVERSED, never reversed → posted, never any update to other fields
  7. entries is immutable once the transaction is committed
  8. Reversing transaction (reversesId != null): the original must be POSTED and not already reversed. The new transaction's entries must be the additive inverse of the original's.
  9. Currency consistency: an Entry's currency must match its referenced Account's currency (no mixed-currency posting)
  10. Sign convention: for an account of type ASSET or EXPENSE, a DEBIT entry has positive amount and a CREDIT entry has negative. For LIABILITY/EQUITY/REVENUE/USER_WALLET/FEE, opposite. The trigger doesn't enforce sign convention - application service does.

Lifecycle state machine

stateDiagram-v2
    [*] --> POSTED: Post
    POSTED --> REVERSED: Reverse (creates compensating tx)
    POSTED --> [*]: (terminal - never deleted)
    REVERSED --> [*]: (terminal)
Loading

The journal is immutable. REVERSED doesn't undo the row - it links to a new compensating transaction.

Why amount is signed

Two equivalent representations exist:

  • Signed amount: amount = -100 for debit, +100 for 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.


Bounded context: Payments

Aggregate: Payment

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
}

Invariants

  1. State machine must be respected (see below).
  2. amount.amount > 0 always.
  3. amount.currency must match both involved accounts' currencies (no FX in OSS).
  4. Exactly one of (fromAccountId, externalCounterparty) is set on outbound from internal.
  5. idempotencyKey is unique across all payments.
  6. ledgerTransactionId is null until the payment moves to PROCESSING (or directly to COMPLETED for synchronous internal-internal).
  7. attempts <= maxAttempts.
  8. events is append-only - old events never modified.
  9. PENDING_REVIEW always has complianceCaseId != null.

Lifecycle state machine

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 --> [*]
Loading

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)

Bounded context: Compliance

Aggregate: KycSession

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
}

Invariants

  1. expiresAt > createdAt (default 30 days)
  2. Once APPROVED or REJECTED, no further state changes
  3. PII never persisted in this aggregate - only evidence.externalRef pointing to the provider

Lifecycle state machine

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 --> [*]
Loading

Aggregate: AmlAlert

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 }

Invariants

  1. riskScore in 0..100
  2. matchedRules.size > 0
  3. Once CLOSED_NO_ACTION, immutable

Aggregate: ComplianceCase

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 }

Invariants

  1. At least one of (alertId, paymentId, kycSessionId) is set
  2. Resolution requires resolutionReason
  3. Resolved cases are immutable - re-opening creates a new case linked to the previous

Lifecycle state machine

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
Loading

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

Bounded context: Decision Engine

Aggregate: DecisionRule (with Versions)

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 }

Invariants

  1. versions.size > 0 - rule must have at least one version
  2. Exactly one version is "active" at any time per (ruleSetId, ruleId) - others have activeUntil != null
  3. DSL definition is validated against schema at insert time (no invalid rules ever stored)
  4. Versions are immutable - to change a rule, create a new version
  5. Rule never deleted - only deprecated. decision_logs may reference any historical version

Lifecycle state machine

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 --> [*]
Loading

Entity: DecisionLog (append-only)

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 }

Invariants

  • Append-only - never updated, never deleted (regulatory requirement)
  • Reproducibility - given inputPayload + matched ruleVersionIds, re-evaluation produces same decision
  • Retention - minimum 7 years (regulatory)

Cross-cutting aggregates (Platform)

Entity: IdempotencyRecord

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:

  • key is primary key
  • requestHash mismatch with same key → 409 Conflict
  • expiresAt > createdAt

Entity: OutboxEvent

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

Entity: ProcessedEvent

class ProcessedEvent(
    val eventId: UUID,                  // unique event id from EventEnvelope
    val processedAt: Instant,
)

Invariants:

  • eventId is primary key - duplicate insert = no-op
  • Inserted in same DB transaction as business writes triggered by the event
  • Used by Kafka consumers for dedup

Entity: WebhookSubscription

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 }

Entity: WebhookDelivery (append-only)

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 }

Cross-aggregate invariants

These hold across multiple aggregates - enforced via a combination of DB constraints + service-layer guards.

CAI-1: Ledger consistency vs payment state

Whenever Payment.state == COMPLETED and Payment.ledgerTransactionId != null, the corresponding Transaction.status == POSTED (and not reversed).

If the transaction is reversed (e.g., chargeback), a new PaymentEvent.REVERSED must be appended.

CAI-2: Compliance case decision propagates

Whenever ComplianceCase.decision == REJECTED and ComplianceCase.paymentId != null, the linked Payment must move to REJECTED and any posted ledger transaction is reversed within the same DB transaction as case resolution.

CAI-3: KYC approval gates payment

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 rule kyc-required to enforce this.

CAI-4: Decision log retention vs rule version

No DecisionRuleVersion can be hard-deleted while a DecisionLog references it. (ON DELETE RESTRICT.)

CAI-5: Idempotency response is faithful replay

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.


Sample state transitions - worked examples

Example A: Successful internal payment

Step Aggregate State change Side effect
1 Payment created CREATED event CREATED
2 DecisionLog new row -
3 Payment CREATEDPROCESSING event DECISION_APPROVED, LEDGER_POSTED
4 Transaction new row POSTED with 2 entries account_balances refreshed
5 Payment PROCESSINGCOMPLETED (synchronous internal) event COMPLETED
6 OutboxEvent new row payment.completed dispatched to Kafka

All in one DB transaction.

Example B: External payment with retry

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.idempotencyKeyTransaction.reference).

Example C: Compliance flag → manual approve → complete

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

What's deliberately not modeled (yet)

  • FX rates: not in OSS - delegated to bank provider
  • User aggregate: customers/users live in the adopter's identity service, FinCore stores only userId references
  • Multi-tenancy: each adopter handles tenancy themselves
  • Settlement / reconciliation aggregates: planned for v0.4+ (ReconciliationRun, BankStatement aggregates)
  • Card-specific aggregates (Authorization, Capture, Refund): out of OSS scope, delegated to card processor adapters
  • Crypto/digital assets: out of OSS scope

Mapping to JPA entities

For the database-side reflection of each domain aggregate (DDL, indexes, constraints), see Data-Model. One general rule:

  • Domain types are class (not data class) for entities - required by Hibernate proxying
  • Value objects (Money, IdempotencyKey, AccountId) are data class or @JvmInline value class
  • All persisted enums are @Enumerated(EnumType.STRING) - never ORDINAL
  • All Instant columns are TIMESTAMPTZ in Postgres

Clone this wiki locally