cswork / libraryOpen practice workspace

Interview guides · CPP Investments | Investissements RPC

CPP Investments | Investissements RPC Software Engineer Interview Questions & Guide 2026

System design practice for CPP Investments | Investissements RPC software engineer candidates: reach a data model and an API contract, not a box diagram.

The 60-second brief

As a Software Engineer at CPP Investments | Investissements RPC, you are not just writing code; you are building the technological infrastructure that supports one of the world’s most sophisticated institutional investors. Your work directly impacts the management of the Canada Pension Plan Fund, ensuring that millions of Canadians have a secure financial future. Whether you are working on Total Fund Management (TFM), Risk Analytics, or AI & Emerging Technologies, your contributions underpin high-stakes financial decisions that require extreme precision, reliability, and scale.

Ask how many rounds there are and what each one is before you plan your weeks, because the composition is what your hours should follow. A loop with three coding rounds and one short design conversation deserves a different split from the reverse, and whoever schedules it will usually say plainly which it is.

Focus areas

  • Name the isolation level each invariant requires
  • Store money as integer minor units
  • Reconcile the ledger against processor settlement files

Overview

The environment is intellectually demanding and highly collaborative. You will engage with complex financial models, large-scale data pipelines, and cutting-edge machine learning applications. Success in this role requires a unique blend of technical mastery and an interest in the financial domain. You will work alongside investment professionals to translate complex business requirements into elegant, performant, and scalable software solutions.

The CPP Investments | Investissements RPC interview process

CPP Investments | Investissements RPC candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.

Technical Screening

What this round decides is narrow: whether you can produce code that runs and is correct on inputs nobody showed you. An elegant solution that does not compile scores below a plain one that does, so write a correct brute force first, say out loud that you know its cost, and improve it with the working version still on screen. What separates strong answers is who finds the broken case. Trace your own code against an empty input, a single element, and duplicate keys before you say you are finished, because being told is far more expensive than noticing.

What to demonstrate

  • Whether degenerate inputs get checked without being asked for: an empty collection, one element, every element equal, and the extreme value the input type allows
  • Whether the complexity you state matches the code you actually wrote, including a sort or a copy sitting inside a loop
  • Whether the finished answer is verified against the worked examples before you call it done, rather than assumed correct because the code reads correctly

How to prepare

  • Take five problems you have already solved and, without running anything, write down what each returns for empty input, a single element, and all-duplicates. Then run them and count how many you predicted wrong.
  • Drill the brute force as its own skill: on ten problems, write only the obviously-correct slow version and time how long it takes to get it passing. If that is more than a few minutes, that is what to practise, not the optimal version.
  • Add a fixed last step before you submit anything, reading only the loop bounds and the initial value of each accumulator, which is where most off-by-one errors live

System Design Interview

You cannot drill a format you do not know, so put the preparation into material that travels. Three pieces of your own work, each rehearsed until you can take a follow-up you did not anticipate, will carry a conversation or a code walkthrough equally well. Specificity is what separates that from filler. A number needs its definition before it means anything: a p99 is over some window and measured at some hop, and a server-side figure excludes the queueing and network time a client would see. The number you cannot qualify is the one to leave out.

What to demonstrate

  • Whether your examples carry detail only someone who did the work would hold, such as what the binding constraint actually was, which alternative you rejected and why it was worse, and what you measured on each side of the change
  • Whether a number survives one follow-up, meaning you can say what it was measured over and whether it moved because of your change or merely alongside it
  • Whether a failure is described with the specific change that followed it, rather than a lesson stated in general terms
  • Whether your part in a team effort is stated accurately, including what other people did

How to prepare

  • Write a page on each of three projects covering the constraint, the option you rejected, the measurement before and after, and what went wrong. Cut any line you cannot take a follow-up on, since you are writing the parts you will be pressed on rather than a summary.
  • Recover the real figures while you still have access: request volume, data size, latency with its percentile and window, team size, timeline. Note where each came from, whether a dashboard, a design document or memory, and mark the estimates so you can say which they are out loud.
  • Take your weakest project story to someone who works in a different area and have them ask why four times in succession. The point where you run out of answer is the part to go and re-read before the round.

Deep-Dive Technical Discussions

The same problem is scored by two different mechanisms depending on the format, and preparing for one does not cover the other. With a person watching, partial progress is visible and a hint is a correction you can absorb; silence is the expensive failure, because nobody can read a half-written function. With an automated grader there is no partial credit for what you were about to do, nobody to ask, and the worked examples in the prompt are the entire specification. Read them as a contract, down to whether an empty result should be an empty list or no output at all.

What to demonstrate

  • In a live session, whether your commentary tracks what your hands are doing, and whether a hint redirects you or gets defended against
  • In an automated one, whether you cover the cases the examples do not show, since the hidden cases are where the score moves
  • Whether you manage the clock on purpose: abandoning an approach that is not converging while there is still time to write something simpler that finishes

How to prepare

  • Have someone hand you a problem and feed you one deliberately wrong hint. Practise testing it against a concrete case instead of accepting or rejecting it on authority.
  • Do one timed run a week in a plain browser editor with autocomplete, linting and your own snippets switched off, which is closer to what these environments give you
  • For the automated format, write the harness before the solution: a main that feeds the worked examples plus an empty and a single-element case and prints expected against actual, so a wrong submission is caught by you first

Behavioral Assessments

Many of these questions are about something that went wrong, and the grading sits mostly in the hours after you knew. Who found out first, whether that was you or an alert or a user, how long it took you to say it out loud, and whether the people who needed the news got it while they could still act on it. Engineers under-tell this part because it feels like confessing. The pattern it is looking for is the opposite: the quiet fix, an incident absorbed without telling anyone, after which nothing changed and the same failure is still available.

What to demonstrate

  • How the problem was found, and whether that route was one you had built or one that happened to you, since a user reporting it first means your instrumentation did not cover that failure
  • Whether time-to-detect and time-to-tell are separate numbers in your account and whether you know both, because a fast fix that nobody heard about until the retro is a different answer from a slow one that was announced immediately
  • Whether the resolution left something durable behind, a check that fires or a default that changed, rather than depending on people remembering to be careful
  • Whether you can say what the failure cost without either inflating it or waving it away

How to prepare

  • Reconstruct one incident you were part of as a timeline with clock times: first bad request, first signal, first person who knew, first message outside the team, mitigation, permanent fix. The gaps between those entries are what gets asked about
  • Look up the configuration of the signal that caught it, including its evaluation window and threshold. An alert defined on a five-minute aggregate cannot fire until the condition holds across that window, which puts a floor under time-to-detect that has nothing to do with how severe the failure was. Be able to say what that floor was and whether anyone had chosen it deliberately
  • Prepare one story where you escalated early and the severity turned out to be smaller than you thought, including what it cost the people you pulled in. Without it, every answer you give about raising alarms is unfalsifiable

Where candidates lose points

Deriving the business date from the UTC timestamp

Posting date, value date and the processor's settlement date are three different dates, determined by cutoff times, business-day calendars and holidays rather than by midnight UTC. A movement recorded at 23:50 on one side of a cutoff belongs to the next business date, so computing business_date as created_at::date makes daily totals disagree with every statement and every settlement file. The signature is a reconciliation break that resolves itself the following day and then reopens, which reads like a flaky job and is actually a data model that is missing a column: business_date has to be stored explicitly and set from the cutoff rule, with the timestamptz kept separately for ordering.

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.

Quoting amortised or average cost as if it were a worst-case guarantee

Appending to a dynamic array is amortised O(1), but the append that triggers a resize copies every element, and hash lookup is constant only while the hash spreads the actual keys. Say which guarantee you are offering when the caller cares about the latency of one call rather than the total over many.

Reading the constraints as preamble rather than as part of the problem

The bounds are usually there to eliminate the obvious approach: n up to 10^5 makes an O(n^2) scan roughly 10^10 operations, far outside any per-test time budget, and an input larger than memory rules out loading it at all. When a bound is not given, ask for it, then say out loud which approach it kills.

Questions & practice

Resolve party identity across merges without rewriting posted history

Medium · union-find · identity resolution · immutable history

Duplicate parties get merged over time: a stream of up to 5 million (loser_party_id, winner_party_id, merged_at) events, deliverable in any order, sometimes repeating a pair already merged. Ten million account rows and hundreds of millions of ledger_entry rows already reference pre-merge party ids and must not be rewritten. Build the structure that answers 'what is the canonical party for this id?' in near-constant time, and explain how a report that groups by party stays correct without touching a single historical row.

Approach

  1. The merge events form an undirected graph over party ids and the question is connected components, so use disjoint-set union with union by size and path compression: find and union amortise to O(alpha(n)), and alpha(n) is at most 4 for any n that exists. Five million merges over ten million ids is two integer arrays and a few hundred milliseconds.
  2. The domain twist: the representative cannot be whichever root union-by-size happens to pick, because the canonical party is a business decision recorded as winner_party_id. Keep union by size for the tree shape and a separate root -> canonical_party_id map for the label. Conflating them makes the canonical id flip when an unrelated merge reshapes the tree, and every report built on it moves retroactively.
  3. Never rewrite ledger_entry or account. Resolve at read through the canonical map: rewriting posted rows destroys the audit trail, locks hundreds of millions of rows, and buys nothing the map does not already give you.
  4. Materialise the map fully expanded as party_id -> canonical_party_id, one row per member rather than per edge, so a report is a single join instead of a recursive traversal. Rebuild it from the event log, which keeps it a derived artefact that can be regenerated rather than a second source of truth that drifts.
  5. Pin down determinism: a repeated merge is a no-op because both ids already share a root, but two events merging the same pair in opposite directions is a genuine conflict. Write the tiebreak down — lowest merged_at, then lowest id — or two replays of the same log produce different labels for the same data.
  6. Un-merges: disjoint-set union has no delete. If a merge can be reversed, keep the event log authoritative and rebuild the whole structure from the surviving events; at 5 million events that is seconds, and far cheaper than any incremental un-union scheme.

Follow-up

  • A merge is reversed after six months of postings. What exactly gets rebuilt, and what does the statement produced last month now say?
  • Two processes apply merge events concurrently. What guarantees they converge on an identical canonical map?
  • Resolving at read on the hot path versus materialising the map — where does that join actually live, and what does each cost?

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

  1. Normalise the sign at read time from direction, not from the amount: signed = +amount_minor for debit, -amount_minor for credit (state which convention you picked). The schema constrains amount_minor > 0 precisely so the sign lives in exactly one place.
  2. 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 by hash(account_id) % P and run P passes for 1/P of the memory.
  3. Ride the zero-sum check on the same pass. Because a transaction's entries are contiguous, keep a tiny currency -> int64 map for the current transaction_id only, test it against zero on the boundary and at EOF, then clear it. That is O(currencies in one transaction), typically one or two.
  4. 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.
  5. 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_date without a second full scan?
  • The job is restarted after a crash halfway through the file. What makes the second run produce identical output?

Detect duplicate-charge bursts in an out-of-order authorisation stream

Hard · sliding window · event time · watermarks · deduplication

Authorisations arrive as (instrument_token_id, amount_minor, currency, event_time, arrival_time) at roughly 3,000 per second, up to 60 seconds late and out of order. Flag any token with three or more authorisations of identical (amount_minor, currency) inside any 10-minute window of event time. Report each flag once, as early as correctness allows. State memory per key and in total, how late an event you will accept, and what you do with one that arrives after you have already reported — or already declined to report — that window.

Approach

  1. Key state by (instrument_token_id, amount_minor, currency), not by token: the predicate is about identical amounts, so the window belongs to the triple. Each key holds its own event-time-ordered deque.
  2. In-order, this is two pointers: on insert, pop from the front while front <= new - 10 min, then flag if the deque reaches length 3. Amortised O(1) per event, memory proportional to that key's window occupancy.
  3. Out-of-order arrival breaks append-only monotonicity, so insert in position instead. With lateness bounded at 60 seconds the insertion point is always near the tail, so a short sorted vector or a 600-bucket per-second ring keeps it O(w) with tiny w; a balanced tree per key is correct but over-built for a one-minute reorder.
  4. Drive decisions off a watermark of max(event_time seen) - 60 s, never off wall clock. Anything older than the watermark is too late to change an answer and is counted in a late_dropped metric. Without an explicit watermark you have still chosen a lateness policy — you just cannot state it or test it.
  5. Report-once needs its own state: per key, a set of already-flagged 10-minute window ids. A later event inside an already-flagged window must not re-flag, and a late event that completes a window you never flagged must flag — which is why state is retired at window close plus the lateness bound, not at window close.
  6. Size it: state lives 660 seconds, so at 3,000 events per second there are about 1.98 million in flight, plus one flagged-window set per active key. Bound total memory explicitly and shed by key age, and say what shedding costs — a shed key can miss a flag, making the threshold a product decision rather than a tuning knob.

Worked solution (40 min)

  1. Write the key, the window, the watermark and the report-once rule in four lines before any code; every later bug is one of these left implicit.
  2. Implement per-key state as a sorted deque of event times plus a set of flagged window ids, both retired at watermark - 660 s.
  3. Fixture 1, in order: identical amounts at t, t+1 min and t+9 min produce one flag. A fourth at t+11 min evicts t+1 exactly under the half-open rule pop while front <= new - 10 min, leaving two events and no second flag; change that comparison to strict < and the same input flags twice. Pick one and write it in the spec.
  4. Fixture 2, out of order: deliver the same three events as t+9, t, t+1 and assert the flag fires on the third arrival with the same window id as fixture 1.
  5. Fixture 3, too late: the t event arrives 90 seconds after the watermark passed it, so no flag fires and late_dropped increments by one.
  6. Replay all three fixtures under twenty random arrival orders and assert the flag set is identical whenever every event is inside the lateness bound.

Expected result: Fixture 1 flags exactly once, at the t+9 event; the t+11 event does not flag under the half-open rule and does under the closed one, which is the boundary the fixture exists to pin. Fixture 2 yields the same single flag with the same window id. Fixture 3 yields no flag and late_dropped == 1. All twenty shuffles produce an identical flag set.

Verify it yourself

  • Replaying the same events in any arrival order within the lateness bound produces identical flags — the only property that makes this job re-runnable.
  • Per-key state never exceeds that key's events within 660 seconds; assert it rather than hope.
  • Every flag's window id derives from event time alone; arrival_time appears only in the watermark computation and the lateness metric.

Follow-up

  • Make the threshold and window configurable without rebuilding all in-flight state on every change. What does that constrain in the data structure?
  • Two stream partitions hold events for the same token. What does that force the partitioning key to be?
  • An event arrives three hours late. Does any answer change, and who is told?

Version a loan schedule instead of soft-deleting posted instalments

Medium · audit history · soft deletes · temporal queries

loan_instalment is keyed by (loan_id, schedule_version, instalment_no) and carries due_date, principal_minor, interest_minor, fee_minor, paid_principal_minor, paid_interest_minor, status, days_past_due, effective_from (date) and superseded_at (timestamptz). A borrower defers two payments on 2026-03-14, and instalments 1 to 6 already have allocations posted against them. Model exactly what the deferral writes, and write the query that returns the schedule as the borrower saw it on an arbitrary date. Say why stamping the old rows with deleted_at, or updating them in place, fails an audit.

Approach

  1. Treat the deferral as an insert, not an edit: write a complete new schedule_version with effective_from = the deferral instant on 2026-03-14, and in the same transaction stamp superseded_at on every row of the outgoing version with that identical instant, so the two version windows are half-open and adjacent rather than overlapping. Instalments 1 to 6 are reproduced unchanged, because they are what the borrower was told and what was posted to the ledger.
  2. Write the as-of read as a version selection, not a row filter: WHERE loan_id = $1 AND effective_from <= $2 AND (superseded_at IS NULL OR superseded_at > $2), then assert that exactly one schedule_version comes back - COUNT(DISTINCT schedule_version) = 1, not one row - so an overlapping window raises rather than silently returning two interleaved schedules under one instalment_no.
  3. Fix the type mismatch before writing that predicate: effective_from is declared date and superseded_at timestamptz, so comparing them casts the date at the session TimeZone and two readers in different zones select different versions near midnight. Put both columns in one domain, timestamptz, keep the window half-open with effective_from inclusive and superseded_at exclusive, and resolve a bare as-of date to an instant once, in the loan's booking timezone, at the edge of the system.
  4. Say what deleted_at loses. It records that a row stopped being current but not what replaced it or from when, it leaves every downstream query obliged to remember deleted_at IS NULL, and one query that forgets double-counts the schedule. Versioning puts the same information in the primary key where it cannot be forgotten.
  5. Close the arithmetic: under the loan's stated day-count convention the new version must still sum to outstanding principal plus scheduled interest to the minor unit, with the per-period rounding residual placed in one named instalment, conventionally the last, rather than smeared across the tail.
  6. Index (loan_id, effective_from DESC) for the as-of lookup, keep superseded versions online rather than archiving them, and enforce with a trigger that no UPDATE touches a row whose paid_principal_minor or paid_interest_minor is non-zero.

Follow-up

  • What does days_past_due mean for an instalment that exists in two versions with different due_dates?
  • A payment arrives allocated to an instalment_no that exists only in the superseded version. What do you do with it?
  • How do you prove the ledger postings made under version 1 still reconcile once version 2 exists?

Stop the capture-and-refund join from double-counting merchant money

Medium · join fan-out · conditional aggregation · grain

Report captured_minor, refunded_minor and net_minor per merchant per business_date. payment_intent holds intent_id, merchant_id, amount_minor, captured_minor, refunded_minor. ledger_entry holds transaction_id, account_id, direction, amount_minor, source_type in (capture, refund, fee, chargeback), source_id = the intent_id as text, and business_date; every capture posts one debit and one credit. A draft joins payment_intent to ledger_entry twice, once filtered to captures and once to refunds, and sums both. On intents with two partial captures and one refund the totals are wrong. Name both multiplications precisely and write the correct query.

Approach

  1. Name the first multiplication by cardinality: intent to capture entries is one-to-N and intent to refund entries is one-to-M, so joining both yields N times M rows per intent. Every capture row is then counted M times and every refund row N times, and the two columns are inflated by different factors, which is why the totals look almost right rather than obviously broken.
  2. Name the second, which is easier to miss: double-entry means each capture posts two legs, so summing every entry of a capture counts the amount twice regardless of joins. Select the single leg by ACCOUNT - the merchant's settlement payable, which carries exactly one leg of every capture and of every refund - and not by direction. Direction is not a substitute for that filter: a capture credits the payable and a refund debits it, so adding direction = 'credit' keeps the captures and silently drops every refund leg, leaving refunded_minor at exactly zero on a query that otherwise reads as correct.
  3. Reject the reflexive fixes: SELECT DISTINCT and SUM(DISTINCT ...) de-duplicate values, not rows, so two genuine captures of equal amount collapse into one and the total is wrong in the other direction while looking tidier.
  4. Prefer a single pass with conditional aggregation over ledger_entry, restricted to that one account: SUM(amount_minor) FILTER (WHERE source_type = 'capture') and the same for refunds, grouped by merchant and business_date, joining payment_intent only to reach merchant_id. One scan, no intent-level fan-out, and fees or chargebacks are added as another FILTER rather than another join. amount_minor is a positive magnitude, so net_minor is captured minus refunded; the signed form over the same rows, SUM(CASE WHEN direction = 'credit' THEN amount_minor ELSE -amount_minor END), returns the same net in one column and is the check on the sign convention.
  5. Keep aggregate-then-join in reserve for when per-intent detail is required: aggregate each side to intent grain in its own CTE, then join the two aggregates one-to-one. LATERAL works too and reads better when the right side needs the left's key.
  6. Get the account predicate, the join column and the date right. Each merchant has its own settlement payable, so the predicate is a join to the account dimension (a.account_type = 'merchant_payable' AND a.merchant_id = pi.merchant_id); a single $settlement_account literal is only correct for a one-merchant report. source_id is text and intent_id is uuid, and PostgreSQL has no implicit cast between them, so one side must be cast and the side decides which index stays usable - pi.intent_id = e.source_id::uuid probes payment_intent's primary key per entry row, e.source_id = pi.intent_id::text probes an index on ledger_entry(source_id), and if neither fits the driving table the planner hashes both sides, which is fine for a day's report and not for a single-intent lookup. Group on ledger_entry.business_date rather than posted_at::date, because they differ across the cutoff, and decide explicitly whether a later-dated refund reduces its own day or the original capture's.

Worked solution (30 min)

  1. Build a fixture with one intent that has two partial captures and one refund, each posting a debit and a credit, and compute the correct totals by hand first.
  2. Run the double-join draft against it and express each column's error as a product of the two mechanisms, rather than guessing: four capture rows and two refund rows, so the capture sum is doubled by its legs and multiplied by the two refund rows, and the refund sum is doubled by its legs and multiplied by the four capture rows.
  3. Write the single-pass version: SELECT pi.merchant_id, e.business_date, COALESCE(SUM(e.amount_minor) FILTER (WHERE e.source_type = 'capture'), 0) AS captured_minor, COALESCE(SUM(e.amount_minor) FILTER (WHERE e.source_type = 'refund'), 0) AS refunded_minor FROM ledger_entry e JOIN payment_intent pi ON pi.intent_id = e.source_id::uuid JOIN account a ON a.account_id = e.account_id AND a.account_type = 'merchant_payable' AND a.merchant_id = pi.merchant_id GROUP BY 1, 2, with net_minor as the difference. No direction predicate: the account join already picks one leg out of each pair, and a direction filter would drop the refunds.
  4. Compare both queries against the hand-computed fixture and against SUM(payment_intent.captured_minor) for the same merchant and day.

Expected result: On that fixture the draft reports captured_minor at four times the true figure and refunded_minor at eight times it, the two factors being 2 legs times 2 refund rows and 2 legs times 4 capture rows. The single-pass query matches the hand-computed totals exactly, refunded_minor is the refund's own magnitude rather than zero, and net_minor equals captured_minor minus refunded_minor to the minor unit.

Verify it yourself

  • Add a second refund and confirm the correct query's captured_minor does not move at all, while the draft's rises again.
  • Drop the merchant-payable predicate and watch every total double exactly, which isolates the double-entry legs from the join fan-out. That test only works because there is no direction filter left to mask one leg.
  • Assert refunded_minor is non-zero on the fixture, then re-add direction = 'credit' and watch it fall to exactly zero while captured_minor does not move - the silent failure this account predicate avoids.
  • Cross-check the day's captured_minor against SUM over payment_intent.captured_minor and account for any difference by business_date rather than by tolerance.

Follow-up

  • A refund lands on a later business_date than its capture. Which day does net_minor move, and what does that do to a daily merchant payout?
  • Add scheme fees, which arrive netted into a batch total rather than per transaction. How does the query change, and what can it no longer claim?
  • How would you reconcile this output against payment_intent.captured_minor, and which one is authoritative when they disagree?

How would you design a service that processes large datasets with minimal latency?

Medium

Approach

  1. Clarify what is being asked and what a complete answer contains.
  2. Say what you would check first and why it is the highest-information step.
  3. State your assumptions explicitly before working the problem.

Follow-up

  • What assumption would you test first?
  • How would you know your answer was wrong?

How do you ensure data consistency in a distributed system managing financial transactions?

Medium

Approach

  1. Clarify what is being asked and what a complete answer contains.
  2. Say what you would check first and why it is the highest-information step.
  3. Work from the requirement backwards to the design.

Follow-up

  • How would you know your answer was wrong?
  • What assumption would you test first?

How do you approach testing and quality assurance for mission-critical financial applications?

Medium

Approach

  1. Clarify what is being asked and what a complete answer contains.
  2. Work from the requirement backwards to the design.
  3. State your assumptions explicitly before working the problem.

Follow-up

  • What assumption would you test first?
  • How would you know your answer was wrong?

What are the implications of using cloud-native services for sensitive financial data?

Medium

Approach

  1. Clarify what is being asked and what a complete answer contains.
  2. State your assumptions explicitly before working the problem.
  3. Say what you would check first and why it is the highest-information step.

Follow-up

  • How would you know your answer was wrong?
  • What assumption would you test first?

Deliver merchant webhooks at least once preserving per-destination order

Medium · at-least-once · ordering · queueing

Outbound events come from outbox_event (event_id uuid, aggregate_type, aggregate_id, aggregate_version bigint with UNIQUE (aggregate_type, aggregate_id, aggregate_version), event_type, payload jsonb, published_at nullable, attempts, last_error). Consumers are merchant HTTP endpoints; one is down for six hours and another answers in 8 s. Peak is 5,000 events per second across 20,000 destinations. Specify the delivery contract you publish to merchants (signature, retry schedule, ordering guarantee, duplicate semantics) and the worker topology that keeps one dead endpoint from delaying every other merchant's events.

Approach

  1. Publish at-least-once and say it plainly in the contract: duplicates are expected, event_id is stable across every attempt of the same event, and consumers must deduplicate on it. Minting a fresh delivery id per attempt is the outbound twin of regenerating an idempotency key, and it disables the consumer's dedup while leaving it in place.
  2. Give consumers what they need to order for themselves: aggregate_id and aggregate_version in every payload, with the documented rule that an event whose version is not greater than the last applied version is discarded. Delivery-side ordering can only ever be best effort, because the first retry necessarily reorders an event relative to those that followed it.
  3. Partition work by destination rather than sharing one pool. Give each destination a bounded number of in-flight deliveries so the 8 s endpoint occupies only its own slots and the dead one only its own. One shared pool across 20,000 destinations converges on head-of-line blocking behind the slowest consumers.
  4. Do the arithmetic before promising ordering. Strict per-destination FIFO caps a destination at 1/latency deliveries per second, which is 0.125/s for the 8 s endpoint, so it will never drain 5,000/s of fan-out. Partition ordering by aggregate_id instead, which is the ordering consumers actually act on, and state that cross-aggregate order is not guaranteed.
  5. Retry with exponential backoff and jitter out to 24 hours, cap attempts, then disable the endpoint, notify the merchant, and hold the events behind a replay API rather than dropping them. Synchronised retries across many destinations after a shared outage need the jitter, not just the backoff.
  6. The relay reads only the partial index WHERE published_at IS NULL, claims rows under a lease so two relay instances do not both deliver, and treats a crash after delivery but before marking as an accepted duplicate, which is exactly what the at-least-once contract already licenses.

Worked solution (35 min)

  1. Write the merchant-facing contract page: signature scheme over raw bytes, retry schedule with the exact backoff points, at-least-once with a stable event_id, and ordering stated as per-aggregate best effort with aggregate_version as the tiebreaker.
  2. Implement the relay against the partial index with a lease-based claim, and run two relay instances to confirm no row is delivered twice within one lease.
  3. Stand up three fake destinations: fast, 8 s slow, and hard down. Drive 10,000 events and measure end-to-end latency for the fast destination while the other two misbehave.
  4. Kill the relay mid-batch and assert no event is lost and that the resulting duplicates are all the same event_id.
  5. Compute the per-destination throughput ceiling under strict FIFO at the observed latencies and write down which ordering guarantee you can actually keep.

Expected result: The fast destination's p99 delivery latency is unchanged while one destination is down and another takes 8 s; every event is delivered at least once across the relay kill; duplicates all share their original event_id and aggregate_version.

Verify it yourself

  • A consumer that discards events whose aggregate_version is not greater than its last applied version ends in the same state regardless of delivery order.
  • The dead destination's queue depth grows without any growth in the other destinations' queue depth.
  • The relay's only scan is the partial index on unpublished rows, confirmed by EXPLAIN rather than by reading the code.

Follow-up

  • A merchant asks for exactly-once delivery. What do you offer instead, and what do they have to build?
  • The relay's unpublished backlog grows to 40 million rows during an outage. What does that do to the partial index and to the claim query?
  • How do you let a merchant replay yesterday's events without replaying them to every other destination?

Statement endpoint slows linearly with the entry count

Easy · n+1 · latency · query planning

A statement endpoint reads ledger_entry for one account and a date range and returns entry_id, amount_minor, currency, source_type and business_date, plus the display name of the counterparty account found through the other entry on the same transaction_id. p99 is 40 ms for a 20-entry month and 2.1 s for a 900-entry month. The database reports 901 statements per request, each under 1 ms, and no single query is slow. Diagnose the cause and give the fix, stating the statement count and the p99 you expect afterwards.

Approach

  1. Establish the shape before theorising. Divide the per-request statement count from pg_stat_statements by the rows returned: 901 statements for 900 rows is one driver query plus one per row, and because each is sub-millisecond it rules out a bad plan. The latency is round trips, not work.
  2. Identify the per-row statement by its normalised text. It will be a single-row lookup on ledger_entry joined to account, keyed by transaction_id, issued from the serialisation layer rather than the repository. Confirm it is lazy loading by checking that it disappears when the counterparty name is dropped from the response.
  3. Do the arithmetic as a division from the incident rather than a multiplication from assumptions. The 900-entry request issues 880 more statements than the 20-entry one, 901 against 21, for 2.06 s more wall time, so each extra statement costs at most about 2.3 ms end to end, and that is an upper bound because the larger response also carries more payload. Then measure one round trip on this path instead of assuming it: pool checkout, network, parse, bind, execute and per-row hydration in the driver, not network alone. A naive 1 ms network plus the 0.2 ms the database reports would cover only about 1.05 s, half the excess, so if a measured round trip really is that cheap then the N+1 is not the whole story and you owe an explanation for the rest before proposing a fix.
  4. Replace the per-row lookup with one batch statement: collect the transaction_ids from the driver query and fetch all counterparty entries with WHERE transaction_id = ANY($1), then build the map in memory. Two statements per request, independent of the range width.
  5. Check the index before declaring victory. The batch query needs an index on ledger_entry(transaction_id); without it ANY($1) degrades to a sequential scan over the entry table and the fix becomes a worse regression than the bug.

Follow-up

  • What changes if the statement must paginate at 200 entries per page?
  • A transaction can carry more than two entries once fees have their own leg. What does your query return then, and what should the counterparty column mean?
  • What would have caught this before production, given that no individual query is slow?

Suggested practice checklist

Four days sample coding, design, fundamentals and the practical rounds at deliberately shallow depth, which is enough to surface the topics you did not know were in scope. That map, rather than a guess made on day one, decides where the last three days go.

Day 1 — Coding, one pass at shallow depth

  • Solve one problem from each of six families, an array with two pointers, hash counting, binary search, a tree traversal, a graph traversal and one dynamic program, under a hard twenty-minute cap with no extensions, marking each finished, late, or stalled.
  • For every stall, write the exact move you could not make rather than the subject, so the note reads could not turn the recurrence into a loop rather than bad at dynamic programming.
  • Fix nothing today. The value of the pass is the unfixed record.

Deliverable: Six timed attempts marked finished, late or stalled, each stall carrying a named blocking move.

Day 2 — Design, one pass at shallow depth

  • Spend twenty minutes each on three different shapes, a read-heavy feed, a write-heavy ingest path, and something needing a transaction across two entities, stopping each at requirements, interface and data model.
  • After each, write the first question you could not answer, which is usually a number you could not estimate or a failure mode you had no vocabulary for.
  • Mark which of the three you would be most relieved not to be asked, and treat that as data rather than as a preference.

Deliverable: Three shallow designs, each with the first unanswerable question written at the bottom.

Day 3 — Fundamentals and the practical rounds

  • Answer eight short questions in writing at four minutes each, covering the material that fills the gaps between the big rounds: what happens between a URL and a rendered page, what an index costs on write, when a process is preferable to a thread, and what conditions a deadlock requires.
  • Do one thirty-minute practical task of the kind a take-home compresses: read an unfamiliar two-hundred-line file and write what it does, what you would change, and the one thing you remain unsure of.
  • Score every answer fluent, correct but slow, or absent, and keep the absent ones visible.

Deliverable: Eight scored short answers and one written reading of unfamiliar code.

Day 4 — The rounds that are about you, and the map

  • Deliver three behavioural answers aloud against a timer, a conflict, a failure you owned, and a decision made without enough information, marking any that ran past three minutes or contained no number.
  • Assemble the map: every marked item from days one to three on a single page, sorted by how likely it is to appear in your loop rather than by how uncomfortable it felt.
  • Choose exactly two areas for the remaining three days and write down what you are deliberately abandoning.

Deliverable: A one-page scored map of the whole surface area with two areas chosen and the rest explicitly abandoned.

Day 5 — First chosen area, to the depth you skipped

  • Work the higher-ranked area in four focused blocks, choosing items one level above where you stalled rather than repeating what already works.
  • After each block write the rule you extracted in one sentence with its precondition attached, since a rule carrying no precondition is exactly what fails under a variation.
  • Re-attempt the day-one or day-two item that exposed this area and compare against the original timing.

Deliverable: Four worked blocks, a timed re-attempt against the original, and three one-sentence rules with preconditions.

Day 6 — Second chosen area, where the gap is coverage rather than speed

  • Treat the second area differently from the first. Day five drilled something you could already half-do; this one is usually a topic you had simply never met, so build one worked reference example end to end and keep it, rather than attempting six problems badly.
  • Write down the vocabulary you were missing on day two or three, five terms at most, each with the one sentence that makes it usable in an answer rather than the textbook definition.
  • Redo the shallow attempt that exposed this area and note whether you now fail later in the problem, because moving the failure point is the realistic gain from a single day and is worth more than a score that did not change.

Deliverable: One worked reference example for the newly covered area, a five-term vocabulary list, and a note on where the failure point moved.

Day 7 — Reassemble the loop

  • Sit two rounds back to back with no gap, ordering them so the area you chose second comes last, because the map was built from rested, isolated attempts and the loop will reach your weaker area when you are already spent.
  • Write where the second round suffered from the first, which is normally the point at which structure collapses into narration.
  • Reduce the week to one page holding only the rules you can state without reading them.

Deliverable: Mock notes on cross-round carryover plus a one-page card of rules you can recite from memory.

The failure that was yours

Keep one story where the bad call was yours rather than a dependency's or a manager's. Name the check that would have caught it, whether you added that check afterwards, and whether it has fired since. Answers that route blame outward end the conversation early; answers that end in a guardrail someone still relies on tend to open it up.

Ship payouts with named, scheduled reconciliation debt

Medium · technical debt · reconciliation · scope cutting

You have six working days to ship merchant payouts. The correct reconciliation join is on (external_reference, amount_minor, currency, business_date); the version that fits the window matches on amount and business_date alone, which will mismatch same-amount, same-day lines. Describe a time you shipped with debt you knew about. State exactly what you cut, the guard that made the shortcut visible rather than silent, who agreed to carry the risk, the date it was paid down, and what it cost while it stood. Be specific about the failure you were accepting.

Approach

  1. The probe is whether your shortcuts are bounded and instrumented or merely undocumented. Start by stating the failure you accepted in one concrete sentence: ambiguous matches will be resolved arbitrarily, so a genuine break can be masked by pairing with the wrong line.
  2. Make the shortcut loud rather than silent. When more than one candidate matches, do not pick one: open a break of type duplicate_match and emit a counter on that path. A shortcut that reports its own frequency converts an unknown into a number you can bring to the paydown conversation.
  3. Bound the exposure before you ship: query historical settlement files for lines that collide on (amount_minor, business_date) within a merchant. If that is 0.2 percent of lines, the risk is small and quantified; if it is 8 percent, the plan does not survive contact and you have learned that before shipping rather than after.
  4. Name the owner and the date, and say where they are written down. 'The team agreed' is the generic answer; a ticket with an owner, a date, and the counter that forces the conversation is the strong one.
  5. Distinguish debt you can pay from debt that compounds. A matching shortcut that leaves breaks visible is recoverable; one that auto-resolves breaks by widening a tolerance destroys the evidence and cannot be unwound later, and a strong answer says it would have refused that version even under the deadline.
  6. Report the actual cost: how many ambiguous matches occurred, how much analyst time they consumed, and whether the paydown date held. If it slipped, say by how long and what finally forced it.

Follow-up

  • The paydown date arrives and the team has a new deadline. What do you do differently from the first conversation?
  • Which shortcut would you have refused to ship at any deadline, and why that one?
  • How would you have sized the exposure if no historical settlement files were available?

Estimate a reconciliation rebuild you have never attempted

Hard · estimation · unknowns · reconciliation

You are asked how long it takes to replace a reconciliation service matching 30 million settlement lines a day against the ledger, including a bounded fuzzy fallback for netted fees and an ageing model for breaks. You have never built one. Produce an estimate, the range around it, and the two or three unknowns that dominate that range. Then describe a time you estimated unfamiliar work: what you did in the first day to shrink the range, what you committed to publicly, how far off you were, and what you would tell the requester differently now.

Approach

  1. The probe is whether you can be useful under uncertainty without either refusing to estimate or inventing false precision. Give a number with an explicit range and the basis for both, then immediately name what would move it, rather than asking for two weeks of discovery first.
  2. Decompose into parts with different uncertainty profiles. The hash join on (external_reference, amount_minor, currency, business_date) over 30 million lines is well understood engineering and estimates tightly; the fuzzy fallback for netted and fee-adjusted lines does not, because its scope is defined by whatever the files actually contain; the ageing and break workflow is mostly operations-facing surface area, which estimates by counting screens and states.
  3. Name the dominating unknowns concretely: how many distinct file formats and cutoff conventions the sources use, what fraction of lines are netted rather than itemised, and whether business_date is derivable from any field in the file or must be reconstructed from the cutoff rule. Each is a factor on the fuzzy path, not a percentage on the whole.
  4. Describe the first-day range-shrinking work, which is the part that separates strong from generic: take one real file, count distinct formats, measure the netted fraction, and attempt the exact join on a single day of postings to see what the residual actually is. One day of that typically converts a 3x range into something near 1.5x.
  5. Commit in a form that survives being wrong: a range plus a checkpoint date at which you will replace it with a narrower one, and an explicit statement of what you will cut first if the range turns out to be optimistic.
  6. In the retrospective half, give the real numbers: the estimate, the actual, and the specific thing that consumed the difference. Answers that were within 10 percent are less informative than answers that were 2x off for a nameable reason.

Follow-up

  • The requester wants one number, not a range, for a board deadline. What do you give them?
  • Your one-day probe finds 40 percent netted lines instead of the 5 percent you assumed. What changes in the plan, not just the estimate?
  • What do you cut first if you are at the deadline and the fuzzy fallback is not done?

Argue against a dual write you were assigned to build

Hard · dual write · outbox · design review · disagreement

A senior engineer specifies that the orchestration service should update payment_intent.status and publish the merchant event in the same code path, wrapping the publish in a retry. You believe it is wrong and you have been told to build it. Describe a time you argued against a design you were assigned. State the failure you predicted, the evidence you brought, how long the disagreement ran, what you did once the decision went against you, and what production eventually showed. Include the version of your argument that did not persuade anyone, and why it did not.

Approach

  1. The probe is whether you can disagree with a technical authority using evidence rather than taste, and still execute. Establish the failure precisely first: no transaction spans the database and the broker, so a crash between them leaves one side done, and it fails asymmetrically in both orderings. Publish-then-commit invents events for state that never existed; commit-then-publish loses events for state that does.
  2. Kill the retry argument explicitly, because it is the one that keeps the design alive: the retry loop lives in the same process that can die, so it narrows the window and never closes it. Quantify the window if you can (deploy restarts per week times request rate times the in-flight fraction) rather than asserting it is rare.
  3. Bring evidence in the form the decision-maker can check in a day: a count of merchant events with no corresponding intent version, or intent versions with no event, over a window you can query now. An argument that costs the other person nothing to verify is the one that moves.
  4. Say what you proposed instead in one sentence with its cost owned honestly: an outbox row written in the same transaction with UNIQUE (aggregate_type, aggregate_id, aggregate_version), a partial index on published_at IS NULL, and a relay that delivers at least once. The cost is that every consumer now has to be idempotent, and that is a real tax you are asking others to pay.
  5. Describe the disagree-and-commit mechanics concretely: what you built, what you instrumented so the prediction could be checked, and what threshold would have proved you wrong. A strong answer is falsifiable; a generic one says 'I raised concerns and moved on'.
  6. Report the outcome including the possibility that you were partly wrong about severity or timing, and separate 'I was right' from 'the disagreement was handled well'.

Follow-up

  • You lost the argument. What instrumentation do you add so the question gets settled by data in a month rather than by another meeting?
  • The stream processor's documentation says exactly-once. Why does that not settle the question for a card charge?
  • What would have made you drop the objection entirely?