Interview guides · Bank of Canada
Bank of Canada Software Engineer Interview Questions & Guide 2026
Code reading practice for Bank of Canada software engineer prep: work out what unfamiliar code does before judging it.
The 60-second brief
As a Software Engineer at the Bank of Canada, you play a foundational role in maintaining the integrity and stability of the nation’s financial systems. This is not merely about writing code; it is about building robust, secure, and highly available systems that support the critical infrastructure of the Canadian economy. Whether you are working on internal IT transformation, cloud services, or specialized cybersecurity initiatives, your work directly impacts the tools and platforms that keep the country’s financial operations running smoothly.
The title spans product, platform and infrastructure work, and which of those the seat actually is decides whether design or algorithms carries more weight in your preparation. The posting rarely settles it; what the team is on call for usually does.
Focus areas
- Name the isolation level each invariant requires
- Model money movement as balanced double-entry postings
- Store money as integer minor units
Overview
The environment at the Bank of Canada is unique, balancing the rigorous security demands of a central bank with the need for modern, scalable engineering practices. You will often collaborate with multidisciplinary teams—including business analysts, security experts, and project managers—to translate complex policy and operational needs into efficient technical solutions. If you are driven by the challenge of working on systems where precision and reliability are paramount, this role offers an unparalleled opportunity to contribute to a mission-critical organization.
The Bank of Canada interview process
Bank of Canada candidates report 4 rounds · ≈ 3-5 weeks. The stages below are what candidates describe, not a published process.
Initial Screening
Before anything technical happens, someone has to decide which rung of the ladder your loop is calibrated to, and that decision sets the bar for every round after it. It comes from how you describe scope, not from your title, because titles do not convert cleanly between companies. The weak version of the answer is team size and years. The strong version names the largest change you shipped where nobody reviewed the design, what would have broken if you had been wrong, and what you were paged for. Get the level said out loud on this call, because the range and the loop both follow from it.
What to demonstrate
- Whether the scope in your own account maps onto a level the team actually has an opening at, so a mismatch ends the process cheaply rather than after four interviewers have spent a day
- Whether your title needs re-mapping: the same word describes very different amounts of independent decision-making at a twenty-person company and a ten-thousand-person one
- Whether your compensation expectation can be filled at that level in the structure the role pays in, which is why the number gets asked for before any engineer is scheduled
How to prepare
- Write down two changes from the last two years: the largest one you designed with nobody reviewing the design, and the largest one where someone more senior did. Lead with the first when scope comes up, and be ready to say which parts of the second were yours
- Ask which level the loop is calibrated to and what changes at the level above it, then plan your weeks from that answer rather than from the posting
- Settle a total-compensation range beforehand with the split named, base against bonus against equity and its vesting period, so a question about numbers gets a number instead of the word market
Panel Interviews
A day like this is several different games in a row, and the expensive mistake is carrying the previous one into the next room. Coding rewards narrow precision and finishing inside a timer. Design rewards breadth, stated assumptions and naming what you are deliberately not building. Behavioural rewards specificity about people and decisions. Candidates who over-engineer a coding problem they were supposed to finish, or who start sketching class hierarchies before anyone has agreed what the system has to do, are usually still playing the last round. Between rooms, name out loud which game the next one is.
What to demonstrate
- Whether the coding round ends with something that runs and has been traced against a degenerate input, rather than an extensible design that was never finished
- Whether a design discussion opens by agreeing on traffic shape, read-to-write ratio and what is allowed to be stale, instead of proceeding from an architecture you arrived with
- Whether a behavioural answer names a person, a disagreement and what you did about it, rather than describing the system the story happened inside
- Whether the opening habits still appear late in the day: restating the problem, asking for constraints, saying the plan before typing
How to prepare
- Book three mocks of different types back to back on one afternoon and ask each interviewer afterwards which round you answered in the wrong mode
- Write a three-line opening script per round type — coding: restate, name the approach and its cost, then type; design: ask for scale, read-write mix and what must not break; behavioural: name the person, the stakes and the decision — and run it off a card so the switch is mechanical rather than remembered
- Practise coding with a timer you do not extend, stopping when it stops, so the trained reflex is to finish a correct solution rather than to keep improving one
Behavioral Insights
What you say here is written down by each interviewer and compared afterwards, so the unit of evaluation is a claim someone else could check, not a well-told narrative. Two things make a story checkable: detail only a participant would hold, and a clean line around which part was yours. Vague ownership is the usual failure and it is usually accidental, because engineers say we about the team's work and we about their own, so the thing they personally built disappears into the plural. Name the part you wrote, and name who did the rest.
What to demonstrate
- Whether your details are ones a participant would hold and an observer would not: the constraint that ruled out the obvious approach, the first attempt that failed, the person who objected and on what grounds
- Whether ownership survives a direct question, since a follow-up to we decided is routinely who decided, and an answer that stays plural at that point is read as the work belonging to someone else
- Whether the numbers you quote are ones you would say identically to a former colleague with the dashboard open
How to prepare
- Go through each story replacing every we with either I or a named role (the on-call engineer, the reviewer, the other team) and check the story still holds together. Wherever it stops making sense you have found a part you cannot actually speak to
- Open the artefacts for two of your stories, the pull request, the design doc, the incident notes, and read them for dates and figures you have been rounding in the retelling. Correct your version to match
- For each story write the single sentence you would least want repeated to a former teammate, then either make it accurate or take it out
Technical Assessments
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
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.
Optimising an axis nobody named
Ask which resource is actually scarce here: wall-clock latency, throughput, memory footprint, cost per request, or engineering time. Shaving a constant factor off an in-memory step is wasted effort when the same function makes a blocking remote call inside the loop.
Starting work without saying what you are about to spend time on
State the plan before executing it: the approach, roughly how long it will take, and what you intend to leave hand-waved. That gives the interviewer a chance to redirect you in ten seconds rather than watching you spend fifteen minutes on the wrong sub-problem.
Questions & practice
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
- 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. - 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. - 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.
- 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 alate_droppedmetric. Without an explicit watermark you have still chosen a lateness policy — you just cannot state it or test it. - 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.
- 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.
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?
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?
Match a settlement file to ledger postings under duplicate keys
Medium · hash join · reconciliation · external memory
You have one business date of ledger postings (about 12 million rows: transaction_id, source_id, amount_minor, currency, business_date) and the processor's settlement file (about 12 million lines: settlement_line_id, external_reference, amount_minor, currency, business_date), where source_id carries the external reference. Match one-to-one on (external_reference, amount_minor, currency, business_date). Duplicate keys occur legitimately — the same amount can appear twice. Emit every unmatched item classified ledger_only, file_only or duplicate_match, in linear expected time. Then say what you do when neither side fits in memory.
Approach
- Build the smaller side into
key -> deque of row ids, neverkey -> row id. A duplicate key is data, not corruption; a single-row map drops one of a legitimate pair and the break report then shows afile_onlythat does not exist. - Probe the larger side once, carrying one extra bit per bucket: whether that bucket was ever hit. Pop from the bucket on a match and set the bit. An absent key is a probe-side-only row. A present-but-empty bucket means the probe side holds more copies than the build side — surplus, so
duplicate_match. After the pass, a leftover non-empty bucket that was never hit is build-side-only; one that was hit is build-side surplus, so alsoduplicate_match. - That bit is what makes the classification a function of the per-key counts rather than of which side you happened to build. For a key with L ledger and F file copies: min(L, F) match, and the |L - F| surplus rows are
duplicate_matchtagged with the side that is over, degenerating toledger_onlyorfile_onlyexactly when min(L, F) is 0. Without the bit, surplus is only observable as a present-but-empty bucket, which can only ever happen on the probe side — so the same input reports different break classes depending on build order, and the smaller-side heuristic in bullet one silently decides which. - A genuine amount difference does not surface as
amount_mismatchhere, because the amount is inside the key — it surfaces as aledger_onlyand afile_onlysharing a reference. Promote those in a second, separate pass keyed on reference alone, recording signeddelta_minoras ledger minus file. Keep that promotion out of the exact pass. - Cost: O(N+M) expected time and O(min(N,M)) memory; the hit bit packs into the bucket header and changes neither bound. The constant is the hash map, roughly 60 to 100 bytes per entry in most runtimes, so 12 million rows is order 1 GB — measure it rather than assert it.
- When neither side fits, use a grace hash join: partition both sides with the same hash function into P spill files so a key lands in the same partition on both sides, then join partition by partition in memory. Cost is two extra sequential passes; skew inside one partition is the failure mode, handled by re-partitioning that partition under a second hash.
- Sort-merge is the alternative at O(N log N + M log M) with external sort, and it wins when the file already arrives sorted by reference or the output must be ordered. It also gets the surplus classification for free, since a merge sees L and F side by side. Whichever you pick, do not widen the amount comparison to make breaks disappear: a tolerance wide enough to absorb rounding is wide enough to absorb a real loss.
Worked solution (30 min)
- Build the bucket map over the ledger side and assert that total bucket length equals row count — that single assertion catches the single-row-map bug immediately.
- Probe with the file side, popping on match and setting each touched bucket's hit bit, then drain the leftovers: hit means surplus, never hit means absent on the probe side.
- Fixture of 11 ledger rows and 11 file lines: 7 keys matching one-to-one, one key where the ledger has 2 copies and the file has 3, 2 ledger-only rows and 1 file-only line.
- Assert the counting identity
2*matched + ledger_only + file_only + duplicate_match == N + M; it holds under either build order, so it is necessary but not sufficient — it does not catch a misclassification that moves a row between the three break classes. - Swap build and probe sides and re-run, asserting the four counts and the surplus side are identical, not merely mirrored. Then delete the hit bit and re-run the swap to watch the surplus file copy get reclassified as an absence.
Expected result: 9 matched pairs, 2 ledger_only, 1 file_only, 1 duplicate_match — the file's surplus third copy — satisfying 2*9 + 2 + 1 + 1 = 22 = N + M. Building on the file side and probing with the ledger returns those same four counts with the surplus still attributed to the file, because every label is derived from L and F. Without the hit bit it does not: the swapped run reports ledger_only 2, file_only 2, duplicate_match 0 — the identity still sums to 22, and the surplus file copy has been silently reclassified as an absence, which is the failure this fixture exists to catch.
Verify it yourself
- The counting identity holds on every run, including the skewed one.
- The full break classification — counts and per-row labels — is identical when build and probe sides are swapped. A diff there means a label is being read off the join's mechanics rather than off the data.
- Signed
delta_minoron a promotedamount_mismatchis ledger minus file, and flips sign when the sides are swapped. - Forcing P = 1 in the grace-hash path reproduces the in-memory join's break set exactly, proving partitioning is not changing the answer.
Follow-up
- The file nets three fee lines into one batch total. Which pass catches that, and what is its stopping rule?
- The processor's business date sits one cutoff behind yours for forty minutes of traffic. What does that do to the exact join, and what does it do to break ages?
- The same break recurs on the next run. Why must it link to the existing
reconciliation_breakrow rather than open a second one?
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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?
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.
Worked solution (35 min)
- Seed one account with balance_minor = 100 and floor_minor = 0, then run two concurrent 60-unit withdrawals under READ COMMITTED using SELECT-then-UPDATE and record the final balance.
- Replace it with the single-statement conditional UPDATE and re-run the same race, asserting on rows affected rather than on an exception.
- Re-run under SERIALIZABLE with the read-modify-write shape, count the 40001 aborts, and add a retry loop with a fixed attempt cap and jittered backoff.
- Measure committed writes per second against that single row, then write the drift query: sum signed entries per account and compare against balance_minor.
Expected result: The READ COMMITTED read-modify-write leaves the balance row at 40 after two successful 60-unit withdrawals, so the entries sum to -20 against a floor of 0 while the row says otherwise. The conditional UPDATE lets one withdrawal commit and reports zero rows affected for the other, ending at 40 with entries summing to 40. Under SERIALIZABLE the read-modify-write shape produces 40001 aborts that the bounded retry resolves to the same refusal.
Verify it yourself
- The refused path returns rowcount 0, not an exception; assert on the rowcount.
- The drift query returns zero difference after the correct run and exactly -60 after the lost-update run, which is the anomaly made visible.
- Under SERIALIZABLE, the retry loop terminates: record attempts per transaction and confirm the cap is reached rarely rather than never being reached at all.
- Reverse the lock order in a two-account transfer test and observe 40P01 rather than a silent wrong answer.
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?
What have you done using JavaScript?
Medium
Approach
- State your assumptions explicitly before working the problem.
- 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.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Can you explain the different types of integrations?
Medium
Approach
- State your assumptions explicitly before working the problem.
- Say what you would check first and why it is the highest-information step.
- Work from the requirement backwards to the design.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Design the error taxonomy for a money-moving API
Easy · error taxonomy · retries · api contract
The caller is a merchant's server-side integration behind a 2 s timeout; on any non-2xx it consults a generic retry policy. Design the error contract for POST /payments: the response media type, the fields every error carries, and the classification a caller switches on. Cover input validation, authentication, state conflicts, rate limiting, indeterminate upstream outcomes, and issuer declines. For each class give the status code, whether a retry is safe, and whether that retry reuses the idempotency key. Deliver the schema and the classification table.
Approach
- Fix the envelope first: application/problem+json (RFC 9457, which obsoletes RFC 7807) with type, title, status, detail and instance, plus two extension members, a stable machine code and a retry class. Callers switch on the code; the prose is for humans and must be free to change.
- Separate transport failure from business outcome. An issuer decline is a completed call with a definite answer, so it belongs in the payment representation as 200 with status failed and a decline code. Putting it behind a 4xx or 5xx invites every generic retry layer in the caller's stack to re-present a card the issuer already refused.
- Classify the rest on two axes: is the resulting state known, and is the caller at fault. Known and caller's fault gives 400/422 for validation and 401/403 for credentials, never retryable. Known and ours gives 409 for a fingerprint or version conflict, retryable only after the caller changes something. Unknown gives 502/504 or an explicit status of processing, retryable with the original key. Overload gives 429 with Retry-After.
- Write the rule for the unknown class explicitly: the retry must carry the same idempotency key and the server must replay the stored response, so repeated attempts converge on one effect rather than several.
- Pin the taxonomy with a test that asserts every error path emits a code from the enumerated set. Without it the catch-all handler produces an uncoded 500 and the caller's switch silently falls through to its default branch, which is usually retry.
Worked solution (20 min)
- Write the problem+json schema: type, title, status, detail, instance, code (stable enum), retry_class (never | after_backoff | same_key).
- Enumerate ten concrete failures from the real path: missing currency, unknown merchant, expired instrument token, key reused with a different body, concurrent duplicate key, quota exceeded, processor timeout, processor unreachable, issuer decline, insufficient funds.
- Place each one in the table with its status code, code, retry_class and the caller's action in one line.
- Re-read the two decline rows and confirm they are 200 responses carried in the payment representation, not entries in the error envelope.
- Write the caller-side switch in pseudocode and check that every branch is reachable from the table and that nothing falls through to a default that retries.
Expected result: A table in which declines are 200 responses, exactly three classes are retryable (429, indeterminate 5xx, and explicit processing), every retryable-with-key row states that the original Idempotency-Key must be resent, and no 4xx other than 429 is marked retryable.
Verify it yourself
- Every error carries a stable code distinct from its HTTP status, so two different 409s are distinguishable by a caller.
- A caller that implements only the retry_class column never re-presents a declined card.
- Replaying a completed idempotency key returns the original status code, including when that status was itself a 4xx.
Follow-up
- Two requests arrive with the same idempotency key while the first is still in flight. What status do you return, and what is the caller supposed to do with it?
- How do you add a new error code later without breaking a client that switches exhaustively over the current set?
- The caller reports that a 504 from you was followed by a successful payment they never saw. What in this contract lets them discover that without charging twice?
Duplicate captures appear only in production, roughly weekly
Hard · idempotency · race condition · read committed · concurrency
About once a week one payment is captured twice. The idempotency path is: SELECT id, response_body FROM idempotency_key WHERE scope = $1 AND key = $2; if no row, call the processor; then INSERT. The table has UNIQUE (scope, key). Logs for each duplicate show one successful capture pair and one HTTP 500 carrying SQLSTATE 23505. A 200-iteration sequential test passes, and a 50-thread version passes on a laptop but fails on the production-sized cluster. Explain why, and give the fix.
Approach
- Read the 23505 as evidence, not as noise. A unique violation on the INSERT proves two requests both passed the SELECT and both reached the INSERT, which means both had already called the processor. The duplicate charge happened before the constraint fired. The constraint is reporting the race; it is not causing it, and anyone who treats the 500 as the bug fixes the wrong thing.
- Name the interleaving precisely. Under READ COMMITTED each statement takes a fresh snapshot, so two concurrent requests with the same key can both run the SELECT before either INSERTs and both see zero rows. Raising the isolation level does not fix check-then-act by itself, because at SELECT time the first transaction has written nothing to conflict with; SERIALIZABLE only converts the race into a 40001 abort that the code must then retry.
- Explain the reproduction gap rather than calling the bug rare. The window is the duration of the processor call: milliseconds against a stub on a laptop, hundreds of milliseconds against a real processor. Production retries are also correlated, since a client timeout produces a second request at a predictable delay, while a thread-pool test fires all 50 within microseconds and lands them on the same side of the window. The local test is not exercising the window at all.
- Restructure so the database picks the winner before any side effect. INSERT the key first with ON CONFLICT (scope, key) DO NOTHING RETURNING id. A returned id means this request owns the effect and may call the processor. No returned row means another request owns it, and note that RETURNING yields nothing on conflict, so the loser must then SELECT the existing row explicitly. Mutual exclusion now lives in one atomic statement and the window is gone.
- Give the loser something to read. If the winner is still in_progress, the loser must neither error nor perform the effect: it polls the row inside the caller's timeout budget and replays response_status and response_body once status is completed, or returns 409 when request_fingerprint differs. Without this, deduplication turns a successful payment into a visible failure.
- Close the crash window separately, because the atomic insert does not cover it. If the winner dies after calling the processor and before writing completed, the row stays in_progress with a stale locked_at. Recovery must query the processor for that key or client reference rather than assume either outcome, which is why the processor's own idempotency key has to be the same value, generated once by the caller and reused on every attempt.
Follow-up
- The same key arrives with a different request_fingerprint. What do you return, and why is returning the cached response wrong?
- What is your locked_at staleness threshold, and what does the sweeper do when it finds an expired one?
- Write the test that fails on the laptop. What do you have to inject to make the window observable there?
Suggested practice checklist
Roughly ninety minutes on weeknights with one longer weekend block. The plan cuts scope rather than compressing everything, on the assumption that one thing finished per night beats four half-started.
Day 1 — Fix the scope and take a cold baseline
- Read the role description and write the three things the loop will almost certainly test, then write an explicit not-doing list and keep it visible all week.
- Take one twenty-five-minute coding problem and one fifteen-minute design prompt cold, and write the single sentence naming what blocked each, because those two sentences decide where the remaining evenings go.
- Set the week's rule: one thing finished every night, including the night you only have forty minutes.
Deliverable: A one-page scope with a not-doing list and two cold attempts, each carrying one sentence on what blocked it.
Day 2 — One pattern, written three times from blank
- Choose the single pattern most likely to appear in your loop and write it three times from an empty file rather than editing the previous attempt.
- On the third pass, write the invariant as a comment before the loop body and the complexity before the first line of code.
- Stop at ninety minutes even if the third version is imperfect, and write the one thing you would fix given another hour.
Deliverable: Three independent implementations of the same pattern plus a note on what changed between them.
Day 3 — One design, only to the depth you can defend
- Take one system shape and go only as far as requirements, interface and data model, refusing to draw a box you could not survive a follow-up about.
- Attach one number to each non-functional requirement, deriving it rather than asserting it, and write the assumption the number rests on.
- Write the one tradeoff you are choosing against and the observation that would make you reverse it.
Deliverable: One design at interface-and-schema depth with derived numbers and one written reversible tradeoff.
Day 4 — Only the fundamentals you will have to defend
- Write, in under two hundred words each, the answers to the two questions that follow almost any implementation: why this structure and not the obvious alternative, and what happens to this code at a hundred times the input.
- Write what an index actually costs: faster lookups on the indexed columns against a write that now maintains a second structure, plus the cases where the planner declines to use it anyway, low selectivity, or a predicate wrapping the column in a function.
- Delete any answer you cannot deliver aloud in under a minute, since an answer that needs reading is not an answer you have.
Deliverable: Three written answers, each under two hundred words and each timed aloud.
Day 5 — Your own work, timed
- Write a ninety-second and a four-minute version of your main project and time both aloud rather than reading them.
- Prepare the two follow-ups that always come: what you would do differently, and how you knew it worked.
- Put one number in the first sentence and be ready to say exactly where it came from and what it excludes.
Deliverable: Two timed narratives with one defensible number in the opening line.
Day 6 — The one full rehearsal, in the weekend block
- Run a sixty-minute mock covering a coding round and a design round in one sitting with no break, because sustained attention is the thing evenings have not trained.
- Immediately afterwards, and before hearing any feedback, write the three moments you lost the thread.
- Spend the rest of the block only on those three moments, and on nothing you merely feel shaky about.
Deliverable: Mock notes naming three failure moments with a specific fix written under each.
Day 7 — Taper
- Write the twenty-minute warm-up you will actually do on the morning: one problem you can already solve from a blank file, one design you can narrate, and nothing you have never seen.
- Re-read only your own notes from this week and open no new material.
- Write the logistics down: the editor or shared document you will be working in, whether execution and lookups are permitted, and the sentence you will use when you do not know something.
Deliverable: A one-page card holding the design structure, the project numbers, and the logistics.
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.
Where does this role fall in your career path?
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 would you do differently if you ran that again?
- How did you know your change caused the improvement?
Can you walk me through your experience handling production incidents?
Medium · behavioural and engineering judgement
Approach
- Name the disagreement and how you resolved it with evidence.
- Close with what you would do differently, concretely.
- Pick a story where you made the decision, not one where you watched it.
Follow-up
- What did you decide not to do, and why?
- What would you do differently if you ran that again?
How have you dealt with challenges in past projects?
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.
- Pick a story where you made the decision, not one where you watched it.
Follow-up
- How did you know your change caused the improvement?
- What did you decide not to do, and why?
Why are you interested in this opportunity at the Bank of Canada?
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.
- Name the disagreement and how you resolved it with evidence.
Follow-up
- What would you do differently if you ran that again?
- What did you decide not to do, and why?