Overview
Demo Credit is a wallet service API — the backend a mobile lending app needs so borrowers can receive disbursed loans and send repayments. It was built as a take-home assessment for a backend engineering role at Lendsqr, under a fairly unusual constraint for a take-home: the brief itself specified the exact tech stack (Node.js, TypeScript, KnexJS, MySQL) and explicitly said not to attempt it unless you were already confident with all three. No architecture debate, no picking a comfortable stack — the assessment was as much about working correctly inside someone else's constraints as it was about the code itself.
The service does four things: create an account, fund a wallet, transfer between wallets, and withdraw. Underneath that simple surface sits a strict three-layer architecture — controllers handle HTTP only, services own every unit of business logic and all transaction scoping, repositories touch the database and nothing else — and a data model built around one non-negotiable rule for anything calling itself a fintech wallet: money math has to be exact, and a financial ledger has to survive concurrent writes without corrupting itself.
That second part is what this case study is actually about. Anyone can write a transfer function that works when you call it once, by yourself, on localhost. The interesting engineering is what happens when two transfers try to touch the same two wallets at the same time.
The Problem
The assessment brief was unusually prescriptive for a take-home. Most technical assessments let you pick your stack and show off; this one specified Node.js, TypeScript, KnexJS, and MySQL up front, then added a line most assessments don't bother with: don't attempt this if you're not already confident in all three. That's a different kind of pressure than "impress us" — it's "prove you can already do this," with no room to lean on a framework you're more comfortable with instead.
The functional requirements were plain enough — create an account, fund a wallet, transfer between wallets, withdraw — but one requirement carried real weight: a user with a record in Lendsqr's own Karma blacklist should never be onboarded. That meant integrating a live external API as a hard gate on registration, not a nice-to-have feature bolted on after the fact. If that check fails or times out, the correct answer isn't "let them through" — it's reject the registration, because a wallet service that fails open on identity screening isn't a wallet service worth shipping.
That integration hit a real wall partway through. The Adjutor dashboard's test-mode toggle wouldn't stay switched to live mode — it silently reverted back to test mode immediately after being changed. In test mode, the API returns a 200 with an empty body for every identity checked, which meant there was no way to actually exercise the blacklist-rejection path against real data, only ever the happy path. An escalation email went to Lendsqr's careers and support addresses; no response arrived before the submission deadline.
The response to that wasn't to skip the check or fake success. An environment-gated bypass — SKIP_KARMA_CHECK, active only when NODE_ENV=development, and structurally incapable of being active in production regardless of that flag's value — unblocked local development without touching the actual integration code. The Karma service itself was written, tested, and complete; the bypass exists purely so the rest of the system could be built and tested against something, while a genuinely broken third-party toggle sat unresolved outside anyone's control.
Technical Approach
The architecture is a strict three layers, and the strictness is the point. Controllers only translate HTTP in and out — parse the request, validate its shape with Zod, map whatever error comes back to the right status code. No business logic lives there, which means the same service method can be called from a controller, a script, or a test without duplicating the rules that actually matter. Services own all of it: balance checks, the blacklist check, the self-transfer guard, amount precision, and — critically — every unit of Knex transaction scoping. Repositories are Knex queries and nothing else; every mutation method takes a Knex.Transaction as an explicit argument, so there's no path by which a balance gets updated outside a transaction boundary. That's not a convention enforced by code review — it's enforced by the method signature itself.
The database side follows the same discipline. Every money column is DECIMAL(20,2), never FLOAT or DOUBLE — floating-point types carry IEEE 754 rounding error that's unacceptable the moment you're representing currency. Wallets and users are separate tables in a one-to-one relationship rather than columns bolted onto the user record, partly for clean modeling and partly because a fintech system eventually wants multiple wallets per user, and that's a much smaller change to a normalized schema than to a denormalized one. Every transaction gets a server-generated, database-unique reference (DC-{timestamp}-{random}) — the UNIQUE constraint is the actual idempotency guard, enforced at the one layer that can't be raced around, not a check the application layer promises to remember to do.
Foreign keys across the schema use ON DELETE RESTRICT. A user or wallet can't be deleted while a transaction still references it — full stop, not a soft-delete flag the application layer is trusted to respect. In a system whose entire job is an accurate financial record, "the ledger entry still points at something real" isn't a nice-to-have.
Testing runs at the service layer, deliberately as pure unit tests rather than integration tests — every repository, the Karma service, and the database itself fully mocked, so a test run makes zero real network or database calls. That was a conscious trade against realism: unit tests here buy determinism, speed, and isolation of the actual business rules, at the cost of not exercising the real HTTP or database layer end to end. Twenty-eight tests cover both happy paths and the failure modes that matter most in a financial system — insufficient balance, self-transfer, sub-cent precision, blacklist rejection, duplicate registration, and the Karma service returning an error instead of a clean answer. Auth middleware tests and full HTTP integration tests are the next layer, documented as the known gap rather than left unmentioned.
Key Decisions
Technical Approach covered the shape of the system. This section is about the three places where getting it slightly wrong would have meant a wallet service that corrupts money under real concurrent load — which, for this kind of system, is the only failure that actually matters.
Lock in a fixed order, not "sender first"
The obvious way to write a transfer is: lock the sender's wallet, then lock the receiver's. It's also the version that deadlocks the moment two transfers run in opposite directions between the same two wallets at the same time.
If Transfer A→B locks A first and Transfer B→A locks B first, and both run concurrently, each ends up waiting on a lock the other is holding — indefinitely. The fix doesn't touch the locking mechanism, just the order:
// Lock in consistent ID order to prevent deadlocks on concurrent
// opposing transfers between the same two wallets
const [firstId, secondId] = [senderWalletRaw.id, receiverWalletRaw.id].sort();
const firstWallet = await this.walletRepo.findByIdForUpdate(firstId, trx);
const secondWallet = await this.walletRepo.findByIdForUpdate(secondId, trx);
const senderWallet =
firstWallet.id === senderWalletRaw.id ? firstWallet : secondWallet;
const receiverWallet =
firstWallet.id === receiverWalletRaw.id ? firstWallet : secondWallet;Both wallets get locked in the same sorted order regardless of which one is sending and which is receiving — so Transfer A→B and Transfer B→A both try to lock the same wallet first, and the second one just waits its turn instead of deadlocking. Which wallet is "sender" and which is "receiver" only gets resolved after both locks are already held, so the role assignment can't influence lock order and reintroduce the bug it's meant to prevent.
The same amount-validation gate — reject anything non-positive or with more than two decimal places — runs before any of this, shared across fund, transfer, and withdraw:
private hasCentPrecision(amount: number): boolean {
return Math.round(amount * 100) / 100 === amount;
}
private validateAmount(amount: number): void {
if (amount <= 0 || !this.hasCentPrecision(amount)) {
throw new AppError(400, 'Amount must be a positive number with at most 2 decimal places');
}
}One validation function, called from three places, instead of three copies that could quietly drift apart from each other.
An operation either fully happens, or it never happened
Every money-moving call wraps its entire body in one db.transaction(). A transaction record gets inserted as PENDING first, the balance gets updated second, and the record gets flipped to SUCCESS last — all inside that same callback. The ordering looks like a state machine with an in-flight step, but the actual guarantee is simpler and stronger: if anything between the PENDING insert and the SUCCESS update throws — an insufficient-balance check, a database error, anything — Knex rolls back the whole transaction. The PENDING row doesn't get left behind for someone to find later. It just never existed.
That means balance checks always run before a transaction record gets created, not after:
if (senderWallet.balance < dto.amount) {
throw new AppError(400, 'Insufficient balance');
}
// only reachable if the check above passed —
// nothing gets written to the ledger for a rejected transfer
const txId = generateId();
await this.walletRepo.createTransaction(
{
/* ...status: 'PENDING' */
},
trx,
);A rejected transfer leaves no trace in the transactions table at all, which is the correct behavior for a ledger — it should record what happened, not what someone attempted and failed to do.
Fail closed when you can't get a clean answer
The Karma blacklist check has one job: confirm a user isn't on Lendsqr's own blacklist before letting them register a wallet. What it does when it can't confirm that is the more interesting design decision. A 404 from Adjutor means the identity genuinely wasn't found — not blacklisted, safe to proceed. Anything else that isn't a clean match — a timeout, a 5xx, a network failure — throws, and registration fails:
try {
const response = await axios.get(
`${this.baseUrl}/${encodeURIComponent(identity)}`,
{
headers: { Authorization: `Bearer ${env.ADJUTOR_API_KEY}` },
timeout: this.requestTimeoutMs,
},
);
const hasData = response.data && Object.keys(response.data).length > 0;
return hasData;
} catch (error: unknown) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return false; // genuinely not found — safe
}
// anything else: timeout, 5xx, malformed response — fail closed
throw new AppError(
503,
'Unable to verify user eligibility. Please try again later.',
);
}Adjutor's test-mode toggle wouldn't stay switched to live mode in the dashboard — it silently reverted right after being changed. In test mode, every identity check returns a 200 with an empty body, which is indistinguishable in shape from "this person isn't blacklisted." There was no way to exercise the actual rejection path against real data, only ever the clean-user path.
The response wasn't to loosen the check or assume it worked. An environment-gated bypass — active only when NODE_ENV=development, checked alongside a SKIP_KARMA_CHECK flag, structurally incapable of firing in production regardless of that flag's value — unblocked local development without touching the integration code itself. The Karma service above is the real implementation, complete and unmodified by the bypass. The bypass exists to work around a third-party dashboard that wouldn't do what it claimed to, not to skip writing the check correctly.
Outcome
Twenty-eight tests pass at the service layer, covering the paths that actually matter for a wallet service: insufficient balance, self-transfer rejection, sub-cent precision, duplicate registration racing against a database-level unique constraint, and the Karma service correctly propagating a 503 instead of silently letting a registration through. Every repository, the Karma service, and the database itself are mocked — a deliberate trade of end-to-end realism for determinism and speed, and a limitation the README states outright rather than leaves implicit. Auth middleware tests and full HTTP integration tests are the documented next layer, not a gap discovered later.
That same directness runs through the rest of what shipped. A "Known Limitations and Production Extensions" table sits in the README listing exactly where this implementation would need to change before handling real volume — parseFloat-based decimal arithmetic that should become a dedicated decimal library, a server-generated reference that should become a caller-supplied idempotency key, a faux JWT that should become asymmetric keys with refresh rotation. None of these are bugs. They're the honest boundary between "correct for an assessment scoped to four operations" and "correct for a production lending platform processing real transaction volume," stated plainly instead of left for someone else to discover.
The row-locking order, the all-or-nothing transaction wrapping, and the fail-closed Karma check are the parts of this system that would actually break under concurrent production load if they were wrong — and they're also the parts that are easiest to get subtly wrong without ever noticing in a single-user test run. Two transfers moving money between the same two wallets in opposite directions, at the same moment, is not a scenario a solo developer testing locally ever generates by accident. It's exactly the scenario a payments system meets on its first real day with more than one user, and it's the one this assessment was actually testing for underneath the stated requirements of "build four endpoints."
The role didn't come through. The wallet service still does exactly what it was built to do, and the discipline behind it — lock ordering that doesn't depend on which side of a transfer you're on, a ledger that only ever records what actually happened, a blacklist check that fails safe when it can't get a clean answer — isn't specific to one company's hiring process. It's the same discipline any system moving real money needs, regardless of who's asking for it.