Skip to content
IPRF

Reference Architecture

Rendered from docs/framework/architecture.md

The structure that makes the sync/async classification in methodology.md enforceable rather than aspirational.


1. System overview

flowchart TB
    CLIENT(["Payment channel"])

    subgraph MONO["IPRF modular monolith — single deployable"]
        direction TB
        API["<b>transaction-api</b><br/>HTTP boundary, validation,<br/>correlation ID, pipeline orchestration"]

        subgraph INPATH["IN-PATH — bounded latency budget"]
            direction LR
            RE["<b>risk-engine</b><br/>Layers 1-2"]
            NR["<b>network-risk</b><br/>Layer 3"]
        end

        RS["<b>risk-state</b><br/>RiskStateStore client"]
        AUD["<b>audit</b><br/>immutable decision trail"]

        subgraph OFFPATH["ASYNC — no latency budget"]
            direction LR
            EE["<b>external-enrichment</b><br/>Layer 4"]
            PS["<b>post-settlement</b><br/>Layer 5"]
        end

        AE["<b>assessment-engine</b><br/>maturity + resilience assessment"]
    end

    REDIS[("Redis 7<br/>pre-computed risk state")]
    PG[("PostgreSQL 16<br/>audit, idempotency, history")]
    MQ{{"RabbitMQ<br/>internal event bus"}}
    EXT(["External registry<br/>(simulated)"])

    CLIENT -->|"POST /api/v1/transactions/evaluate"| API
    API --> RE --> NR
    NR --> RS
    RS -->|read only| REDIS
    API -->|"decision returned"| CLIENT
    API --> AUD --> PG

    API -.->|publish, non-blocking| MQ
    MQ -.-> EE
    MQ -.-> PS
    EE <-.-> EXT
    EE -.->|write| REDIS
    PS -.->|write| REDIS
    PS -.-> PG
    AE --> PG

    classDef sync fill:#0b3d5c,stroke:#1b7fbf,color:#fff
    classDef async fill:#3d2f0b,stroke:#bf9a1b,color:#fff
    classDef store fill:#2a2a2a,stroke:#888,color:#fff
    class RE,NR sync
    class EE,PS async
    class REDIS,PG,MQ store
Diagram source

The dotted edges are the ones that matter. Every dotted edge leaves the payment path, and the response to the client is returned before any of them resolve.


2. Why a modular monolith

The five layers are frequently drawn as five services. This framework deliberately does not build them that way.

Because a network hop cannot fit the latency budget. The in-path budget is 50 ms p99 for all three in-path layers combined (see latency-model.md). Splitting Layers 1–3 across services adds two network round trips plus serialization to a budget that is mostly consumed by one Redis round trip. Distribution would consume the budget it is supposed to serve.

Because distribution converts local failures into partial ones. In-process calls fail in one way. Network calls fail in several — timeout, partial response, retry storm, cascading unavailability — and each mode needs its own degradation design. The framework already requires explicit degradation for the one dependency that genuinely must be remote (Redis). Adding two more remote dependencies to the payment path multiplies that work for no benefit.

Because the boundaries are what matter, not the deployment topology. The architectural discipline this framework insists on is the sync/async split. That discipline is enforced by module boundaries and build-time architecture tests — which work exactly as well inside one process as across many, and are considerably easier to verify.

Because the components have no independent scaling story. Microservices earn their cost when components scale independently. Layers 1–3 execute on exactly the same trigger, exactly once each, on every transaction. Their load is identical by construction.

The genuinely independent components — Layers 4 and 5 — are separated by the message broker, which is the boundary that reflects a real difference in execution model. That is the seam worth having, and the framework has it.

(D) Roadmap. If Layer 5 analytics outgrows the monolith's resource envelope, it is the natural first extraction: it is already event-driven, already asynchronous, and already communicates only through the state store and the broker. The architecture is arranged so that extraction is possible, not so that it is required.


3. Module boundaries

ModuleLayerPathMay depend onMust not depend on
transaction-apiINrisk-engine, network-risk, audit
risk-engine1–2INrisk-state (read)JPA / repositories / HTTP clients
network-risk3INrisk-state (read)JPA / repositories / HTTP clients
risk-statebothRedis clientPrimary database
external-enrichment4ASYNCrisk-state (write), broker, HTTPtransaction-api
post-settlement5ASYNCrisk-state (write), broker, JPAtransaction-api
auditIN (write)JPArisk-engine, network-risk
assessment-engineofflineJPA, configin-path modules
benchmarksofflineall

The constraint is enforced by the build

The "must not depend on" column is not a code-review convention. An ArchUnit test fails the build if risk-engine or network-risk imports a JPA or repository type.

This matters because the in-path constraint is exactly the kind of rule that erodes. It is violated by a well-intentioned change, under deadline, by someone who needs one more piece of data and notices that a repository is right there. The rule has to be enforced by something that does not have a deadline.


4. Decision pipeline

sequenceDiagram
    autonumber
    participant C as Channel
    participant API as transaction-api
    participant RE as risk-engine
    participant NR as network-risk
    participant RS as RiskStateStore
    participant AU as audit
    participant MQ as broker

    C->>API: POST /transactions/evaluate
    API->>API: validate, assign correlation ID, start timer

    API->>RE: Layer 1 — identity & posture
    RE-->>API: LayerResult (contribution, reasons, latency)

    API->>RE: Layer 2 — behavioral scoring
    RE->>RS: read baseline + velocity (pre-computed)
    RS-->>RE: state + version
    RE-->>API: LayerResult

    API->>NR: Layer 3 — counterparty
    NR->>RS: read counterparty tier
    RS-->>NR: state + version, or absent/stale
    NR-->>API: LayerResult

    API->>API: compose score, apply thresholds → decision
    API->>AU: persist audit record (rule versions, state versions, latency)
    API-->>C: DecisionResponse

    Note over API,MQ: Response already sent. Nothing below affects it.
    API-)MQ: RiskEvaluationCompleted
Diagram source

Steps 1–13 are the entire latency budget. Step 14 is fire-and-forget.


5. Event catalog

Internal events over RabbitMQ, JSON payloads with versioned schemas. Every consumer is idempotent, keyed by event ID against an idempotency store in PostgreSQL — duplicate delivery is a logged no-op, never a second side effect.

EventPublished byConsumed byPurpose
TransactionReceivedtransaction-apiauditArrival record
RiskEvaluationCompletedtransaction-apiexternal-enrichment, auditDecision made; triggers async enrichment
TransactionApprovedtransaction-apipost-settlementOutcome
TransactionReviewedtransaction-apipost-settlement, auditRouted to review
TransactionDeclinedtransaction-apipost-settlement, auditOutcome
TransactionSettledsettlement adapterpost-settlementTriggers Layer 5 analysis
ExternalRiskUpdatedexternal-enrichmentrisk-stateLayer 4 → pre-computed state
FraudPatternDetectedpost-settlementrisk-state, auditLayer 5 → Layer 3 feedback loop
AssessmentCompletedassessment-engineauditAssessment run finished

The two bold rows are the feedback loop described in methodology.md. Everything else is supporting traffic.

Event design rules

RuleReason
Events carry a stable eventIdIdempotency key
Events carry the correlationId of the originating requestEnd-to-end tracing across the async boundary
Schemas are versioned; fields are added, never repurposedConsumers deployed at different times must not misread
No consumer publishes back onto a topic it consumesPrevents cycles
Publication never blocks a responseThe framework's core principle, at the transport layer

6. Data stores

StoreHoldsRead in-path?Written in-path?
Redis 7Pre-computed risk state: counterparty tiers, baselines, velocity counters, network flags — each versioned with a write timestampYes, exclusivelyVelocity counters only
PostgreSQL 16Audit trail (append-only), idempotency keys, settled transaction history, assessment inputs and resultsNeverAudit write only
RabbitMQEvent transportNeverPublish only, non-blocking

Versioning of risk state

Every entry carries a version and a write timestamp. Decisions record which version they read.

Without this, an audit trail records the decision but not the belief that produced it — and reconstructing a decision six months later against risk state that has since been updated produces a different answer, silently. The version is what makes the audit trail actually reconstructive rather than merely descriptive.


7. Configuration

FileContainsChangeable without deployment
application-rules.ymlRule thresholds, weights, decision boundaries, layer budgets, TTLsYes — by design
Assessment model configCategories, controls, level criteria, gates, weightsYes
application.ymlInfrastructure wiringNo

application-rules.yml is a published interface, not an implementation detail: Phase 5 generates the TypeScript simulator's constants from it at build time, so the browser simulator and the Java engine cannot silently diverge. A CI parity test asserts that both produce identical decisions and reason codes for the same inputs.


8. Deployment topology

flowchart LR
    subgraph VERCEL["Vercel — static"]
        LANDING["Landing + docs<br/>+ client-side simulator<br/><i>no backend dependency</i>"]
    end

    subgraph VPS["VPS — isolated Docker network"]
        NGINX["nginx<br/>TLS, aggressive rate limiting"]
        APP["IPRF demo API"]
        R[("Redis")]
        P[("PostgreSQL")]
        Q{{"RabbitMQ"}}
        NGINX --> APP --> R & P & Q
    end

    USER(["Visitor"]) --> LANDING
    LANDING -.->|"optional, for live demo"| NGINX
Diagram source

The landing page and simulator run entirely client-side, so the public demonstration has no dependency on the backend being up. The demo API is additive.

Security posture. The demo API runs on an isolated Docker network with no volumes or credentials shared with any other service on the host, aggressive rate limiting on public endpoints, and synthetic data only. Deployment configuration lives in deploy/ and is built in Phase 6.