Liking cljdoc? Tell your friends :D

Architecture

The store is five strata and one wiring namespace. Each stratum is a directory under src/monero_store/, and the direction of dependency between them is the architecture — everything else is detail.

boundary/    what leaves the process
pipeline/    the operations, and the effects they cause
payments/    the rails, as data plus a protocol
promote/     what the values mean — pure
collect/     the ports, and the adapters that need no SDK

schema.clj, adt.clj and currency.clj sit underneath all of them: the value objects, the closed sums and the currency registry. They require nothing from the strata above, so a namespace at any level can name a domain value without dragging a layer in with it.

design/ is the sixth directory under src/monero_store/ and is not a stratum. design/theme.clj is the storefront stylesheet as data; bb tokens renders it to resources/public/css/store.css through the :design alias. Nothing under src/monero_store/ requires it, and it requires nothing back.

The strata

CPPB is Collect, Promote, Pipeline, Boundary. payments is the fifth stratum, between promote and pipeline; the acronym does not name it. The rule for each is what it may not contain, not what it may.

StratumNamespacesHoldsMust not hold
collectstore, wallet, cards, rates, fulfilment, http, analytics, reachabilityThe protocols a deployment satisfies, plus adapters that need no vendor SDK, plus a fake for eachAny decision about money. A port describes an effect; it never says what the effect means
promotecatalog, quote, invoice, experimentsWhat a price, a quote, an invoice and an experiment assignment mean. invoice and experiments are functions of their arguments; catalog and quote each keep one process-global registry — see belowI/O, and a clock of its own — invoice/resolution is handed the instant it reasons about
paymentsprovider, chain, hosted, stripe, manualIPaymentRail, the registry, the ProviderProfile each rail declares, and settleA branch on which rail it is. settle reads thresholds off the profile and nothing else
pipelinecheckout, notice, reconcileopen!, settle!, grant!, apply-notice!, sweep!. The exactly-once transition and the late-money ruleAn HTTP status. notice returns a verdict value; what that is on the wire is the boundary's problem
boundaryroutes, wire, identity, shellRouting, the projection of domain values to wire shapes, the identity seam, the authorisation gates caller, with-customer and with-operator, and the SPA shell as hiccupA decision that could be made below. A handler resolves who is asking, calls one pipeline operation, and projects the result

The authorisation gates are the decisions boundary does keep, and they are tested where they live: test/monero_store/boundary/routes_test.clj holds eleven tests, among them the-operator-surface-is-closed-without-the-token, an-invoice-is-visible-only-to-the-customer-it-belongs-to and checkout-needs-to-know-who-is-asking.

system.clj is the only namespace that knows what a deployment is. It reads the environment, builds every adapter, and hands the result down as one map.

Two registries in promote are global

catalog/registry and quote/bounds are defonce atoms, not values carried in deps:

(defonce ^:private registry
  (atom {}))

catalog is mutated by register!, register-all! and clear!; quote by set-bounds!. system/start! calls (catalog/clear!) and then (catalog/register-all! items), so the catalog is one registry per JVM: two embedded stores in the same process cannot sell different things, and the second start! discards the first's items. The rail registry below is a value for reasons that apply to the catalog too. The catalog does not have them.

Dependencies point one way

No namespace in collect requires promote, payments, pipeline or boundary. No namespace in promote requires payments, pipeline or boundary. No namespace in payments requires pipeline or boundary, and no namespace in pipeline requires boundary. There are no exceptions and no waivers.

The consequence worth having is that the money path is testable without a request, a socket or a coin: checkout/settle! takes a deps map and two values, so every settlement decision in the suite is a function call.

The ports

Each port has at least one adapter that ships in the core and one fake, so the whole path runs with no daemon, no processor account and no coin.

PortDeclared inShips in the coreBehind an alias
IPaymentRailpayments.providerchain/->rail, hosted/->rail, manual/->rail
IOrderStorecollect.storestore/memory-storeadapters.jdbc-store (:jdbc)
IChainWalletcollect.walletwallet/moneropay-wallet, wallet/fake-walletadapters.monero-rpc (:monero-rpc)
ICardGatewaycollect.cardscards/fake-gatewayadapters.stripe-cards (:stripe)
IRateSourcecollect.ratesrates/registry over rates/sources, rates/fixed-source
IFulfilmentcollect.fulfilmentfulfilment/noop, /logging, /ledger, /composite
IHttpcollect.httphttp/hato-client, http/stub-client
IAnalyticscollect.analyticsanalytics/noop, /logging, /memory, /umami
IEndpointProbecollect.reachabilityreachability/socket-probe, reachability/fake-probe

IFulfilment is the one a host application is expected to implement: the store decides whether money was good, the host decides what that buys. IOrderStore carries the two invariants — record-payment! is idempotent per (invoice, reference), and claim-paid! is a compare-and-set that tells exactly one caller it won the transition to :paid. It transitions from either open status: store/open-statuses is #{:pending :underpaid}, so an underpaid invoice that is later topped up still claims. An implementation over a real database gates on that set, not on :pending alone.

The rail registry is a value

provider/registry returns a map from provider id to {:profile .. :rail ..}. It is built at boot and carried in deps; there is no global atom anywhere in payments.

(defn registry
  [entries]
  (into {}
        (map (fn [{:keys [profile rail]}]
               (schema/check! schema/ProviderProfile profile
                              {:monero-store/producer `registry})
               [(:provider/id profile) {:profile profile :rail rail}]))
        entries))

Three reasons it is a value and not a global.

Two stores can hold different rails in one process. A test and the system under test can carry different registries at the same time, in the same JVM, without either seeing the other's rails — which is what lets the suite register a rail with a deliberately malformed profile without breaking anything else. Their catalogs are not separated this way; that is the cost recorded above.

A registry that is a value has no load order. Registration is not a side effect of requiring a namespace, so which rails exist is decided in one place, system/rails, and is visible in the boot log.

Validation has somewhere to happen. registry checks every ProviderProfile as it builds the map, so a rail whose declared behaviour is malformed fails at boot, where an operator is watching, rather than at the first customer.

What a profile declares is the whole swap point:

(def ProviderProfile
  [:map {:closed true}
   [:provider/id ProviderId]
   [:provider/currency CurrencyId]
   [:provider/min-confirmations [:int {:min 0}]]
   [:provider/underpay-tolerance [:int {:min 0}]]
   [:provider/settles-async? :boolean]
   [:provider/settlement-poll? :boolean]
   [:provider/webhook-auth [:enum :none :signed-payload :path-token :server-confirmed]]])

provider/settle reads two of those fields — :provider/min-confirmations and :provider/underpay-tolerance — and produces a SettlementOutcome. The rest are read by name elsewhere: :provider/currency by provider/currency-of, :provider/settlement-poll? by provider/pollable?, :provider/webhook-auth by provider/webhook-settleable?, and :provider/settles-async? by wire/provider, which projects it to the storefront as :async. No function among them branches on which rail it is, so a new rail is a new registry entry and no edit below it.

The order of the clauses is the policy. :settle/reject is first: a settlement the rail reports as :failed is rejected whatever else is true of it. :settle/suspect is second, so a double spend that leaves the invoice short is suspect before it can be pending, underpaid or granted.

The same reasoning runs one level up. system/start! builds

{:store .. :rails .. :fulfilment .. :identify-fn .. :analytics ..
 :experiments .. :admin-token .. :callback-base .. :rates-fn ..}

and threads it through every pipeline call. A host application embedding the store overrides any of those keys by passing them to start!.

SDK adapters live on their own source roots

The core carries no payment-provider SDK. :paths is ["src" "resources"], and each SDK-backed adapter is an :extra-paths entry on its own alias:

AliasSource rootDependency it pulls
:monero-rpcadapters/monero-rpc/srcio.github.woodser/monero-java
:stripeadapters/stripe/srccom.stripe/stripe-java
:jdbcadapters/jdbc/srcnext.jdbc, postgresql, HikariCP

Three things follow.

A deployment pays only for the rails it runs. A store that settles in Monero through MoneroPay never resolves stripe-java or a JDBC driver, and its uberjar does not contain them.

The adapters are resolved at runtime, not at compile time. system/optional-fn is requiring-resolve in a try; a build without :stripe has no Stripe gateway and logs that it has none, instead of failing to start. STORE_BACKEND=jdbc without the :jdbc alias falls back to the memory store and says so.

Authentication does not need the vendor jar. Stripe webhook signatures are verified with javax.crypto in payments.stripe, which is core source — the SDK is confined to adapters.stripe-cards, the namespace that opens sessions. A deployment that only needs to authenticate notices carries no Stripe jar at all.

The published artifact follows the same rule: io.github.buddhilw/monero-store is a source jar of src and resources, with no payment SDK in its dependencies. The protocols are what a consumer implements, and an AOT'd record compiled against a source-shipped protocol fails to link at the consumer's loader.

The model is data, the diagrams are generated

The architecture is described once, as EDN, under models/monero-store/:

FileHolds
model.ednThe C4 elements — actors, the system, its containers, the payment components, the ports, the external systems, and every relation between them
views.ednWhich elements each view contains, and in which direction the relations are laid out
state.ednOne state machine, :monero-store/invoice-lifecycle, and its view. There is no separate settlement machine — the SettlementOutcome variants appear as transition labels inside this one
deployment.ednThe compose stack as nodes — the Docker host, the store, PostgreSQL, MoneroPay, wallet-rpc, monerod and the volumes — and its view

The PlantUML under docs/diagrams/plantuml/monero-store/ is rendered from those files by overarch. Nothing there is hand-drawn, and editing a .puml by hand is a change that the next render discards. Regenerate with:

clojure -M:arch --model-dir models --render-format plantuml --render-dir docs/diagrams

The views

DiagramAnswers
context-view.pumlWho the store serves, and every system it depends on to take money — the payer, the operator, the host application, MoneroPay, monero-wallet-rpc, Stripe, the rate tickers, PostgreSQL, analytics
container-view.pumlAll five strata as deployable parts, and the one-way dependency between them: storefront to boundary, boundary to pipeline, pipeline to payments, promote and collect, and never back
rails-view.pumlThe DIP seam. How the registry, the profiles, settle, the three rails and the Stripe reader relate — and therefore what adding a rail actually touches
ports-view.pumlEverything the store refuses to decide for you: IOrderStore, IChainWallet, ICardGateway, IRateSource, IFulfilment, IEndpointProbe, and which external system satisfies each

Three further views are rendered from the same model: integration-view.puml, one picture of every system the money path touches and which direction each integration runs; invoice-lifecycle-view.puml, the invoice state machine from state.edn; and deployment-view.puml, the compose stack from deployment.edn. The command writes seven .puml files in all, and all seven are checked in beside their rendered .svg.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close