All articlesFull-Stack

Designing a microservices backend in Python: lessons from building a digital wallet

July 2, 20268 min read

Microservices get a bad reputation because most projects that adopt them didn't need them yet — a single Postgres-backed monolith would have shipped faster and been easier to debug. But some domains genuinely have separable responsibilities with different scaling and failure characteristics, and a digital wallet is one of them. Building SecurePay, a multi-currency wallet MVP, was a useful exercise in finding where those boundaries actually are instead of splitting services by guesswork.

Splitting by responsibility, not by CRUD resource

The instinct is to make one service per database table — a "users service," a "wallets service." That's splitting by noun, and it recreates a monolith with network calls between its parts. The split that held up in practice was by responsibility:

  • api-gateway — the only public entrypoint. Rate-limits per IP using Redis, proxies everything else.
  • user-service — registration, login (issues JWT), email verification via OTP.
  • wallet-service — wallet creation (email-verified only), balance, statements, beneficiaries.
  • transaction-service — atomic peer-to-peer transfers, cross-currency conversion at live rates.
  • fraud-service — real-time rule-based scoring, can block a transfer before it settles.
  • notification-service — a background worker that only reacts to events, never receives direct requests.

The test I used for "does this deserve its own service": does it have a distinct failure mode that shouldn't take down the others? Fraud scoring can be slow or degraded without blocking a user from checking their balance. Notifications can queue and retry without anyone waiting on them. Wallet balance reads should stay fast even if the transaction service is under load. Each of those is a real reason to draw a boundary — not just "this feels like a separate concept."

Events over direct calls, wherever the caller doesn't need to wait

The transaction service and the notification service are decoupled through RabbitMQ, not a direct HTTP call:

# transaction-service, after a transfer commits
await channel.default_exchange.publish(
    Message(body=json.dumps({
        "event": "transfer.completed",
        "transfer_id": transfer.id,
        "parties": [transfer.sender_id, transfer.receiver_id],
    }).encode()),
    routing_key="notifications",
)
# notification-service, consuming independently
async def on_transfer_completed(message: IncomingMessage):
    async with message.process():
        payload = json.loads(message.body)
        await send_transfer_emails(payload)

If the email provider is slow or down, transfers still complete instantly for the user — the notification just retries from the queue. That's the actual payoff of splitting this out: not "microservices are more scalable" in the abstract, but a concrete case where a slow dependency shouldn't be on the critical path of money moving.

Fraud screening as a synchronous gate, not an afterthought

Unlike notifications, fraud scoring does sit on the critical path — a transfer shouldn't settle before it's screened. That's a direct, synchronous call from the transaction service, with a strict timeout and a fail-closed default:

async def screen_transfer(transfer: TransferRequest) -> FraudResult:
    try:
        async with httpx.AsyncClient(timeout=2.0) as client:
            resp = await client.post(f"{FRAUD_SERVICE_URL}/screen", json=transfer.dict())
            return FraudResult(**resp.json())
    except httpx.TimeoutException:
        return FraudResult(action="review", score=None)  # fail closed, not open

The decision to fail closed (hold for manual review) rather than fail open (let it through) is a business call, not a technical one — but it's the kind of decision that's easy to get backwards if you're only thinking about uptime. A wallet that stays "available" by skipping fraud checks under load isn't actually working correctly.

One database, until there's a reason for more

Every service in this stack shares a single Postgres instance, just with separate schemas — not one database per service. That's a deliberate compromise: true per-service databases add real operational cost (migrations, backups, cross-service joins becoming API calls) that wasn't justified at MVP scale. The services are split by responsibility and failure isolation; the data store isn't, yet. That's a boundary I'd revisit only once a specific service's read/write pattern actually needed to scale independently — not before.

The takeaway

Microservices aren't a maturity milestone — they're a tool for isolating failure and load characteristics that are genuinely different. If two pieces of a system would always scale together and fail together, keep them in one process. Split where a slow or broken dependency shouldn't be able to take down something that has no business depending on it — that's where the boundary in SecurePay actually paid for itself.


Farhan Shafi
Farhan Shafi
Full-Stack Developer