A Python microservices backend for a digital wallet
Contents6
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 do have separate 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 really are instead of splitting services by guesswork.
Splitting by responsibility, not by CRUD resource
The instinct is to make one service per database table, such as a "users service" and 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. "This feels like a separate concept" is not.
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, and the notification retries from the queue. That is the real payoff of splitting this out. It is not "microservices scale better" in the abstract. It is a concrete case where a slow dependency should not sit 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, because 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 working correctly.
One database, until there's a reason for more
Every service in this stack shares a single Postgres instance, 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 needed to scale on its own, and not before.
The takeaway
Microservices aren't a maturity milestone. They're a tool for isolating failure and load patterns that really are 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 paid for itself.
Continue exploring
Compare this service split with the shared application boundaries in multi-tenant SaaS architecture and the MERN API testing strategy. The complete implementation is SecurePay on GitHub.