Loading home…
Loading home…
Loading article…
Who else needs to know once the money has already moved? I asked myself that with the ledger freshly done and no topic running yet. That week I split what happened in the domain from the message Kafka will carry next.

No spam — just a note when I publish something new on backend, cloud, and architecture.
One email when a post goes live. Unsubscribe anytime.

A LinkedIn post on fintech architecture, a recent technical challenge, and a decision to rebuild a banking core from scratch — slower this time, to actually learn.

Three seconds on the spinner. Timeout on screen. Did the money move? I reopened my Arkano challenge next to a fintech architecture map — and understood why POST /transfer was never just an endpoint.

Six boxes on the diagram. Six microservice temptations. I almost fell for it again — until I got that the full map doesn't mean shipping it all in week one, and splitting repos doesn't split the domain
When I closed the internal ledger article, I left a line hanging that I have not stopped thinking about: then comes the bus.
That line was about what happens after money has already moved. In my earlier technical challenge that meant Kafka: as soon as you completed a transfer, something went to a topic. Fraud found out. Notifications got built. Another team could react without digging into how you stored accounts.
Here the ledger had already closed another part of the problem. Deposit, transfer, derived balance. Tests passed. All in one request, all in PostgreSQL.
I thought the next step would come almost on its own: import kafkajs, repeat the script, start the bus.
I almost did. Out of habit.
But before publishing anything, another question stopped me. The ledger tells you whether you counted correctly. It does not tell you who to notify or in what words.
And notifying someone means deciding what to tell them. That is when I remembered a mistake I had seen many times: confusing what happened inside with what must go outside.
The first thing that came to mind was the fastest path: take the row I had just saved in TypeORM, serialize it, and publish it to Kafka. Many teams sell that as a "domain event". It works in the demo. Tests pass. The topic receives something.
The problem shows up months later. You rename a column to fit the ledger, or change how you store the idempotency key, and suddenly the fraud service no longer understands the message. Or notifications. Or a service that did not even exist when you wrote the producer. The coupling did not come from Kafka. It came from mixing two different questions in one JSON.
The first question is business: what happened in accounts? There I need names the domain team understands, like FundsDeposited or InternalTransferCompleted. Facts that already happened inside the module.
The second is contract: what do I promise the rest of the system? There I need a versioned, stable message without ORM details, like com.fintech.account.credited.v1. Fraud, notifications, or another service can consume it without knowing how I store rows in PostgreSQL.
I looked at both options with the same logic I used when choosing Money over float:
| Approach | Why it tempted me | Why I passed |
|---|---|---|
| Publish the entity as-is | Less code on day one | Couples database, domain, and external contract |
| Split domain and integration | More types and a mapper | Each layer speaks its language; the contract versions separately |
That is why I did not start with Kafka. First I modeled the fact inside and the message outside. When a deposit completes, I record FundsDeposited inside the service; a handler translates it to com.fintech.account.credited.v1 for the outside world. Only with that translation clear does it make sense to return to the bus I promised when I closed the previous article. Not the other way around: I do not start the bus and only then discover what should have been published.
With that distinction in mind, this week's code built the bridge between the ledger and the bus, still without starting Redpanda.
Curiously, I did not add another service. I stayed in the same accounts-service in fintech-core-platform and wired a simple chain: first the movement is persisted in PostgreSQL; then the domain fact is published; a handler translates it to the integration contract; and for now a console log shows the JSON that will later go to fintech.accounts.events.
A deposit shows how that feels in practice. When it completes, and is not an idempotent retry, the service records the fact inside:
await this.domainEvents.publish(
new FundsDepositedEvent(
accountId,
command.amount,
command.idempotencyKey,
entry.id,
),
);
That fact is not yet the message fraud or notifications will see. A handler converts it to the external contract:
// Domain → integration
com.fintech.account.credited.v1
{
accountId, amountCents, currency,
idempotencyKey, ledgerEntryId
}
An internal transfer follows the same pattern: InternalTransferCompleted inside, com.fintech.transfer.completed.v1 outside, with transferReference, accounts, and amount.
I chose to leave that contract in a log before connecting it to Kafka. If I mix ledger, mapper, and producer in one commit and something fails, I do not know which piece broke. First I want to see the JSON in the console, validate that it says what I need, and only then plug in Redpanda with outbox, as in the earlier challenge, but this time on a ledger I can already trust.
Same setup as the ledger article:
pnpm install
docker compose -f infra/docker/docker-compose.yml up -d
cp apps/accounts-service/.env.example apps/accounts-service/.env
pnpm dev:accounts
Make a deposit and check the service console. If the chain closed correctly, you should see something like:
{
"message": "integration_event_ready",
"eventType": "com.fintech.account.credited.v1",
"eventId": "...",
"payload": { "accountId": "...", "amountCents": "1000", ... }
}
That JSON confirms the two layers I separated above: the money was recorded in the ledger and the integration message already has a stable shape for outside consumers. Repeat the deposit with the same Idempotency-Key and the ledger does not duplicate; the retry stops before publishing again.
In the earlier challenge that separation already existed, only everything ran faster. Fraud consumed events without caring how we persisted accounts. Here I am writing it more slowly because I first needed the ledger from the previous article.
A domain event tells what already happened inside. An integration event is the version you show the outside world. Without that boundary, the bus only spreads coupling faster.
First I learned to count with Money. Then I moved money and left a trail. This week I knew what to announce and in what format. What is left is connecting Redpanda, not inventing the contract while the bus is already on.
Series: makingcode.dev/series/fintech-core-platform
Code: github.com/AndresED/fintech-core-platform (commit e6146ef)