Interview guides · Atominvest
Atominvest Software Engineer Interview Questions & Guide 2026
API design practice for Atominvest software engineer candidates: compatibility, versioning, and changes you cannot take back.
The 60-second brief
As a Software Engineer at Atominvest, you will play a pivotal role in shaping the technology that powers private markets. The company provides a sophisticated software platform designed for private market investors, focusing on areas like investor management and portfolio monitoring. Your work directly impacts how financial professionals manage complex data, track performance, and streamline their day-to-day operations.
Treat capacity estimation as a conversion skill rather than a table to memorise: turn a user count and an action rate into requests per second and bytes per day, then name the component that number breaks first. The figure only matters if it changes the design.
Focus areas
- Consume webhooks duplicated, delayed and out of order
- Model money movement as balanced double-entry postings
- Make every money-moving endpoint idempotent by key
Overview
This position requires a high degree of technical proficiency, particularly in full-stack development using JavaScript. You will be joining an engineering team that balances the need for robust, scalable architecture with the agility required to iterate on a fast-evolving SaaS product. Whether you are building new features for investor portals or optimizing data processing pipelines, your contributions will be central to the firm's ability to maintain its competitive edge in the fintech landscape.
Expect to work in a collaborative environment where cross-functional alignment is key. You will regularly engage with product managers and other stakeholders to translate complex business requirements into elegant, performant code. Success in this role demands not just strong coding capabilities, but also a proactive mindset and the ability to solve ambiguous problems in a high-stakes financial domain.
The Atominvest interview process
Atominvest candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.
Technical Screening
Most of the time lost in this format is not lost to thinking. It goes to a standard-library call you half-remember, an off-by-one in a loop bound, and a debugging loop that mutates code at random until something passes. When output is wrong, stop re-reading the whole function: take the smallest input that reproduces it and walk the state through by hand, printing intermediates if the environment allows. Guessing at a fix without a failing case you understand is how a five-minute bug becomes twenty, and the clock does not pause while you do it.
What to demonstrate
- Whether you reach the right structure without a detour, and can write it from memory rather than only recall that one exists
- Whether overflow is considered where the language has fixed-width integers, since a signed 32-bit value stops at 2,147,483,647 and then wraps in Java, is undefined behaviour in C++, and does not arise in Python, whose integers grow instead
- Whether recursion depth is treated as a constraint on large inputs, given that CPython's default limit is 1000 frames and a deep recursion can exhaust the stack in any language where an iterative version would not
- Whether a failing case is isolated and explained before any edit is made to the code
How to prepare
- From an empty file and with no references open, implement the pieces you lean on most: a heap push and pop, an iterative DFS with an explicit stack, and a binary search whose midpoint is written lo + (hi - lo) / 2, which avoids the overflow that (lo + hi) / 2 can hit in a fixed-width integer type
- Time yourself on the ten library calls you look up most, such as sorting with a custom comparator, splitting and joining strings, and finding the next key at or above a value in an ordered map, until the lookup is gone
- Take a solution you know is broken and, before touching it, write one sentence naming the input, the expected value and the actual value. Repeat until you do it without deciding to.
Deeper-Dive Interviews
When a round has no standard shape, it is often there because something is still open: an area no earlier conversation reached, a round where the signal came out mixed, or a decision someone is not ready to make alone. Work out which by going back over what each earlier round actually covered rather than how it felt, and arrive able to give evidence on that point without being asked twice. Weak answers replay the loop's earlier material at the same depth. Strong ones go a level deeper and stay consistent with what you already said.
What to demonstrate
- Whether your account of a project matches the one you gave earlier in the loop, since what you said before may be available to whoever runs this round
- Whether you can go a level deeper on something already covered, reaching the decision and its alternatives rather than repeating the summary
- Whether you state your own uncertainty accurately, including parts of a system you did not build and decisions you inherited, instead of claiming even ownership across all of it
- Whether you can answer a question you handled poorly earlier by naming what you missed, rather than delivering a polished second version as if the first had not happened
How to prepare
- Reconstruct the loop on one page: for each round, the questions you were asked and the answer you actually gave, not the better one you thought of afterwards. The gaps on that page are your best available guess at why this round exists.
- Take the two claims you made earlier that carry the most weight and assemble the backing for each: the measurement, the date, what broke, the decision you would make differently now.
- Write down the three facts about your work that must not drift between tellings, such as team size, timeline and your own role, and check your stories against that list rather than trusting recall under pressure
Engagement with Senior Engineers
An unlabelled round is first an information problem, and the cheapest information is free. Whoever schedules it can usually tell you how long it runs, who will be in the room and what they work on, whether you will be writing code and in what environment, and whether anything is being sent beforehand. Ask in writing so the answer is on record, then prepare for the two or three formats those answers still leave open instead of betting on one. What separates a strong candidate is not guessing right; it is having an opening that works whichever one it turns out to be.
What to demonstrate
- Whether you can start work from an ambiguous brief, since tolerating a vague scope without stalling is the same thing the job asks for
- Whether the questions you asked beforehand were ones that change your preparation, such as duration, medium and who is joining, rather than ones whose answers you could not have acted on
- Whether you adapt when the round turns out to be something other than what you were told, instead of spending the first ten minutes visibly recalibrating
How to prepare
- Send one short scheduling message asking four things: how long, who is joining and what they work on, whether you will be writing code and where, and whether to prepare anything in advance. Treat a vague reply as real information, since it means the round is loosely structured and you will be shaping it yourself.
- Write one opening that works in any of the formats still open: restate in your own words what you have been asked to do, then ask which of two directions is more useful to them. Say it aloud until it stops sounding recited.
- Set up for the two most likely formats before the call starts, with a blank editor in the language you would choose and a shared document you can type into, so a format surprise costs you nothing in the first minutes
Final Decision
Coding rounds mostly set a floor. They decide whether you clear the bar, not where you land on the ladder. Level tends to come out of the design discussion and the ownership stories, so the question worth auditing beforehand is whether the scope you describe matches the scope of the job. Work that stops at your own service, or a story whose hard part was writing the code rather than getting several people to agree on an interface, reads a level below where you think you are interviewing, and that gap is usually resolved downwards.
What to demonstrate
- Whether the largest thing you describe owning ran end to end — the decision, the migration path, the rollout, and what you did when it went wrong — or stopped at the change you merged
- Whether design answers include what you would not build, what you would defer, and what you would measure before committing, rather than only what the boxes are
- Whether a disagreement in a story was settled with something checkable — a benchmark, a prototype, a written proposal — instead of by seniority or by waiting it out
- Whether you can say which calls you made alone and which you escalated, and why the line sat where it did
How to prepare
- Write your largest piece of owned work as a timeline of decisions — who decided what, when, and what you did when the plan broke — then delete every sentence whose subject is "we" and see how much survives
- Take one system you know well and drill the migration answer: how old and new paths run side by side under live traffic, how you compare their outputs, what the rollback is once writes are going to both, and which step you would not automate
- Map each line of the ladder in the job posting to a specific thing you have done, find the line you cannot support, and prepare the closest evidence you have plus an honest account of the gap
Where candidates lose points
Assuming the default isolation level enforces the invariant you wrote down
PostgreSQL defaults to READ COMMITTED, where every statement takes a fresh snapshot, so a read-modify-write on a balance loses updates under concurrency. Its REPEATABLE READ is snapshot isolation, which blocks that particular anomaly by aborting the loser with SQLSTATE 40001 but still permits write skew across two different rows; only SERIALIZABLE closes that, and both levels therefore require a bounded retry loop on 40001 that many implementations simply never write. MySQL's InnoDB REPEATABLE READ behaves differently again — it does not abort on a conflicting write, so the identical application code silently changes behaviour when the engine changes. Two-sided transfers add a second failure mode on top: without a deterministic lock ordering, such as always locking account ids in ascending order, concurrent opposing transfers deadlock (SQLSTATE 40P01).
Retrying a charge after a timeout
A timeout is not a failure; it is an unknown outcome, and the request may have been processed in full with only the response lost. Re-sending it without an idempotency key that the processor itself honours produces a duplicate charge, which is a customer-visible incident and usually a dispute. The correct handling is to treat the state as unknown, query the processor for that key or client reference, and only then decide. The mechanism also depends on the key being generated once by the caller and reused across every attempt — generating a fresh key per retry turns the whole scheme into a no-op while leaving all the code that appears to implement it in place.
Trusting input because it came from your own front end
Anything crossing a trust boundary is hostile: parameterise queries instead of building SQL by concatenation, validate against an allow-list rather than a deny-list, and bound the size of anything you allocate from a request. Raising this unprompted in an API or design question is a cheap and unusually strong signal.
Listing technologies instead of trade-offs
Name the property the design needs first, such as ordered range scans, multi-entity transactions, cheap appends, or a predictable p99, then pick something that provides it and say what it gives up in exchange. Almost any component is defensible once you state the requirement it satisfies and the one it sacrifices.
Questions & practice
Parse settlement amounts into minor units without floating point
Easy · parsing · money representation · iso-4217
A settlement file carries amounts as text: 1234.56, -0.07, 1,234.5, 1234, 12 345,67, and for three-exponent currencies 1.234. Each line also carries an ISO 4217 alphabetic code and the file's sign convention. Write the function converting one amount string plus its currency into a signed amount_minor int64, scaling by the currency's ISO 4217 exponent — 0 for JPY and KRW, 2 for USD and EUR, 3 for KWD and BHD. Reject anything not convertible exactly, with a typed reason. Never raise, never use a float.
Approach
- Fix the contract first: return a result union of
Ok(int64)orErr(reason)with reason drawn fromunknown_currency,malformed,too_many_fraction_digits,overflow. A parser on this path that throws turns one bad line into a failed fifty-million-line run. - Decide which character is the decimal mark from the file's format spec, not from a heuristic on the last separator, then strip grouping separators.
1,234is genuinely ambiguous between 1234 and 1.234 exactly when the exponent is 3, so an unstated spec is a reject, not a guess. - Split on the decimal mark. Right-pad the fraction with zeros to the currency's exponent e; if it is longer than e and any dropped digit is non-zero, return
too_many_fraction_digits. Silently truncating is how half a minor unit becomes a reconciliation break nobody can source. - Build the integer by digit accumulation on int64 with checked multiply and add, or equivalently concatenate the integer and padded-fraction substrings and parse once. Either way the value never passes through a float.
- Apply the sign last, from the convention the file declares — trailing
CR/DR, a trailing minus as in1234.56-, or a leading minus. Assuming a leading minus silently flips every credit in a file that uses the trailing form. - Complexity is O(len) per amount with no allocation beyond a digit buffer, so the whole file is one O(total bytes) pass.
Follow-up
- Exponent 4 exists (CLF). Does the exponent come from a compiled-in table or from the file header, and what happens when the two disagree?
- A line has more fraction digits than the exponent allows. Is that a reject, a round, or a break — and who makes that call?
- How do you fuzz this so that every failure is a typed rejection rather than a wrong number?
Derive per-account balances and catch unbalanced transactions
Easy · aggregation · double-entry · streaming
You are given ledger_entry rows streamed in entry_id order: transaction_id, account_id, direction (debit or credit), amount_minor (a positive int64), currency, business_date. Up to 500 million rows, at most 20 million distinct (account_id, currency) pairs, and the entries of one transaction are contiguous in the stream. In a single pass with no re-reads, return the closing balance per (account_id, currency) and the transaction_id of every transaction whose entries do not sum to zero within each currency. State your time and space bounds.
Approach
- Normalise the sign at read time from
direction, not from the amount:signed = +amount_minorfor debit,-amount_minorfor credit (state which convention you picked). The schema constrainsamount_minor > 0precisely so the sign lives in exactly one place. - Hold one hash map keyed
(account_id, currency)to an int64 running total. Twenty million keys at 16 bytes of payload plus map overhead is order 1 GB in most runtimes — quote the number, and offer the fallback: partition the stream byhash(account_id) % Pand run P passes for 1/P of the memory. - Ride the zero-sum check on the same pass. Because a transaction's entries are contiguous, keep a tiny
currency -> int64map for the currenttransaction_idonly, test it against zero on the boundary and at EOF, then clear it. That is O(currencies in one transaction), typically one or two. - Bound the arithmetic explicitly. Int64 holds about 9.22e18, so overflowing one account across 500 million entries needs an average of 1.8e10 minor units per entry — safe here, but use a checked add so an adversarial file fails loudly rather than wrapping.
- Complexity: O(n) time, O(distinct account-currency pairs) space, one sequential pass, no sort. The zero-sum check adds no asymptotic cost, which is the argument for doing it here rather than in a second job.
Follow-up
- Entries of a transaction are no longer contiguous. What does the zero-sum check cost now, and which is cheaper: buffering open transactions or an external sort on
transaction_id? - How would you produce the same balances as of an arbitrary
business_datewithout a second full scan? - The job is restarted after a crash halfway through the file. What makes the second run produce identical output?
Rank merchants by unmatched break value in one streamed pass
Medium · top-k · heap · streaming · space-bounded
A reconciliation run streams up to 50 million break rows of (merchant_id, currency, delta_minor signed, break_type). Return the 50 merchants with the largest total absolute delta_minor in a single currency, from one pass, with memory that does not grow with merchant count — there are up to 8 million distinct merchants and room for roughly 200,000 counters. Give both an exact and an approximate design, state the error bound the approximate one actually guarantees, and say which you would put in the nightly job.
Approach
- Start by testing whether the constraint is real. Exact needs one hash map
merchant_id -> int64plus a size-50 min-heap: O(n) to aggregate, O(m log 50) to rank, O(m) space. At 8 million merchants and 16 bytes of payload that is a few hundred megabytes — quote the number before reaching for a sketch. - If it genuinely does not fit, exact still costs only two passes or an external group-by: partition by
hash(merchant_id) % Pto disk, aggregate each partition independently, then merge with a K-way heap. This is the answer whenever exactness is non-negotiable, which for money it usually is. - One-pass approximate: weighted Space-Saving with C counters. An item either hits an existing counter or evicts the minimum one and inherits its value as an over-estimate. With total weight W and C counters, every reported total over-estimates by at most W/C, and any merchant whose true total exceeds W/C is guaranteed to be in the table. Quote W/200,000 as a fraction of the day's break value.
- State what the bound does not give you: the ordering within the top 50 is not guaranteed, and a merchant just below the threshold can be missed entirely. The honest claim to an operations reader is 'contains everyone above 0.0005% of today's break value', not 'the top 50'.
- Decide the metric before either design, because
sum(abs(delta))andabs(sum(delta))are different questions: a merchant with offsetting +1,000,000 and -1,000,000 breaks is top-ranked under the first and invisible under the second. Break work wants the first. - Recommend exact (two-pass or external group-by) for the nightly job — it runs once, has hours of budget, and an analyst acts on its output. Keep the sketch for a live dashboard, where a cheap bounded approximation beats an exact number that is four hours stale.
Worked solution (25 min)
- Fix the metric in writing as
sum(abs(delta_minor))per(merchant_id, currency), because that is what an analyst works from. - Implement exact: hash-map aggregate, then a size-50 min-heap — push while the heap is under 50, then push-pop only when an item beats the root.
- Implement weighted Space-Saving with 200,000 counters over the same stream.
- Generate 50 million rows with a Zipfian merchant distribution near s = 1.1 so a few merchants dominate, then generate a uniform variant for contrast.
- Compare: how many of the exact top 50 the sketch recovers, and the largest observed over-estimate, against the W/C bound.
Expected result: On the Zipfian stream the sketch recovers all 50 and every reported total over-estimates by at most W/200,000. On the uniform stream it recovers materially fewer — which is the result that tells you the bound, not the benchmark, is what you may promise a reader.
Verify it yourself
- Exact top-50 via the heap equals exact top-50 via a full sort on a 1-million-row fixture.
- Every sketch total is greater than or equal to the true total and never less; an under-estimate means the counter-inheritance step is wrong.
- The largest observed over-estimate is at or below W/C on every run.
- Heap memory stays at 50 entries regardless of input size.
Follow-up
- Totals are now needed per currency as well as overall. What does that do to the key and to the counter budget?
- Prove the Space-Saving over-estimate bound to me in two sentences.
- The nightly job restarts halfway. Is the aggregate idempotent, and what does the heap do with a partially consumed stream?
Decide whether balance is derived or materialised, then hold the floor
Hard · denormalisation · isolation levels · lock contention
Authorisation needs an account's available balance inside an 80 ms budget at 3,000 requests per second; that account already has 200 million ledger_entry rows. A product rule says the balance may never fall below the account's negative overdraft limit. Decide whether the balance is summed from entries or held in a materialised account_balance(account_id, balance_minor, floor_minor, currency, version) row, and justify the choice from the read pattern. Then give the write path that holds the floor, naming the isolation level, the anomaly a weaker level permits, and the SQLSTATE you retry.
Approach
- Size the derived read before arguing about it: summing 200 million rows is not an 80 ms operation under any index, since even a covering index on (account_id, entry_id) still reads work proportional to the rows. The authorisation read pattern forces one materialised row fetched by primary key; the statement read pattern, which is low-rate and historical, stays derived from entries. That is the whole justification for the denormalisation, and its price is a write on that row per posting.
- Put the floor where it can be a single-row constraint: CHECK (balance_minor >= floor_minor) on account_balance, updated in the same transaction as the entries. As a predicate over a set of entry rows it cannot be a CHECK at all, which is the reason the materialised row earns its keep twice.
- Write the mutation as one statement: UPDATE account_balance SET balance_minor = balance_minor - $1, version = version + 1 WHERE account_id = $2 AND balance_minor - $1 >= floor_minor. Zero rows updated means refused. This is safe even under READ COMMITTED, because the UPDATE re-evaluates its predicate against the locked, post-update version of the row.
- Name the anomaly in the shape that is not safe: SELECT the balance, compute a new value in application code, then UPDATE to that constant. Every statement under READ COMMITTED takes a fresh snapshot, so two concurrent 60-unit withdrawals against 100 both read 100 and both write 40. The row then claims 40 while 120 has actually left, so the true position is -20, below a floor of 0, and the row it is checked against cannot show it. PostgreSQL REPEATABLE READ is snapshot isolation and aborts the loser with SQLSTATE 40001; SERIALIZABLE additionally closes write skew across two rows. Both require a bounded retry with backoff, and InnoDB REPEATABLE READ does not abort at all, so identical code changes behaviour on a different engine.
- For a two-account transfer, acquire the rows in a deterministic order such as ascending account_id; without it, opposing concurrent transfers deadlock and the database kills one with SQLSTATE 40P01. That is a retry, not a correctness failure, but it is a retry somebody has to write.
- Finish on the ceiling: the hot row admits one committed write per lock hold, so at a 2 ms hold it caps near 500 per second regardless of cores. Sharding into N sub-rows multiplies throughput and immediately makes the floor check cross-row again, which then needs the shard sum under SERIALIZABLE or a per-shard reserved allowance.
Follow-up
- Shard the balance into eight sub-rows. Write exactly what the floor check now does, and what it costs per authorisation.
- Someone proposes an AFTER INSERT trigger on ledger_entry to maintain the balance. What does that change about ordering, about batch posting, and about failure handling?
- How do you detect that the materialised row has drifted from the entries, how often do you run it, and on which replica?
Add and backfill business_date on a live ledger table
Hard · online migration · lock queue · backfill
ledger_entry holds 4 billion rows, is append-only, takes 10,000 inserts per second, and every reporting query currently derives the business date as posted_at::date. You must add business_date date NOT NULL, populated from the cutoff rule (17:00 in the account's own timezone), backfilled across all history, indexed, and cut over, with no write downtime and no long-held lock. Give the ordered migration steps with the lock each one takes, how you make the backfill restartable and throttled, and how you retire the old expression safely.
Approach
- Add the column nullable and with no default. ALTER TABLE ... ADD COLUMN takes ACCESS EXCLUSIVE but is a catalogue-only change held for microseconds. The hazard is the lock queue, not the statement: a blocked ALTER waits behind one long reader holding ACCESS SHARE, and every query arriving afterwards queues behind the ALTER's pending ACCESS EXCLUSIVE, so set lock_timeout to a couple of seconds and retry rather than letting a metadata change take the table down.
- Deploy the write path before the backfill, so new inserts populate business_date from the cutoff rule while reads stay on the old expression. The backfill then chases a closed set with a fixed upper bound instead of a moving target.
- Backfill in bounded batches keyed by entry_id range, on the order of 50,000 rows per statement, committing between batches and recording the high-water mark in its own table so a killed run resumes instead of restarting. WHERE business_date IS NULL makes each batch idempotent, and the pacing is set by replica lag and dead-tuple growth rather than CPU, since each UPDATE writes a new row version and the WAL volume is proportional to the rows touched.
- Install the constraint without a blocking scan: ALTER TABLE ... ADD CONSTRAINT ck_business_date CHECK (business_date IS NOT NULL) NOT VALID takes a brief ACCESS EXCLUSIVE and scans nothing, then VALIDATE CONSTRAINT takes SHARE UPDATE EXCLUSIVE and runs alongside reads and writes. On PostgreSQL 12 and later, SET NOT NULL can then use the validated CHECK and skip its own full scan; on 11 and earlier it always scans, so the CHECK is the migration on those versions.
- Build the index with CREATE INDEX CONCURRENTLY, which avoids ACCESS EXCLUSIVE at the cost of two table passes, cannot run inside a transaction block, and on failure leaves an INVALID index that must be dropped and rebuilt rather than reused.
- Cut over behind a flag: run the new and old expressions side by side for one reporting cycle and compare totals per day, since the cutoff rule will legitimately move entries near 17:00 across the boundary. Only once they reconcile do you retire the posted_at::date expression index, and you keep posted_at as the ordering key rather than repurposing it.
Worked solution (40 min)
- Write the migration as numbered SQL statements, annotating each with its lock mode and expected duration, and set lock_timeout plus a retry around every ALTER.
- Rehearse on a 10-million-row copy under a concurrent insert load, measuring per-batch duration, WAL generated and replica lag.
- Kill the backfill at a random point, restart it, and confirm it resumes from the high-water mark.
- Add the CHECK as NOT VALID, VALIDATE it while inserts continue, then SET NOT NULL and build the index CONCURRENTLY.
- Run old and new date expressions side by side for one cycle and diff the daily totals before dropping the old expression index.
Expected result: A migration in which no statement holds ACCESS EXCLUSIVE for more than a few milliseconds, every batch is bounded and resumable, VALIDATE and CREATE INDEX CONCURRENTLY run while writes continue, and the only rows whose daily bucket changes are those between 17:00 local and midnight UTC on their posting day.
Verify it yourself
- SELECT count(*) FROM ledger_entry WHERE business_date IS NULL reaches exactly zero before the constraint is validated.
- During the backfill, an INSERT from another session returns in single-digit milliseconds and no session waits on a relation lock longer than lock_timeout.
- After a kill and restart, the count of rows updated equals the row count exactly once, with no batch re-applied and none skipped.
- pg_index.indisvalid is true for the new index; an INVALID index means the CONCURRENTLY build failed and must be dropped, not left in place.
Follow-up
- Reporting now wants ledger_entry partitioned by business_date. Why can this not be another ALTER, and what is the migration instead?
- Nightly totals move for the days around the cutoff change. How do you tell a correct restatement from a backfill bug?
- The backfill is halfway done when a replica falls 20 minutes behind. What do you throttle, and what do you refuse to throttle?
How would you design a robust API for a private market portfolio monitoring tool?
Medium · system design and architecture
Approach
- Fix the scope first: who calls this, how often, and what they do when it fails.
- Choose a partition key and say what query it makes expensive.
- Name the read and write paths separately; they rarely have the same bottleneck.
Follow-up
- What breaks first when traffic grows ten times?
- How does this behave when that dependency is down for an hour?
What considerations do you make when integrating third-party financial data feeds?
Medium · system design and architecture
Approach
- Name the read and write paths separately; they rarely have the same bottleneck.
- Fix the scope first: who calls this, how often, and what they do when it fails.
- Name the failure you are designing for, then the recovery path.
Follow-up
- What would you drop to keep the system up under load?
- What breaks first when traffic grows ten times?
How do you approach debugging performance bottlenecks in a browser-based environment?
Medium
Approach
- Work from the requirement backwards to the design.
- Clarify what is being asked and what a complete answer contains.
- Say what you would check first and why it is the highest-information step.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
How do you ensure your code is unit-tested and performant before it reaches production?
Medium
Approach
- Say what you would check first and why it is the highest-information step.
- Clarify what is being asked and what a complete answer contains.
- Work from the requirement backwards to the design.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Raise the write ceiling on one hot clearing account
Hard · lock contention · isolation levels · sharding · write-heavy
The ledger posts balanced entry sets at 10,000 entries/s into append-only ledger_entry(entry_id, transaction_id, account_id, direction, amount_minor>0, currency, source_type, source_id, business_date, posted_at), roughly three entries per transaction. A materialised row per account carries the floor check available_minor >= floor_minor and is updated in the posting transaction. One pooled clearing account appears in 40 percent of transactions. Compute the throughput ceiling on that one row, then raise it. Name the isolation level each version requires and state precisely what sharding the balance costs the floor check.
Approach
- Compute the ceiling before designing anything. Throughput against a single row is one divided by the lock hold time, where the hold runs from first acquiring the row to commit, including the WAL flush. At a 2 ms hold that is about 500 commits/s on that row, and adding cores does not move it. Demand is 10,000 entries/s at three entries per transaction, so roughly 3,300 transactions/s, of which 40 percent touch this account: about 1,300/s against a 500/s ceiling. You are 2.6x over before any code exists.
- Shorten the hold before sharding, because it is free. Acquire the hot row last in the transaction and hold it for exactly one statement: UPDATE balance SET available_minor = available_minor - $1 WHERE account_id = $2 AND available_minor - $1 >= floor_minor. This is correct at READ COMMITTED, because an UPDATE that blocks on a concurrent writer re-evaluates its WHERE clause against the newly committed row version rather than its original snapshot.
- Name what the broken version needs. SELECT the balance, compute in application code, then UPDATE loses updates at READ COMMITTED, since the second statement takes a fresh snapshot. Making that shape safe requires REPEATABLE READ or SERIALIZABLE plus a bounded retry loop on SQLSTATE 40001, which is the part most implementations omit; SELECT ... FOR UPDATE also works but is simply the same lock held for longer. Note that the identical application code changes behaviour on a different engine: InnoDB's REPEATABLE READ does not abort the loser.
- Order locks deterministically on any two-account movement, always ascending by account_id. Concurrent opposing transfers without that ordering deadlock with SQLSTATE 40P01, and the deadlock rate rises with exactly the concurrency you added to gain throughput.
- Shard the hot balance into N sub-rows keyed (account_id, shard_no), writers choosing a shard by hash of transaction_id. Per-shard hold time is unchanged, so capacity rises roughly N-fold, and at N=16 you clear 1,300/s with headroom. The cost is precise: the floor is now a predicate over the sum of N rows and is no longer expressible as a single-row CHECK, so it must be paid for deliberately. Either give each shard its own floor, which rejects postings while other shards hold headroom, or add a slow path that locks all shards in ascending shard_no under SERIALIZABLE, rebalances and retries on 40001. For a clearing account whose floor is effectively unreachable, dropping the per-shard floor and enforcing it as a monitored invariant is a defensible stated choice; for a customer deposit account it is not.
- Keep the entries immutable throughout: no UPDATE or DELETE grant on ledger_entry plus a trigger, corrections as reversing transactions, and partitioning by business_date so index writes stay in the current partition and retention is a DETACH.
Worked solution (45 min)
- Run two concurrent sessions doing SELECT then UPDATE to a computed value against the same account under READ COMMITTED and observe one update lost.
- Replace it with the single atomic conditional UPDATE and re-run; then run the original read-modify-write under REPEATABLE READ with a bounded retry on 40001.
- Measure committed transactions per second against the single pooled row at 1, 8, 32 and 128 concurrent writers, recording mean lock hold time.
- Shard into 16 sub-rows hashed on transaction_id, re-measure, and write the query that answers the true available balance.
- Run concurrent opposing two-account transfers with and without ascending account_id lock ordering.
Expected result: The read-modify-write loses an update at READ COMMITTED and aborts with 40001 at REPEATABLE READ; the single atomic statement is correct at READ COMMITTED with no retry loop. Single-row throughput flattens near one divided by the lock hold time, about 500 commits/s at a 2 ms hold, against the roughly 1,300 transactions/s this account needs. Sixteen shards clear that, and the floor check becomes a predicate over sixteen rows. Unordered transfers produce 40P01 deadlocks; ascending order produces none.
Verify it yourself
- Past the plateau, adding writers raises latency without raising throughput; that is the ceiling, not a tuning problem.
- After sharding, a floor breach is reproducible when each shard enforces the floor independently; construct it deliberately rather than assuming it cannot happen.
- ledger_entry has no UPDATE or DELETE path throughout, and every correction in the test appears as a new reversing transaction.
Follow-up
- While the balance is sharded, what query answers the account's true available balance, and what does it cost?
- A transaction has legs in two currencies. What does the balanced-to-zero check mean now, and where does the rounding residual go?
- What does the nightly reconciliation compare the sharded balance against?
Approval rate collapses when one feature-store shard fails
Medium · cache stampede · partial outage · bulkhead · retry amplification
The risk decision service answers about 3,000 authorisations per second within an 80 ms p99, reading features from an eight-shard store behind a local cache with a fixed 60-second TTL, a 2-second client timeout and two retries on timeout. One shard becomes unreachable at 14:02. Approval rate falls from 96% to 71% within 90 seconds, and the seven healthy shards show p99 rising to 900 ms with normal error rates. Diagnose in order, name the cause of the approval drop, and state the degraded behaviour this service should have had.
Approach
- Establish what declined actually means here before touching the cache. Split the rate into approve, decline, refer and timeout. If decline counts are flat and timeouts account for the whole 25-point drop, the model is not declining anything; it is failing to answer, and something downstream is reading silence as a decline. That reading is the first finding and the most expensive one.
- Break the store's metrics out per shard. One shard at 100% errors and seven at elevated latency with baseline error rates is the signature of queueing, not of seven shards failing. Healthy dependencies with healthy error rates and bad latency are almost always victims of a shared resource.
- Follow the resource. A single connection or thread pool shared across shards means requests to the dead shard occupy slots for the full 2-second client timeout, which is 25 times the whole p99 budget, and with two retries each such request can hold a slot for 6 seconds. Confirm by comparing pool acquisition wait against per-shard service time: if wait dominates, the pool is the bottleneck.
- Quantify the amplification. Two retries on timeout offer 3x load, and an unjittered 60-second TTL expires a warm set together, so misses arrive as a synchronised wave rather than spread across the window. Retrying a timeout is only correct when the failure is likely transient; against a hard-down shard it is pure amplification and needs a circuit breaker to gate it.
- Fix the three separate defects separately: per-shard pools so one shard cannot starve seven, a breaker that stops retrying a shard that is failing, and per-key TTL jitter with single-flight refresh so N concurrent misses for one key produce one upstream read.
- Write down the degraded decision as a product decision, not a timeout handler's accident. With features unavailable the service must return a stated answer, such as deterministic rules alone below an amount threshold and refer above it, and must record that the decision was degraded so it is auditable and reproducible from stored inputs.
Follow-up
- Approving on deterministic rules alone during a feature outage carries a fraud cost. How do you size the amount threshold, and who signs it off?
- What should the orchestrator do with a risk timeout, given it cannot distinguish one from a decline?
- Does the circuit breaker fix the healthy shards' latency, or is the bulkhead doing that work? Which would you ship first if you could only ship one?
Suggested practice checklist
Day one measures instead of guessing, under a fixed rubric, and the remaining hours are allocated in proportion to the gaps before any studying begins. The allocation is deliberately not renegotiated midweek, because the area that feels worst on day three is usually the one that is moving.
Day 1 — Diagnostic, scored before you study anything
- Sit a 110-minute diagnostic in four blocks: forty-five minutes on two coding problems, twenty-five on one design prompt taken to interface and data model, twenty of short-answer fundamentals, and twenty delivering two behavioural answers aloud.
- Score each block from 0 to 3 on a fixed rubric where 3 is correct and fluent, 2 is correct but slow or prompted, 1 is partially correct and 0 is stuck, grading the artifact rather than how the attempt felt.
- Allocate days two to five in proportion to 3 minus each block's score, write the allocation down, and commit to leaving it alone.
Deliverable: A scored rubric and a fixed hour allocation for the rest of the week.
Day 2 — Largest gap: find the boundary rather than the subject
- Split the weakest area into named sub-skills and rate each separately. For coding those are restating the problem, choosing the structure, stating the invariant, turning the invariant into loop bounds, handling empty and single-element input, and accounting for complexity out loud.
- Attempt three items positioned just above where the rating drops off, and for each write the first move you failed to make.
- Re-attempt one of them from blank four hours later with nothing open.
Deliverable: A sub-skill map with the two blocking sub-skills circled.
Day 3 — Drill the blocking sub-skill by repeating the shape
- Do eight short repetitions of the same shape rather than eight different problems, so what gets practised is the pattern and not the puzzle.
- State the rule you now hold in one sentence, then test it against a case built to break it, a sliding window over an array containing negative values, or a cache-aside read path whose invalidation message is dropped.
- Have someone else read your one-sentence rule and find the precondition you left out.
Deliverable: One rule statement with its preconditions attached and one counterexample that would have caught the incomplete version.
Day 4 — Second gap, plus maintenance on the strongest area
- Run the same sub-skill decomposition on the second-largest gap in half the time.
- Spend twenty-five timed minutes on the block you scored highest, choosing the hardest item you can still finish rather than a warm-up.
- Write whether each area fails you on recall, on setup, or on execution, and set the fix accordingly: repetition for recall, a written checklist for setup, timed work for execution.
Deliverable: A second sub-skill map plus a one-line failure diagnosis for each area.
Day 5 — The gap that is not a skill
- Record one technical and one behavioural answer, then count two things in the playback: seconds before your first clarifying question, and sentences you began without knowing where they would end.
- Practise saying that you do not know, followed by how you would find out, without letting it soften into a guess, and practise stating a complexity or an estimate before being asked for it.
- Redeliver one answer under a hard ninety-second cap, which forces structure ahead of detail.
Deliverable: Two recordings with a counted reduction in time-to-first-question.
Day 6 — Retest under day-one conditions
- Sit the same 110-minute structure with new prompts of comparable difficulty and score it on the identical rubric.
- For any block that did not move, change the method rather than adding hours: a block stuck at 1 usually means the practice was too varied, not too short.
- Write down which single block you would still lose the offer on.
Deliverable: A second scored rubric placed beside the first, with one named remaining risk.
Day 7 — Full loop under interview conditions
- Run a sixty-minute mock over the two blocks that moved least, with an interviewer briefed to interrupt and change direction mid-answer.
- Write the recovery script for going blank: restate the question, state your assumption, name the first thing you would check.
- Say every rule from the week aloud without reading it, and cut any you cannot state in a single sentence, since a rule you have to reconstruct mid-answer will not survive an interruption.
Deliverable: A one-page card holding the recovery script and only the rules you could state from memory.
The estimate and the day you knew
A slipped date is only a bad story if you sat on it. What matters is what you believed when you gave the number, the signal that told you it was wrong, how many days passed before you said so, and what you cut rather than asking for more time. Scope you defended counts as much as scope you dropped.
How do you handle disagreements within an engineering team regarding technical direction?
Medium · behavioural and engineering judgement
Approach
- Close with what you would do differently, concretely.
- Pick a story where you made the decision, not one where you watched it.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- What did you decide not to do, and why?
- How did you know your change caused the improvement?
How do you handle data consistency in a distributed system?
Medium · behavioural and engineering judgement
Approach
- Close with what you would do differently, concretely.
- State the situation in two sentences and spend the rest on the reasoning.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?
Describe a time you had to refactor a large piece of legacy code to improve scalability.
Medium · behavioural and engineering judgement
Approach
- Name the disagreement and how you resolved it with evidence.
- Give the blast radius: what could have broken, and what you measured.
- Close with what you would do differently, concretely.
Follow-up
- What did you decide not to do, and why?
- What would you do differently if you ran that again?