Distributed Payment & Wallet Service Interview Questions | JiQuest

add

#

Distributed Payment & Wallet Service

Mini project / POC · end to end

Distributed Payment & Wallet Service: a complete Java 21 ledger mini project.

A double-entry ledger with pessimistic-locked atomic transfers, two-layer idempotency, the transactional outbox pattern, and scheduled balance reconciliation — the exact mechanics interviewers actually probe when they ask "how do you prevent a duplicate charge."

3Microservices
2Idempotency layers
0Distributed transactions
Client Payment Serviceidempotent API + outbox Wallet Servicedouble-entry ledger Reconciliation+ notification wallet_dbone strongly-consistent DB Kafka brokerpayment + discrepancy events both sides of a transfer live in one database — on purpose

Project overview and requirements

Every payment system interview eventually asks the same three questions: how do you stop a retried request from charging someone twice, how do you guarantee money never silently disappears between two accounts, and how do you know your ledger is actually correct in production. This project builds real, working answers to all three instead of just describing them.

Functional requirements

WalletsEvery customer and merchant has a wallet with a currency and a balance.
TransfersA payment moves money from one wallet to another as one atomic, all-or-nothing operation.
Idempotent APIRetrying the same payment request never results in a double charge.
AuditabilityEvery balance change is explained by an immutable ledger entry, not just a mutated number.

Non-functional requirements

Strong consistency where it mattersA wallet balance is never read or written outside a properly locked transaction.
No distributed transactionsThe service boundary is drawn so a transfer is always a single local ACID transaction.
Reliable event publishingA crash between committing a payment and publishing its event can never lose the event.
Self-verifyingA scheduled job independently proves the cached balance matches the ledger, continuously.

Technology stack and why each piece is there

TechnologyRole in this projectWhy this one
Java 21 + Spring Boot 3.xRuntime and framework for all three services.Same reasoning as the other two mini projects — virtual threads, records, auto-configuration.
PostgreSQLWallet Service's single source of truth for balances and the ledger.Row-level locking (SELECT ... FOR UPDATE) and real ACID transactions are non-negotiable for a ledger — this is not a place for eventual consistency.
Spring Data JPAPersistence, including pessimistic lock annotations on wallet reads.First-class support for @Lock(PESSIMISTIC_WRITE) without hand-written native SQL.
Apache KafkaDownstream events only — notification and reconciliation alerts, never the transfer itself.The transfer must never depend on Kafka being available; Kafka only carries what happens after a transfer is already durably committed.
Spring Security + JWTStateless auth with CUSTOMER / MERCHANT / ADMIN roles.Same pattern as the other two projects — every service verifies its own JWT.
OpenFeignPayment Service → Wallet Service synchronous transfer call.A payment's success genuinely depends on the transfer's outcome, so this has to be synchronous, not fire-and-forget.
Notably absent: AWS S3. Unlike the other two mini projects, this one has no natural file-upload requirement — forcing S3 in here just to reuse a pattern would be artificial. Not every project needs every technology; use what the problem actually calls for.

Jump to a section

High-level architecture

Payment Service is the public-facing orchestrator; Wallet Service is the only service that ever mutates a balance, and it owns every wallet in one database — not one database per customer, not sharded by user. That single decision is what makes a transfer a simple local transaction instead of a distributed one.

Client API GatewaySpring Cloud Gateway Payment Service/payments + outbox relay Wallet Service/internal/transfers Reconciliation Svcverify + notify payment_dbpayments + outbox wallet_dbwallets + ledger, ACID reconciliation_dbruns + notifications Kafka brokerpayment.* + reconciliation.* sync OpenFeign
Why this is not a distributed-transaction problem A naive design might put payer and payee wallets in different databases (or different services) and then reach for two-phase commit or a SAGA to keep them consistent. Instead, Wallet Service owns all wallets in one database, so a transfer between any two wallets is always a single local ACID transaction — the hard distributed-consistency problem is avoided by service-boundary design, not solved with more machinery.

Service breakdown

Payment Service — public API, idempotency, and reliable eventing

The only service a client talks to directly. It enforces client-facing idempotency, calls Wallet Service synchronously to execute the actual transfer, and uses the transactional outbox pattern so a downstream event is never lost even if the process crashes right after committing.

  • POST /payments — CUSTOMER, requires an Idempotency-Key header
  • GET /payments/{id} — CUSTOMER (own), MERCHANT (received), ADMIN (any)
  • scheduled outbox relay — polls unpublished events and sends them to Kafka
Owns payment_dbSync calls Wallet ServiceTransactional outbox

Wallet Service — the double-entry ledger, and the only source of truth for balances

Owns every wallet and every ledger entry in one strongly-consistent database. Its transfer endpoint is idempotent at the database level and performs the debit and credit as one local transaction under row-level locks acquired in a fixed order.

  • POST /internal/transfers — internal only, called by Payment Service, idempotent
  • GET /wallets/{id}, GET /wallets/{id}/ledger — owner or ADMIN
Owns wallet_dbPessimistic row lockingNo outbound calls to other services

Reconciliation & Notification Service — the independent safety net

Consumes payment events to notify customers and merchants, and separately runs a scheduled job that recomputes every wallet's balance directly from ledger entries and compares it against Wallet Service's cached balance — catching bugs that transactions alone wouldn't.

  • consumes payment.completed / payment.failed — sends notifications
  • scheduled job — recomputes and compares every wallet's balance
  • publishes reconciliation.discrepancy if a mismatch is found
Owns reconciliation_dbRead-only against Wallet ServiceIndependent of the transfer path

Database and ledger design

The ledger is append-only and double-entry: every transfer produces exactly two rows — a debit and a credit — that share a transfer_id and always sum to zero. The wallets.balance column is a cached, fast-to-read projection of "sum of this wallet's ledger entries," not the source of truth itself.

payment_db payments(id PK, idempotency_key UNQ,  payer_wallet_id, payee_wallet_id,  amount, status, created_at) outbox_events(id PK, aggregate_id,  event_type, payload, published,  created_at) wallet_db (strongly consistent) wallets(id PK, owner_id, currency,  balance DECIMAL, version, updated_at) ledger_entries(id PK, wallet_id FK,  transfer_id, direction, amount,  balance_after, created_at) processed_transfers(idempotency_key PK,  transfer_id, status, created_at) reconciliation_db reconciliation_runs(id PK,  wallet_id, expected, actual,  discrepancy, run_at) notifications(id PK, payment_id, ...) payer_wallet_id / payee_wallet_id are stored values, not cross-DB foreign keys Example: a $50 transfer produces two rows ledger_entries: wallet=101, transfer=T1, DEBIT, -50.00, balance_after=150.00 ledger_entries: wallet=202, transfer=T1, CREDIT, +50.00, balance_after=80.00 Sum of both entries' amounts = 0.00 -- this invariant is what reconciliation checks.

The atomic double-entry transfer

This is the piece interviewers care about most: how do you move money between two wallets so that it's impossible for the debit to happen without the credit, impossible for two concurrent transfers on the same wallet to corrupt the balance, and impossible for a retried request to apply twice.

1. Idempotency checkprocessed_transfers lookup 2. Lock both walletsSELECT ... FOR UPDATE, id-ordered 3. Check fundspayer.balance ≥ amount 4. Write both entriesDEBIT + CREDIT, one commit already processed? return cached resultno re-processing insufficient funds? rollback, throwlocks released wallet 101 and 202 → always lock lower id first (prevents deadlock between concurrent opposite-direction transfers) Why lock ordering matters Transfer A: 101 → 202 locks 101 then 202. Transfer B: 202 → 101, if it locked 202 then 101, could deadlock with A.
// Wallet.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select w from Wallet w where w.id = :id")
Wallet findByIdForUpdate(@Param("id") Long id);   // WalletRepository

// WalletTransferService.java
@Transactional
public TransferResult transfer(String idempotencyKey, Long payerWalletId, Long payeeWalletId, BigDecimal amount) {

    Optional existing = processedTransferRepository.findById(idempotencyKey);
    if (existing.isPresent()) {
        return existing.get().toResult();          // safe to retry -- same result, no reprocessing
    }

    Long firstId = Math.min(payerWalletId, payeeWalletId);   // fixed lock order prevents deadlock
    Long secondId = Math.max(payerWalletId, payeeWalletId);
    Wallet first  = walletRepository.findByIdForUpdate(firstId);
    Wallet second = walletRepository.findByIdForUpdate(secondId);
    Wallet payer  = payerWalletId.equals(first.getId()) ? first : second;
    Wallet payee  = payerWalletId.equals(first.getId()) ? second : first;

    if (payer.getBalance().compareTo(amount) < 0) {
        throw new InsufficientFundsException(payerWalletId);
    }

    payer.setBalance(payer.getBalance().subtract(amount));
    payee.setBalance(payee.getBalance().add(amount));

    String transferId = UUID.randomUUID().toString();
    ledgerRepository.save(LedgerEntry.debit(payer.getId(), transferId, amount, payer.getBalance()));
    ledgerRepository.save(LedgerEntry.credit(payee.getId(), transferId, amount, payee.getBalance()));
    processedTransferRepository.save(new ProcessedTransfer(idempotencyKey, transferId, "COMPLETED"));

    return new TransferResult(transferId, "COMPLETED");
}
Classic interview gotcha amount and balance are BigDecimal, never double or float — binary floating point cannot represent most decimal fractions exactly, and a rounding error in money code is a bug report, not a footnote.

The transactional outbox pattern

After Payment Service commits a completed payment, it must publish a payment.completed event — but a crash between "commit the payment" and "publish to Kafka" would otherwise lose the event forever, even though the money already moved. The outbox pattern closes that gap by writing the event to the same database, in the same transaction, as the payment itself.

// PaymentService.java
@Transactional
public Payment createPayment(String idempotencyKey, PaymentRequest request) {
    Optional existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
    if (existing.isPresent()) {
        return existing.get();                       // client retried -- return the original result
    }

    Payment payment = paymentRepository.save(Payment.pending(idempotencyKey, request));

    TransferResult result = walletClient.transfer(
        payment.getInternalIdempotencyKey(), request.payerWalletId(), request.payeeWalletId(), request.amount());

    payment.markCompleted(result.transferId());
    paymentRepository.save(payment);

    // same local transaction, same commit as the payment row above
    outboxRepository.save(OutboxEvent.of("PaymentCompleted", payment.getId(), toJson(payment)));
    return payment;
}

// OutboxRelay.java -- separate process/thread, decoupled from the request path
@Scheduled(fixedDelay = 2000)
public void relayPendingEvents() {
    List pending = outboxRepository.findTop50ByPublishedFalseOrderByCreatedAtAsc();
    for (OutboxEvent event : pending) {
        kafkaTemplate.send("payment." + event.getEventType(), event.getAggregateId().toString(), event.getPayload());
        event.markPublished();
        outboxRepository.save(event);
    }
}
This makes publishing at-least-once, not exactly-once — the relay could publish an event and crash before marking it published, causing a duplicate on restart. That's fine, because the consumer (Reconciliation & Notification Service) is written to be idempotent on payment.id, which is a far easier property to guarantee than exactly-once delivery across a network.

Security and role-based access control

Same JWT/RBAC pattern as the other two mini projects: every service independently verifies the signed token. The role set here is deliberately narrow — nobody, including ADMIN, gets an endpoint that directly edits a balance.

RoleCan do
ROLE_CUSTOMERInitiate payments from own wallet, view own wallet and ledger history.
ROLE_MERCHANTView received payments and own wallet, cannot initiate transfers from other wallets.
ROLE_ADMINView any wallet and ledger, view reconciliation reports — never a direct balance-edit endpoint.
Security note There is intentionally no PATCH /wallets/{id}/balance endpoint at any privilege level. Every balance change must go through the transfer path so it always produces a matching ledger entry — an admin "quick fix" endpoint would be the one way to make a balance and its ledger silently disagree.

Service communication: the payment sequence, including a client retry

Client Payment Service Wallet Service Kafka 1. POST /payments (Idempotency-Key: K1) 2. transfer(K1-internal, ...) 3. lock, debit+credit, commit 4. 200 COMPLETED 5. commit payment + outbox row (same tx) response lost on the wire! 6. client retries: same Idempotency-Key K1 7. payments table already has K1 → return cached result, no re-call to Wallet Service 8. 200 COMPLETED (identical result) 9. outbox relay publishes payment.completed once
Two idempotency layers, two different jobs Layer one (Payment Service, keyed on the client's Idempotency-Key) stops the client's retry from calling Wallet Service a second time at all. Layer two (Wallet Service, keyed on an internal idempotency key Payment Service generates per attempt) stops double-processing even if Payment Service itself retries the internal call after a timeout without knowing whether the first attempt succeeded.

Balance reconciliation: the independent safety net

Transactions and locking prevent corruption from concurrent access, but they don't protect against a logic bug, a bad migration, or a manual data fix that silently breaks the invariant that a wallet's balance always equals the sum of its ledger entries. Reconciliation is a second, independent check of that invariant — the same principle as double-entry bookkeeping in traditional accounting.

Scheduled jobhourly, per wallet SUM(ledger_entries)derived balance wallets.balancecached balance compareequal or not reconciliation.discrepancypaged to on-call, not silent
@Scheduled(cron = "0 0 * * * *")   // hourly
public void reconcileAllWallets() {
    for (WalletSummary w : walletClient.getAllWalletBalances()) {
        BigDecimal derived = ledgerRepository.sumEntriesForWallet(w.walletId());
        if (derived.compareTo(w.cachedBalance()) != 0) {
            reconciliationRepository.save(ReconciliationRun.discrepancy(w.walletId(), w.cachedBalance(), derived));
            kafkaTemplate.send("reconciliation.discrepancy",
                new DiscrepancyEvent(w.walletId(), w.cachedBalance(), derived));
        }
    }
}

Project folder structure

payment-wallet-poc/
├── api-gateway/
│  ├── src/main/java/com/jiquest/gateway/GatewayApplication.java
│  ├── src/main/resources/application.yml
│  ├── Dockerfile
│  └── pom.xml
│
├── payment-service/
│  ├── src/main/java/com/jiquest/payment/
│  │  ├── PaymentServiceApplication.java
│  │  ├── config/SecurityConfig.java, KafkaProducerConfig.java
│  │  ├── client/WalletClient.java          // OpenFeign
│  │  ├── controller/PaymentController.java
│  │  ├── service/PaymentService.java, OutboxRelay.java
│  │  ├── repository/PaymentRepository.java, OutboxRepository.java
│  │  └── entity/Payment.java, OutboxEvent.java
│  ├── src/main/resources/application.yml
│  ├── Dockerfile
│  └── pom.xml
│
├── wallet-service/
│  ├── src/main/java/com/jiquest/wallet/
│  │  ├── WalletServiceApplication.java
│  │  ├── config/SecurityConfig.java
│  │  ├── controller/WalletController.java, TransferController.java
│  │  ├── service/WalletTransferService.java
│  │  ├── repository/WalletRepository.java, LedgerEntryRepository.java, ProcessedTransferRepository.java
│  │  ├── entity/Wallet.java, LedgerEntry.java, ProcessedTransfer.java
│  │  └── exception/InsufficientFundsException.java
│  ├── src/main/resources/application.yml
│  ├── src/test/java/com/jiquest/wallet/ConcurrentTransferIT.java
│  ├── Dockerfile
│  └── pom.xml
│
├── reconciliation-service/
│  ├── src/main/java/com/jiquest/reconciliation/
│  │  ├── ReconciliationServiceApplication.java
│  │  ├── config/KafkaConsumerConfig.java
│  │  ├── client/WalletClient.java
│  │  ├── listener/PaymentEventListener.java
│  │  ├── service/ReconciliationJob.java, NotificationService.java
│  │  ├── repository/ReconciliationRunRepository.java, NotificationRepository.java
│  │  └── entity/ReconciliationRun.java, Notification.java
│  ├── src/main/resources/application.yml
│  ├── Dockerfile
│  └── pom.xml
│
├── docker-compose.yml
├── .env.example
└── README.md

Local development setup

# docker-compose.yml (excerpt)
services:
  postgres-payment:
    image: postgres:16
    environment: { POSTGRES_DB: payment_db, POSTGRES_PASSWORD: postgres }
    ports: ["5433:5432"]

  postgres-wallet:
    image: postgres:16
    environment: { POSTGRES_DB: wallet_db, POSTGRES_PASSWORD: postgres }
    ports: ["5434:5432"]

  postgres-reconciliation:
    image: postgres:16
    environment: { POSTGRES_DB: reconciliation_db, POSTGRES_PASSWORD: postgres }
    ports: ["5435:5432"]

  kafka:
    image: apache/kafka:3.7.0
    ports: ["9092:9092"]

  wallet-service:
    build: ./wallet-service
    depends_on: [postgres-wallet]
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-wallet:5432/wallet_db
    ports: ["8082:8080"]

  # payment-service and reconciliation-service follow the same shape as the other mini projects

Testing strategy: proving the concurrency guarantees

Unit tests aren't enough here — the entire point of the locking design is to behave correctly under concurrent access, so the most important test in this project fires many transfers at the same wallet pair simultaneously and asserts the final balance is exactly right.

@Testcontainers
@SpringBootTest
class ConcurrentTransferIT {

    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16");

    @Test
    void hundredConcurrentTransfersNeverCorruptTheBalance() throws InterruptedException {
        Wallet payer = createWallet(BigDecimal.valueOf(1000));
        Wallet payee = createWallet(BigDecimal.ZERO);

        ExecutorService pool = Executors.newFixedThreadPool(20);
        CountDownLatch latch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            String key = "test-key-" + i;   // each a DISTINCT idempotency key -- 100 real transfers
            pool.submit(() -> {
                transferService.transfer(key, payer.getId(), payee.getId(), BigDecimal.TEN);
                latch.countDown();
            });
        }
        latch.await(30, TimeUnit.SECONDS);

        assertThat(walletRepository.findById(payer.getId()).getBalance()).isEqualByComparingTo("0");
        assertThat(walletRepository.findById(payee.getId()).getBalance()).isEqualByComparingTo("1000");
    }

    @Test
    void retryingTheSameIdempotencyKeyNeverDoubleCharges() {
        Wallet payer = createWallet(BigDecimal.valueOf(100));
        Wallet payee = createWallet(BigDecimal.ZERO);

        transferService.transfer("same-key", payer.getId(), payee.getId(), BigDecimal.TEN);
        transferService.transfer("same-key", payer.getId(), payee.getId(), BigDecimal.TEN); // retry

        assertThat(walletRepository.findById(payer.getId()).getBalance()).isEqualByComparingTo("90"); // not 80
    }
}
Testcontainers PostgreSQLConcurrent-load integration testIdempotency replay test

Observability

Transfer latency & lock wait timeA rising lock-wait metric on a specific wallet id usually means one "hot" wallet (a popular merchant) is becoming a contention point.
Idempotency hit rateHow often processed_transfers or payments lookups find an existing key — a proxy for client retry behavior and network reliability.
Outbox relay lagTime between an event being written to the outbox and actually published — a growing lag means Kafka or the relay itself is falling behind.
Reconciliation discrepancy countShould be zero, always. A non-zero count is treated as a page-worthy incident, not a warning log.

Path to production on AWS

Local (this POC)AWS equivalent
3 services in Docker ComposeECS Fargate or EKS, one service per task definition
3 Postgres containersAmazon RDS PostgreSQL, Multi-AZ for wallet_db specifically given its criticality
Single-broker Kafka containerAmazon MSK
Scheduled @Scheduled jobsConsider moving the outbox relay and reconciliation job to independent, separately-scalable workers rather than in-process schedulers once volume grows

Key design decisions and interview talking points

Why does a single transfer only ever touch one database instead of two?

Wallet Service owns every wallet in one database rather than sharding by customer, so both sides of any transfer are always local rows in the same database. That service-boundary decision is what avoids needing a distributed transaction (two-phase commit or a SAGA) for what is fundamentally a single, simple operation.

Why row-level SELECT ... FOR UPDATE with ordered locking instead of just using SERIALIZABLE isolation for the whole transaction?

SERIALIZABLE would also work, but it detects conflicts by aborting and retrying transactions, which degrades badly under high contention on a popular wallet. Explicit ordered row locks pay a small, predictable wait cost up front instead of unpredictable abort-and-retry storms, and the lock ordering rule is simple enough to reason about directly.

How exactly does the idempotency key prevent a double charge on client retry?

Before doing any work, Payment Service checks whether a payment with that Idempotency-Key already exists; if it does, it returns the stored result immediately without calling Wallet Service again. The check-and-insert is protected by a unique database constraint on the key, so even two near-simultaneous retries can't both slip through a race in the application code.

Why two separate idempotency keys instead of just passing the client's key all the way through to Wallet Service?

The client's key protects against the client retrying; Payment Service's own internally generated key protects against Payment Service itself retrying the call to Wallet Service after an ambiguous timeout, without needing to know or trust anything about the client's key format or uniqueness guarantees. Two layers, two independent failure modes covered.

What actually breaks if the transactional outbox pattern is removed and Payment Service just calls Kafka directly after committing?

A crash in the narrow window after the database commit but before the Kafka send would lose the event permanently — the payment is real and money moved, but nothing downstream (notification, reconciliation) ever finds out. The outbox makes that window disappear by writing the event in the same transaction as the payment itself, so "payment committed" and "event will eventually be published" become the same guarantee.

The outbox relay can publish an event and then crash before marking it published — doesn't that cause duplicates?

Yes, and that's an accepted trade-off: the system gets at-least-once delivery, not exactly-once, which is a much easier guarantee to build correctly. The fix is making every consumer of these events idempotent on the payment id, the same discipline applied everywhere else in this project.

Why keep a cached balance column on wallets at all if the ledger is the real source of truth?

Recomputing a balance by summing every historical ledger entry on every read would get slower as a wallet accumulates transaction history, so the cached column exists purely for fast reads. The ledger stays authoritative precisely because reconciliation periodically checks the cache against it — the cache is a performance optimization with a verification safety net, not a second source of truth.

What does the reconciliation job protect against that the transaction and locking design doesn't?

Locking and transactions protect against concurrent-access bugs during a transfer; they do nothing to catch an unrelated bug, like a migration script that directly updates a balance, or a future code change that writes a ledger entry without updating the cached balance to match. Reconciliation is an independent, continuously-running proof that the invariant still holds regardless of how it might have been violated.

Why BigDecimal instead of double or float for money amounts?

Binary floating point cannot exactly represent most decimal fractions (0.1 has no exact binary representation), so repeated arithmetic on money stored as double silently accumulates rounding error. BigDecimal represents decimal values exactly and lets you control rounding explicitly, which is why virtually every production financial system uses a decimal type, never floating point, for currency.

How would you handle a currency mismatch, like trying to transfer USD into a EUR wallet?

Reject it at the validation layer before any locking happens — a transfer request's currency must match both wallets' currency exactly in this design. Supporting cross-currency transfers would require an explicit conversion step with its own rate source and rounding rules, which is a meaningfully different (and riskier) feature, not a small extension.

Isn't a version column (optimistic locking) redundant with the pessimistic SELECT ... FOR UPDATE locks already in place?

Within the transfer path itself, yes — the pessimistic lock alone is sufficient there. The version column matters for any other code path that might read and later write a wallet outside the transfer service (an admin tool, a batch job), giving those paths a cheap way to detect a stale read without needing to take a pessimistic lock themselves.

How would you extend this design to support multi-currency wallets per customer?

Model it as one customer owning multiple single-currency wallet rows (a USD wallet and a EUR wallet, each with its own id) rather than one wallet with a currency field that changes meaning — transfers stay single-currency and simple, and any cross-currency conversion becomes an explicit, separately-designed operation between two of that customer's own wallets.

What happens to a payment request if Wallet Service is briefly unavailable?

The OpenFeign call fails or times out, Payment Service's local transaction rolls back (no payment row is committed), and the client sees an error and can safely retry with the same Idempotency-Key — because nothing was ever committed, that retry is a fresh attempt, not a double-processing risk.

Could a malicious client replay an old, legitimate Idempotency-Key days later to trigger a duplicate payment?

No — replaying the same key against the same payment just returns the original cached result again, by design; it can never trigger a second transfer. The actual risk to guard against is a leaked API credential being used to submit new payments with fresh keys, which is an authentication and rate-limiting concern, not an idempotency one.

Why can't even ADMIN directly edit a wallet's balance through the API?

Any balance change that doesn't go through the transfer path produces a balance with no corresponding ledger entry, which is exactly the kind of drift the reconciliation job exists to catch and exactly the kind of unauditable change a financial system should never allow, regardless of who's making it. A correction, if ever needed, should itself be modeled as a transfer (from a system "adjustments" wallet) so it leaves the same audit trail as everything else.

How would you scale Wallet Service if it becomes a write bottleneck, given it's deliberately one strongly-consistent database?

Vertical scaling and read replicas for the read-heavy endpoints buy real headroom first. Beyond that, the next real lever is partitioning wallets across multiple database shards by wallet id range — which reintroduces the cross-shard-transfer problem this design currently avoids, so it should only be taken once single-database scaling is genuinely exhausted, not as a default.

If an interviewer asks for the single most important design decision in this project, what do you say?

Keeping every wallet in one strongly-consistent database so a transfer is always a single local ACID transaction — it's the decision that makes the rest of the system simple, because it means idempotency, locking, and the outbox pattern only ever have to solve well-understood, single-database problems instead of the much harder distributed-consensus problem a naive multi-database design would have created.

Related guides

Update these hrefs to your published Blogger post URLs once each page is live.

No comments
Leave a Comment