DEV Community

rinat kozin
rinat kozin

Posted on

A payment platform on .NET: what it actually costs, and how much of it you never have to write

redb fintech

Conversations about payment platforms almost always start from the wrong end. People argue message brokers. They debate microservices versus a modular monolith. They draw the schema.

Eighteen months later a team of twelve has produced a million and a half lines, of which maybe two hundred thousand are business logic. The rest is transport, retries, dashboards, deployment, and increasingly elaborate attempts to work out where a payment got stuck last night.

This post is about that second number. About the layer that earns nothing and without which nothing runs — and about how much of it you can simply not write in 2026.

We've spent four years building redb: a typed store on top of PostgreSQL, MS SQL and SQLite; an integration engine; a runtime with clustering and a dashboard; and an identity server speaking OAuth 2.1 and OpenID Connect. It runs in our own production, ships as packages and images, and — relevant to everything below — every Pro capability is free across the whole 3.x line, no keys and no license server.

What follows is a payment platform taken apart by layer, with an honest accounting of where the person-years go. No code: the technical deep dives live in other posts. This one is about money, timelines and risk.

Part of the redb / redb.Route series — recent posts first:

Sources: github.com/redbase-app. Docs: redbase.app.


Three layers, three budgets

Every payment platform — a marketplace's payouts, a wallet, a PSP, a corporate treasury, a SaaS billing engine — decomposes into three parts, and each has a different cost profile.

The core. Holds money and operations. Postings, balances, limits, pricing, reconciliation. This is what you get paid for, and it's where a mistake costs the most: a mis-rounded cent isn't a bug, it's a complaint.

The facade. Decides who gets in. Who is this, what are they allowed to do, how did they prove it, and how is all of that recorded for the auditor and the security team. A layer made entirely of standards, invisible to the business — right up until the first audit.

The integrations. Talk to the outside world. A bank on IBM MQ, an acquirer over HTTP, payout files on SFTP, accounting on a schedule, fraud scoring on a queue. The layer that breaks most often and costs the most when it does, because the thing on the other end belongs to someone else.

Layer by layer: what genuinely has to be yours, and what's already written.


Layer one: the core, where cents don't evaporate

Start with something that looks like an implementation detail and is actually a first-order business risk: how the number that measures money is defined.

In most .NET projects an amount lands in the database as a decimal with whatever precision someone set in the ORM config years ago. Usually two decimal places, sometimes four. Then: a fee is a percentage, the percentage produces a third decimal, the third decimal gets rounded, roundings accumulate — and a year later reconciliation with the acquirer is off by an amount nobody can explain. That's not a hypothetical. It's a genre, and it gets resolved by hand, by an analyst and a developer, over weeks.

In redb, monetary precision isn't in application config. It's in the storage schema itself: numeric values live in a column with 38 digits of precision, 18 of them after the decimal point. The schema comment on that column says why, in as many words: exact decimal numbers for financial calculations, lossless, for money, taxes and percentages where arithmetic accuracy matters.

In business terms:

  • Fees, FX and tax compute without accumulating rounding error. Eighteen decimals is headroom for any chain of calculations, not "two decimals and fingers crossed."
  • Multi-currency and crypto work out of the box. Eighteen decimals is exactly the precision Ether is denominated in. If a crypto line of business shows up next quarter, the storage doesn't change.
  • Precision isn't a function of who configured the project. A developer can't accidentally declare an amount with two decimals, because the decision was made one level below them.

Money doesn't get double-spent

The second baseline guarantee: one operation executes once, and never half-way. This part is standard and boring, as finance should be — real transactions with explicit control, atomic execution of a group of operations, and row-level locking while a record is being modified.

That last one is worth calling out, because it's where homegrown solutions usually break. Classic scenario: two requests read the balance concurrently, both see "sufficient funds", both debit. The defence is locking the row for the duration. In redb that's a first-class mechanism, and it isn't decorative — it's what our own subsystems run on: the identity server's token store, the failed-MFA-attempt counter, the cluster's distributed lock. It carries production load for us, rather than existing "in case someone needs it."

Deletion you can undo

Another thing that gets reinvented in every finance project: correct deletion. You can't hard-delete — reporting, regulators, incident forensics. So it's either an is_deleted flag dragged into every query forever, or archive tables and the job of keeping them in sync.

In redb this is built in, in two phases. First the object is marked — together with the whole tree of records hanging off it — and immediately disappears from every normal query, with no change to a single select in your code. Physical removal happens later, on a background worker, in batches, with progress persisted in the database, so a restart doesn't kill it and any node in the cluster can pick it up.

Two business effects: the UI can honestly offer "Deleted — undo" and have it work, and the user-facing delete doesn't start crawling as data grows.

Reporting where the data already is

Reconciliation, registries, regulatory reports, commission calculations — all aggregation. The usual answer is a separate stack: extract, warehouse, BI. A separate stack means a separate team, separate licenses, and the eternal question of why the report disagrees with the system.

redb computes aggregates, groupings, window functions and having-clauses on the database side, over the same data serving operations. "Show me merchants whose reconciliation gap exceeds the threshold" is a query against live data, not an export into a third system.

The fair objection — and what to do with it

Here an experienced reader will reasonably point out that aggregating over an operational store, at volume, is slower than aggregating over a flat pre-built table. That's true, and there's nothing to argue about.

Except the objection is about a different problem. Nobody does heavy analytics on an operational core, and not because of the storage engine — because it's bad architecture on any storage engine. A yearly slice across ten dimensions doesn't get computed on the live database on plain PostgreSQL, or Oracle, or anything else: it competes for resources with payment processing, and payments win. For that, you build flat data marts. You still will.

The two query classes get conflated in these arguments, so let's separate them:

Operational. Narrow slice, fresh data, answer needed now: remaining limit, today's reconciliation gap, top declines in the last hour, windowed sum for a fraud rule, ranking operations inside a batch. Small volumes of hot data — and these need to compute where the data lives, or you end up with a data mart that's a day stale answering a question that needed a second.

Analytical. Wide period, many dimensions, tolerates latency: reporting, product analytics, finding patterns. That's a data mart, and it should be.

Built-in aggregation is aimed squarely at the first class. It covers the case where a typical project either stands up a warehouse prematurely, hand-writes optimized SQL, or pulls rows into the app and sums them in memory.

And the mart itself isn't a project. "Every hour, roll up aggregates and write them flat into a table or a search index" is an ordinary route on this stack: a scheduler, a read, a sink. Configuration, not an ETL programme with its own headcount.

So data marts stay exactly where they belong. They just stop being a precondition for launch — which takes them out of year-one budget and puts them in the year they're actually justified.

Nothing is hidden: there's always a way down

A separate guarantee worth stating, because it removes a whole class of risk.

Any abstraction over a database eventually meets a query it can't express. Then you get two options: a workaround in application code, or a ticket to the vendor with an unknown ETA. In a financial system that moment arrives on schedule — at the first non-standard request from security, compliance, or a regulator.

There's no dead end here. The storage structure is open and documented, and you can always go at it with plain SQL — your analyst, your DBA, your existing tooling.

And that isn't "technically possible but painful." The structure is designed so odd queries come out short and fast.

The showcase example is a query that's nearly impossible in a classical schema: find every object that has any field starting with a given string, regardless of what type those objects are. In a normal relational model that's a union across forty tables enumerating every text column — or a separate full-text system you now have to feed and keep in sync. Here, values for all objects of all types live in one table with a pattern-search index over it. The answer comes back immediately, across millions of rows, without caring which entity anything belongs to. Same story for "which objects have this field populated at all" — there are dedicated indexes for exactly that check.

What this buys:

  • An unplanned request stops being a sprint. Compliance wants operations matching an odd criterion, security is reconstructing an incident, a regulator asked for something in their own phrasing — that's an analyst-hour, not a backlog item for next quarter.
  • Custom logic is cheap. Views, stored procedures, feeds into your reporting — with ordinary database tooling, without asking us and without waiting for a release.
  • The data isn't locked in. The database reads with standard PostgreSQL or MS SQL tools, without a single line of our code.

The storage layout is written up in full at redbase.app/architecture.

What you write yourself anyway — and why that's correct

There's no double-entry, no chart of accounts, no posting rules in the box. That's not a gap, it's a boundary.

The accounting model is your product. A marketplace's is one thing, a treasury's another, a card issuer's a third. Every attempt by a vendor to hand you a "ready-made ledger" ends with the business spending six months bending its model to fit someone else's abstractions, then a year living with the workarounds where it didn't fit.

What redb provides is the right foundation underneath your model: an exact number, transactions, locks, reversible deletion, and aggregation. You describe postings as ordinary C# classes, and they become tables.

Which brings us to the second effect, and the one businesses consistently under-weight.


Changing the schema stops being an event

In a normal project, adding one field to a payment entity looks like this: a developer writes a migration, the migration goes to review, review pulls in a DBA, the DBA asks for a rollback script, the script gets tested on staging, the deploy waits for the next window. Between "we need a field" and "the field is in production": days to weeks, in a healthy organisation. In a bank, longer.

In redb the schema is described by ordinary C# classes, and the store reconciles itself against them. Add a property, it exists. No migration, no DDL, no sign-off.

Three consequences, all of them money.

One: designing as you go. The classical approach demands an agreed data model before development starts. In a financial platform that model isn't knowable up front — it emerges as you discover what the partner bank actually sends and what the business actually needs on screen. So you either spend a month or two guessing, or you get a stream of migrations later. Usually both.

When the schema can evolve freely, that phase never happens. The team starts with a base set of fields and grows it as understanding arrives. On a payment-platform scale that's 400–900 engineering hours — before a single line of useful code.

Two: being wrong about the model stops being expensive. The classic: a field was designed as text, and a month later it needs to be a reference. In a normal project that's a migration with data conversion, a backfill and a rollback plan — a day to several, plus production risk. So in practice that decision often doesn't get made: the answer is "too expensive to change", and the workaround lives forever.

Here, changing a field's type is editing a class. And the Pro tier ships a mechanism that backfills a new field from data you already have: add "amount including tax" and it populates from amount and rate across the entire operation history, expressed in C#, with no export and no script.

Three, and most under-rated: the cost of testing an idea.

Work out what it costs to test a product hypothesis that needs a new field or a new entity. Think of it, write the migration, spin up a local stack or fight for the shared one, run it, and if it didn't work — write the reverse migration and clean up the data. Honestly: half a day to a day.

Here: add a property, run against a local database file, didn't work, delete the property. Twenty minutes.

An order of magnitude. And the point isn't the saved hours, though across a project's life that's another 500-plus. The point is how many options the team gets to consider at all. At half a day per test, you evaluate one and ship the first thing that works. At twenty minutes, you evaluate three to five and ship the best one. That's not a line item, that's product quality — and it resurfaces later in conversion, in support volume, and in what rework costs a year out.

On the local stack specifically: the same store runs on PostgreSQL, MS SQL and SQLite with no code changes. So a developer fixing one small module doesn't need a full environment of database, broker, identity server and neighbouring services — a file will do. Onboarding drops from days to hours, and CI stops needing infrastructure spun up per pull request.


Layer two: the facade the auditor will look at

Nobody sane writes their own authorization server anymore. You take one off the shelf — and that's where a fork in the road costs money before the first line of code.

The commercial .NET product means a license, and for a financial institution that license isn't on the entry tier. Open-source Keycloak means a Java stack inside a .NET organisation: another runtime, another skill set, another on-call rotation, another upgrade cycle. Both work. Both cost.

redb.Identity is OAuth 2.1 and OpenID Connect inside the same stack, on the same storage, under the same runtime. What matters to the business here:

Standards in the code, not on a slide. The source references 40 RFCs by number — not as a wish list, but next to the implementation and the tests that check it. The full set a security reviewer asks about: mandatory PKCE, proof-of-possession token binding, pushed authorization requests, dynamic client registration, private-key-JWT client authentication, token exchange, the device grant, introspection, revocation, back-channel logout.

Verified by an outside arbiter. The server is exercised against the official OpenID Foundation conformance suite — the same suite the Foundation uses to certify providers. The configuration profile passes with zero findings; the authorization-code modules of the basic profile are green. Formal certification is the next step and we're not claiming it as done. But the fact of running against an external reference is something most .NET stacks, homegrown and purchased alike, never do — and on an audit that's a different quality of conversation.

Audit and second factors included. MFA: TOTP, SMS and email codes, FIDO2 hardware keys, recovery codes. User lifecycle over SCIM, so HR systems provision and deprovision automatically. And 79 typed audit event types across seven categories, persisted and optionally fanned out to your SIEM in parallel. Not "logs you could reconstruct something from" — typed events: login failed, second factor failed, token replay detected, client secret rotated, all sessions revoked.

One product on three databases. The server's full test suite — over 1,700 checks — passes identically on PostgreSQL, MS SQL and SQLite by flipping one setting. If picking a database is its own conversation with infrastructure at your company, that removes a class of blockers.

The saving that never shows up in a budget

There's an architectural detail that looks technical and converts directly into hardware.

In the usual shape, every internal call between services is authorized, and authorizing means a network hop to the identity server. At meaningful traffic that server becomes a bottleneck, a single point of failure, and a permanent addition to your latency budget.

Here, modules running in the same runtime reach the identity server in-process — no network at all. No socket, no TLS handshake, no serializing to yourself. Externally it stays a normal server on standard protocols: the browser parts speak HTTP because the spec requires a browser, and everything else — token issuance, refresh, introspection, revocation, management, directory — is transport-neutral.

Our own measurements: a three-node configuration sustains roughly five thousand token requests per second at 50–80 ms p99. Translated: fewer nodes doing authorization, and one less single point of failure.

You build the front door — on your transport, with your crypto

This is an architectural fork whose consequences reach well past finance, into any distributed system whose nodes talk over more than one kind of channel.

In a typical identity server, protocol and transport are welded together: it speaks HTTPS, and that's the extent of it. Here transport is decoupled from protocol — HTTP is just the first adapter, not part of the server. Everything except the flows the spec requires a browser for is transport-neutral.

One source of truth about permissions, many front doors. A real distributed system rarely talks over one channel. Some nodes sit in a closed segment behind a tunnel. Some sites are wired to a corporate message bus because that was decided a decade ago and won't be revisited. Partner integrations go over the public internet. Edge devices don't speak modern HTTP at all.

A conventional identity server assumes one channel and that it's HTTPS. Which leaves two bad options: force everything onto HTTPS — and open up the closed segment — or run several servers for several channels, and now you have several sources of truth about who may do what. The second is worse: permission drift between segments gets discovered after the fact, and unpleasantly.

Here the core is single — the standard one, the one that passes conformance and reads sensibly to an auditor. And it has as many front doors as you have channels, each built for its channel: your transport, your envelope, your encryption, your framing. Unwrap on the way in, wrap on the way out; the core neither knows nor should. Adding a front door changes nothing in the server — it's a separate module you drop in. Removing one is deleting a file.

Special cases of one mechanism: an inter-branch segment with crypto your regulator mandates; a closed site where only the corporate bus is permitted; a partner channel with a proprietary format; IoT devices on a lightweight protocol. Same core, same permissions, same audit trail — only the adapters differ.

Shrinking the attack surface. A scenario that rarely gets discussed. An identity server has to sit on a public address: browsers, mobile apps, external provider callbacks, back-channel logout — all require a publicly resolvable URL. You can't wrap a tunnel around the whole thing.

But administrative operations — signing-key rotation, forced revocation, bootstrapping the first admin, mutating clients — don't have to be public. Those can move to a separate port, a separate network, and a format of your own: your markers, your protocol version, your authentication.

The effect isn't cryptographic, it's practical: mass scanners and off-the-shelf exploit kits simply don't understand the format. An attacker has to reverse-engineer it before they can produce even a syntactically valid request. Every day of that delay is a day of lead time for your security team. The pattern is old news to banks and the public sector, but it used to require rewriting the stack around a proprietary protocol; here it's one module in front of a standard core.

Mandatory caveat: this is a layer on top of protection, not a replacement for it. Authorization and signature checks stay in place on every operation. A non-standard wire format doesn't substitute for cryptography — it adds information asymmetry to it.

Where we honestly don't cover you

If your platform goes out into regulated open banking — you're a third-party provider, or a bank serving them — you'll need work. Not implemented: mutual-TLS client authentication, rich authorization requests (consent granted for one specific payment to one specific payee), decoupled confirmation through a banking app, and step-up authentication for a transaction.

That's a deliberate boundary, not an oversight. The cryptographic groundwork for all of it is already in the server — proof-of-possession token binding is implemented and covered by tests — so this is a scoped sprint, not a rewrite.

For everything else — your own processing, a wallet, marketplace payouts, corporate treasury, B2B payments, billing — the server covers the job today and in full.

And yes, this works for plain billing too

Worth stating explicitly, because after all the talk about payment cores it's easy to conclude the stack is aimed only at heavy scenarios.

Billing — subscription, telecom, utility, SaaS — lands on our strengths arguably more precisely than processing does:

  • Pricing changes constantly, and that's billing's central pain. A new plan means new fields and new rules. Where every price-sheet revision normally means a migration and a release window, here it's a class edit and a rollout with charging still running.
  • Proration is fractions. Mid-cycle plan changes, per-day charges, partial refunds — all multiplication and division, and exactly where rounding error accumulates into invoices that don't reconcile. Precision at the storage layer removes the question.
  • Recurring charges are a schedule. The scheduler is built in; a charging run is an ordinary route, not a separate service with its own on-call.
  • Failed payments need retry rules. Dunning with backoff, a dead-letter queue, manual re-run from a support screen — exactly what's below.
  • Invoices and reconciliation are aggregation, computed where the data lives.

And the part that matters most to a small team: there's no entry toll for complexity. Clustering, the coordinator and the dashboard are optional — the stack runs happily as one process on one server, and everything above switches on by configuration when you grow into it. That's the opposite of enterprise platforms, where you pay for all the complexity up front regardless of whether you need it yet.


Layer three: integrations, where it breaks most and costs most

Financial integration doesn't look the way architecture diagrams draw it. On the diagram: a tidy bus and events. In life: a partner bank that accepts IBM MQ only, because its core was written in the 2000s and nobody is rewriting it. A payout file landing on SFTP at four in the morning. An acquirer on plain HTTP with its own opinion of what idempotency means. Accounting that needs an export on a schedule. And fraud scoring on a queue.

The key idea: integration is won not by whoever has the prettiest bus, but by whoever speaks the counterparty's language. Because you're not going to change the counterparty.

redb.Route ships 27 transports. For comparison, the popular .NET messaging libraries offer four to seven, all of them brokers. They assume you're surrounded by modern services. In finance you are not.

What's actually out there What covers it
Bank core on IBM MQ A real transport with transactions, not a hand-rolled adapter
Registries and statements as files SFTP, FTP, filesystem polling
Acquirers and partner APIs HTTP, gRPC, WebSockets
Event backbone Kafka, RabbitMQ, AMQP, Azure Service Bus, Amazon SQS/SNS
Legacy reading straight from a database Scheduled polling and writes
Notifications Email, Telegram, push
Clearing and scheduled runs Built-in scheduler

IBM MQ deserves a note, because it's a maturity tell. It's supported with genuine transactional semantics — including a backout counter and automatic movement of a poison message to a dedicated queue past a threshold. Anyone who has integrated with a bank core knows: without that, one malformed message loops for a day and blocks the channel.

The payment that won't go out twice

The headline technical risk in financial integration is redelivery. The network blinked, the acknowledgement didn't land, the sender retried, the payment went out twice. Resolving that incident means a customer complaint, a manual reversal, and a written explanation.

Three layers of defence, composable:

Transactional processing. Acknowledging receipt to the broker and publishing the result happen together: both or neither. Implemented identically for RabbitMQ, IBM MQ, AMQP and Kafka — so swapping brokers doesn't rewrite the logic.

Persistent duplicate suppression. The operation key is claimed first and confirmed only after successful processing. If the process dies mid-way, the key stays unconfirmed and the operation is correctly retried. State lives in the database, not memory — so it works cluster-wide and survives restarts.

Guaranteed publication. The classic pattern where the event is written in the same transaction as the business operation and published by a separate process.

We get asked why this isn't a single "enable guarantees" switch, the way some competitors package it. The answer is practical. A ready-made guarantees container brings its own table, its own schema and its own notion of how to select unsent items. In finance that table regularly has to live in someone else's database whose schema you don't control, and the selection criterion isn't "where not sent" but a business rule with priorities, windows and caps. A welded-in implementation is a blocker in that situation, not a convenience. Here it's a few lines of configuration where the query is yours, the table is yours, and the transaction is yours.

About international message formats

The question always comes: what about ISO 20022?

There are no shipped codecs for financial message formats — ISO 20022, ISO 8583, SWIFT MT. That's honest, and it's a niche for whoever needs it: transport, framing and schema validation exist, parsing your specific profile is yours. And frankly you'd write it anyway — every bank's ISO 20022 profile differs, and a "ready-made codec" gets modified regardless.

What helps: ISO 20022 is XML with a schema, and validating a message against XSD is a built-in pipeline step. So validating a payment instruction against the official schema is configuration, not development. Same for JSON formats via JSON Schema.


3 a.m.: what a stuck payment costs

Now for the thing that appears in no presentation and determines actual cost of ownership.

A payment is stuck. Not "the system is down" — a down system is visible and gets fixed. Stuck: one operation got an error from an external service somewhere mid-pipeline and never arrived. The customer calls in the morning.

How that goes in a typical project: a developer reads logs, reconstructs what was in the message, hand-assembles a request, resends, and hopes it doesn't double-charge. An hour to a day, requires a developer rather than support, and happens under pressure.

How it goes here:

  • A route can carry a save-point. Not a log of what happened — a snapshot of the operation's state at a specific place in the pipeline.
  • If something fails further along, the snapshot lands automatically in a dead-letter store backed by the database — PostgreSQL, MS SQL or SQLite, your pick.
  • A support operator opens the dashboard, sees the stuck operations with cause and timestamp, and clicks Replay. The operation re-enters its route from the save-point, not from whatever the state decayed into on the way down.

Two details that show this was built for real operations rather than for a feature list:

Capture is opt-in by construction. Only what a developer marked as replayable lands in the queue. The system never claims an operation it wasn't handed — otherwise the dead-letter store becomes a landfill nobody opens.

There's a guard against split ownership of delivery. If a save-point is placed on a route where a broker or a transaction already owns redelivery, the engine warns. That specific mistake — two parties responsible for the retry — is what produces double charges.

Alongside it: a watchdog that classifies routes and raises alerts on hung ones, and in-flight tracking, so you can see which message is sitting in which step right now.

What this means for budget. Triage of stuck operations stops being developer work and becomes first-line work. We put that at 2–4 hours of senior time per week returned to development — but the bigger effect is that a 3 a.m. escalation to a developer stops being routine.


Regulation: guidance in the morning, production in the evening

A financial organisation lives inside a stream of changes it didn't initiate. A regulator publishes guidance. A rate moves. A mandatory field appears in reporting. A partner changes their exchange format. The deadlines aren't yours.

Look at what the path from "guidance published" to "running in production" is made of in a normal project:

  1. Code change — hours.
  2. Database migration, if the structure moved — days of sign-off.
  3. Build and test — hours.
  4. Getting a release window approved — days or weeks.
  5. Service stop, deploy, verification, rollback plan.

Notice: the actual development is item one, and it's the shortest. Everything else is permission to deploy, and it consumes most of the elapsed time.

The same path on this stack:

  1. Change — a new field is a class property, no migration.
  2. The module is built and signed with your key in your pipeline. The key pair is generated with one command; the runtime only ever knows the public half.
  3. It's uploaded — through the management interface or as a file.
  4. The runtime verifies the signature before a single line of code from the package executes. The public key decides, not who put the file there. Unsigned is rejected.
  5. Swap in place: the old version finishes the operations it started, the new one starts alongside, and only after it proves healthy is the old one retired. In a cluster this rolls node by node.

The payment flow never stops. Permission to deploy collapses to "the signature is valid." Rollback is uploading the previous package.

Guidance in the morning, change and build during the day, rollout across nodes in the evening. Not heroics from the on-call shift — a routine procedure.

For the business those are two separate wins: speed of response to a regulator, and the elimination of downtime as a category. In finance the second is often worth more — an approved maintenance window isn't just unavailability, it's a change request, customer notifications, and reputational cost.


The platform team you don't have

There's a layer that teams under fifty people never build. Not from incompetence — it doesn't pay for itself within a single product. The operational envelope: a management interface, a CLI, a dashboard, node coordination, metrics, a watchdog, deployment.

What you get instead is the standard kit: three deploy scripts, log grep, and "SSH in and take a look." Which works while the system is small.

In redb.Tsak, that layer arrives with the product:

Dashboard — 10 screens: cluster overview, a three-level topology tree, per-node detail with live load charts, every route with counters and error rates, per-route drill-down with in-flight operations, the dead-letter queue with a replay button, the hung-route watchdog, log search, access-key management.

CLI — 43 commands. Not just start and stop: sign a module, deploy, validate, roll back, drain a node, return it, rebalance, pull diagnostics, replay a dead letter, pause the scheduler.

Management API — 16 controllers, 45-plus operations, with a typed client. Meaning all of the above embeds into your existing control plane instead of requiring a separate panel.

Observability — Prometheus-format metrics, a ready Grafana dashboard as a file, three distinct Kubernetes probes, a full manifest set including monitoring-operator integration. And, separately, distributed tracing — which deserves its own paragraph.

"Where is my payment" — the question that usually has no fast answer

A customer calls asking where their money is. The operation crossed an inbound request, an authorization check, fraud scoring, a queue, the partner bank and back — five systems, each with its own logs in its own time format.

In a normal project the answer gets assembled by correlating timestamps and fragments of identifiers. Half an hour to a day, and it needs the person who remembers how everything connects.

The right solution is well known — distributed tracing: one operation gets an identifier carried through every system, and afterwards its whole path is one chain with per-step timings. The catch is that building it yourself is a project: pick a standard, thread context across every process boundary and every transport, instrument each connector, stand up a collector, teach the teams.

Here tracing is built into the integration engine and the runtime picks it up on its own. Drop in a module and its routes are already in traces and metrics, with no configuration in application code. Counters for processed and failed operations, a duration histogram, a gauge of operations in flight right now — all present from first start. Export to Jaeger or any compatible collector is one setting; your own spans and counters join the same pipe.

Two engineering details that show this was built for operations:

Collection always runs; export is on demand. Traces are produced regardless of whether export is enabled. So attaching a collector during an incident doesn't require redeploying anything.

Observability may not take the system down, and may not be a hole. Before starting, the metrics exporter probes whether it can bind and, if not, logs a warning and continues without metrics — because an optional subsystem must not take down a payment path. And the collector listens on loopback only; metrics go out through the main API. Telemetry physically cannot end up exposed to the network by accident.

For the business, short version: "where did it get stuck" becomes a matter of minutes and of support staff — rather than a day and a developer who remembers the architecture.

Scaling out is starting another process

Worth a separate note, because it's a direct capital line.

Going from one node to three is the same configuration file on every node, differing only in node id and address. Nodes find each other through shared storage, elect a leader, and the leader distributes modules and rolls updates one node at a time.

No service discovery system, no external coordinator, no service mesh. And not one line of new code.

Going back is symmetric: a node is drained with one command, finishes what it started, and leaves.

An important detail for larger platforms: nodes don't have to be identical. Placement is tracked per module, scoped to a group. So an "acquiring" group can carry one set of modules, "payouts" another, "reporting" a third — all under one coordinator. That lets you separate contours by load and by criticality without splitting them into separate systems with separate operations.

And containers however you like. Three image variants: a headless worker for Kubernetes, a standalone management UI, and an all-in-one for single-server installs. Ready compose sets, Kubernetes manifests, and a no-container archive option. Images are signed. Need a custom build with your own connectors baked in? An ordinary standard .NET build, no proprietary tooling.

What that layer is worth

If you had to build it — component by component, conservatively:

Component Engineering hours
Management API 250–350
CLI 150–250
Web dashboard 400–600
Node coordination: leader election, registry, distribution 250–350
Zero-downtime updates with graceful drain 100–150
Dead-letter store with replay 100–150
Hung-process watchdog 60–100
Metrics, distributed tracing, health probes 310–480
Images, deployment sets, manifests, signing 210–330
Signature verification for loaded modules 40–60
Total 1,870–2,820

That's over a person-year — before a single line of business logic. And, again, most teams simply don't build it: they live without it and pay later, in night escalations and hours of incident triage.


Hardware: why the same traffic needs fewer nodes

There's a line in a platform's budget that technology posts almost never mention — the infrastructure bill. And it's driven less by traffic than by how much unnecessary work the system does per operation. Two mechanisms here, and they pay out every month rather than once.

Don't rebuild what didn't change

Standard behaviour for nearly any data-access layer: you asked for five hundred operations, so it assembled five hundred objects out of database rows. Next page, then back — assembled again. CPU work producing no value.

In redb every object carries a checksum, and loading happens in two steps. First a cheap query — identifiers and checksums only, no touching field values. Then that's compared against what's already in memory, and only genuinely changed objects get fetched. The rest come from cache without reassembly.

Same on the write side: an object that didn't change never reaches the database.

And this stays correct in a cluster, which is the part that matters most. The checksum comes fresh from the database on every query. So if one node changed an operation, the next node sees the mismatch on its next request and re-reads it by itself. No separate cache server with its own operations and its own bill, and no invalidation broadcast between nodes — the mechanism that breaks most often and most quietly in distributed systems.

The mode is a setting, and the safe one — with validation — is the default. A monolith can turn validation off and go faster still; a distributed deployment leaves it on and gets correctness for free.

Don't hold a thread just to wait

The second mechanism is about what a payment system spends its time doing.

Which is waiting. For the acquirer's response. For the bank's confirmation. For the fraud verdict. For a disk write. Actual computation is a fraction of a percent.

In a synchronous model each of those waits pins a thread. Threads are memory and context switches, and they run out considerably earlier than CPU does. Hence the familiar picture: the server is at fifteen percent utilisation and requests are already queueing.

The routing engine is async by construction: its core holds around a hundred and fifty async methods against eight blocking calls — and all eight sit in synchronous convenience wrappers, not in the processing pipeline. While an operation waits on an external system, the thread serves others.

Practical meaning: one node holds substantially more concurrent operations on the same CPU. For a payment gateway, where waiting is nearly all of the elapsed time, that's not a difference measured in percent.

What that means in money

Resource savings convert down a chain, and every link is its own budget line:

  • Fewer cores per node — and enterprise database licenses, plus a chunk of commercial software, are priced per core.
  • Fewer nodes for the same traffic — a direct cloud or hardware bill.
  • The standby environment mirrors production, so any saving on the primary automatically doubles.
  • Less load on the database itself — and in a financial system the database is usually the most expensive component and the hardest to scale: you add an app node, you don't add a database.

We're deliberately not quoting percentages here. They depend on your load profile, and any tidy figure in a post like this would be a figure from nowhere. But the mechanism is verifiable, and you'll see it on your own load immediately.

The important part is that this saving is recurring. Saved engineering hours are a one-time gain. The infrastructure bill arrives every month, for the life of the system.


People: why one stack is cheaper to run

There's an effect that never appears in a budget and in practice costs more than migrations.

Take a normal .NET project two years and fifty features in. What you'll find: two controller styles, because the approach changed. Three validation approaches. Two background-work mechanisms — the scheduler adopted at the start, and the second one added when the first didn't fit. A hand-rolled mapper next to a library one. And a data-access layer half the team bypasses because it's faster that way.

Nobody is surprised, because this is what always happens when the scaffolding is chosen fresh on every task.

Our stack constrains that freedom deliberately. Three rules total: a data structure is a class, an entry point is a route, an integration is a connector. Not out of hostility to variety, but because variety in the foundation is paid for forever.

In money:

Onboarding. A new developer learns one approach rather than five that "grew historically." Difference: 3–5 days per person. On a team of ten with normal attrition, 150–250 hours a year.

Review. The "but how should this be done" argument doesn't arise, because there's one shape. Half an hour to an hour per pull request, a hundred and fifty of those a year — another 75–150 hours.

The refactor that never happens. In a heterogeneous codebase, every eighteen months to two years someone launches a "let's unify everything" initiative worth 200–400 hours that gets half-finished. Here there's nothing to unify.

Key-person risk. No longer about hours. When the scaffolding is uniform, a departing lead doesn't take with them the knowledge of why it's done one way here and another way there. For a financial organisation, where team turnover is a genuine operational risk, that's a line of its own.


Procurement: what developers don't think about and directors do

Three questions that surface at sign-off and can stop a project after the technical side has satisfied everyone.

Licenses. Every Pro capability — optimised query generation, partial writes, parallel materialisation, clustering, advanced analytics — is free across the whole 3.x line. No key, no license server, no registration, commercial production included. Not "free up to a volume", not "free for development." Free. Versions you're already running stay free permanently.

The open portion is Apache 2.0. Pro packages are closed-source but free.

Source code. Companies that adopt the ecosystem can request the Pro sources — for a security audit, escrow, or in-house builds. In enterprise procurement, source escrow for a proprietary component isn't a bonus, it's a line in the requirements, and it usually costs separate money and separate negotiation.

No vendor lock-in. Data sits in PostgreSQL or MS SQL — your database, your infrastructure, your control. The storage structure is open and documented, so the database reads with ordinary SQL, without our code and without our tools. There's a first-class export and import for the whole database, including across engines. No closed formats anywhere. The worst case — "the vendor disappeared" — leaves you with a running system, your data in readable form, and, if needed, the sources.

For contrast: the typical alternative for the facade layer is either a commercial license priced at enterprise tier for a financial institution, or an open product on a foreign stack that needs an operations team of its own.


The whole bill

Everything above, in one table. This is a model with stated assumptions, not a measurement of two parallel teams — but the assumptions are conservative, and every line can be argued with individually.

One-time cost that doesn't occur:

What you don't write Engineering hours
Transports — the 9 a payment platform actually needs, at production quality 900–1,350
Integration patterns — duplicate suppression, splitting, aggregation, routing, retries, circuit breaker, compensation 400–800
Operational layer — API, CLI, dashboard, clustering, observability, deployment 1,870–2,820
Identity facade — adoption, client management, audit, MFA, federation 600–1,000
The data-model design phase that never happens 440–880
Testing product hypotheses — 10–15× cheaper per test ~560
Total 4,770–7,410

That's 2.8–4.4 person-years.

Recurring cost that doesn't occur:

What Hours per year
Database migrations 360–600
Preparing and running release windows ~450
Triaging stuck operations with developer time 100–200
Maintaining your own connectors as external systems change 150–250
Onboarding and review on heterogeneous scaffolding 225–400
Total 1,285–1,900

That's 0.8–1.1 of a permanent headcount.

Rates vary too much across markets for a single number to be useful, so substitute your own. Fully-loaded cost per engineering hour — salary plus taxes, benefits, workspace, management, holidays and idle time, not the number in the offer, which is the most common mistake and understates the result two-to-threefold:

Fully-loaded hourly One-time (4,770–7,410 h) Annual (1,285–1,900 h)
$60 $286k – $445k $77k – $114k
$100 $477k – $741k $129k – $190k
$150 $716k – $1.11M $193k – $285k

Plus no license fees, and source escrow already answered.

One honest caveat: calling this a saving is only fair where the team would genuinely have built all of it. An organisation that would have bought instead gets the benefit in another form — absence of license fees and absence of an integration zoo. And hours spent on your business logic — the accounting model, pricing, fraud rules, reconciliation — nobody saves and nobody should. What gets saved is the infrastructure layer underneath them.


Where we're a bad fit

The section that usually doesn't get written. We'll write it, because understanding the boundary is cheaper at the start than halfway in.

The accounting model is yours. No double-entry, no chart of accounts, no posting rules, and none planned. We give the foundation, you describe your accounting. If you were shopping for turnkey processing, this isn't it.

Regulated open banking needs work. If the platform faces outward as a third-party provider or as a bank serving them, you'll need a sprint across the standards listed earlier. The groundwork is there and the scope is knowable, but it's not in the box today.

Financial message codecs are yours to write. Transport, framing and schema validation exist; parsing a specific ISO 20022 or ISO 8583 profile is yours.

No multi-region clustering out of the box. Coordination assumes low latency to a shared database. The pattern for geographic redundancy is a cluster per region, federated a level above.

Horizontal sharding is a kit, not a feature. Every necessary mechanism is in the store: block key generation with the ability to move the key source to a separate database, per-connection cache isolation, several independent stores in one module. An assembled "switch it on and go" solution isn't there yet. That's a post of its own, and it's in progress.

Module isolation is not a sandbox. A loaded module runs with the process's privileges: trust comes from the signature at the door, not from restricting rights after start.

For enterprise practice that barely matters. The contractor delivers source, the build runs in your pipeline and is signed with your key — the pair is generated with one command, and the verifying side only knows the public half. Then the signature doesn't mean "we trust the contractor", it means "this is exactly the code that passed our review and our build", which is stricter than any sandbox around a third-party binary.

The real limitation remains for one scenario: a module arriving as a finished binary from a supplier you don't control and whose code you haven't seen. There you'll want OS-level isolation — a process or container per module.


Wrapping up

You can't buy a payment platform ready-made — the part you actually get paid for is always written for you. But it's the smaller part of the code and the smaller part of the budget.

The larger part is the transport to a bank that speaks IBM MQ. It's the defence against double-charging. It's an authorization server that will survive an audit. It's a dashboard that shows where an operation stopped. It's deploying without pausing payments. It's node coordination once one node stopped being enough. And that's roughly three person-years plus a standing headcount — if you write it yourself.

Our bet over the last four years is that this layer should be shared. One stack across all three parts of the platform, one version across the ecosystem, one set of rules for the whole team. A store that counts money without loss and changes its schema without migrations. An integration engine that speaks the counterparty's language instead of demanding they speak yours. An identity server checked against an external reference. A runtime that ships changes without a maintenance window and hands support a button instead of a phone call to a developer.

And all of it free across the whole 3.x line, with sources on request.

If you're working on something similar — tell me in the comments which of these hurts most. We have detailed technical write-ups per layer, and the next ones will follow real questions rather than what we happen to find interesting.

Sources and releases: github.com/redbase-app. About the store: redbase.app. Previous posts in the series: my dev.to profile.

If this was useful — a ⭐ on GitHub helps others find it.

Top comments (0)