Interview guides · American Modern Insurance
American Modern Insurance Software Engineer Interview Questions & Guide 2026
Caching practice for American Modern Insurance software engineer prep: settle the staleness budget before choosing an invalidation strategy.
The 60-second brief
As a Software Engineer at American Modern Insurance, you play a vital role in maintaining and evolving the technical infrastructure that supports a leader in the specialty insurance market. This position is not merely about writing code; it is about building robust, scalable solutions that enable the business to manage complex risk profiles and deliver exceptional service to policyholders. Your work directly impacts the efficiency of internal operations and the reliability of digital tools used across the organization.
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
- Model money movement as balanced double-entry postings
- Store money as integer minor units
- Name the isolation level each invariant requires
Overview
You will find that this role offers a blend of stability and technical challenge. You will work within teams that value clear communication and practical application of engineering principles. Whether you are optimizing database queries or designing object-oriented components, your primary objective is to contribute to a stable, high-performing technical environment. It is a position for engineers who appreciate the intersection of insurance domain knowledge and solid software craftsmanship.
The American Modern Insurance interview process
American Modern Insurance candidates report 3 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
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 Discussions
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
Where candidates lose points
Calling a compensating action a rollback
A saga's compensation is a new, externally visible business event, not an undo. A refund after a capture leaves both movements on the customer's statement, may not return the scheme fee, and lands days later rather than immediately. Designing a multi-service flow as though the compensation restores the prior state produces flows that turn out to be unimplementable at the final step, when the thing that needs undoing has already left the building. The sequence has to be ordered so the irreversible step is last and the reversible ones precede it, with an explicit pending state shown to the customer while a compensation is in flight.
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).
Not asking what the system looks like if it dies halfway through
For any multi-step write, say what state remains if the process stops between step two and step three, and what brings it back: a single transaction, a saga with compensating actions, an outbox, or a reconciliation job. Partial failure is routine at any real call volume, so 'that shouldn't happen' is an answer with nothing behind it.
Listing technologies instead of trade-offs
Name the property the design needs first, such as ordered range scans, multi-entity transactions, cheap appends, or a predictable p99, then pick something that provides it and say what it gives up in exchange. Almost any component is defensible once you state the requirement it satisfies and the one it sacrifices.
Questions & practice
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
- 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:
findandunionamortise 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. - 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 separateroot -> canonical_party_idmap 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. - Never rewrite
ledger_entryoraccount. 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. - 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. - 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. - 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.
Worked solution (25 min)
- Implement disjoint-set union with
parentandsizearrays plus aroot -> canonicalmap, keepingfinditerative with path halving so a ten-million-element chain cannot exhaust the stack. - Apply events sorted by
(merged_at, loser_party_id), setting the new root's canonical label to that event'swinner_party_id. - Fixture: A into B, C into D, B into D applied in that order, plus a repeat of A into B and a self-merge of B into B.
- Expand to the flat map and assert all of A, B, C and D resolve to the same canonical id.
- Shuffle the event order, re-run, and assert the flat map is identical — this is the test that catches an order-dependent canonical label.
Expected result: All four ids resolve to D's canonical id; the repeat and the self-merge are no-ops; the flat map is byte-identical across every shuffle, because events are sorted deterministically before application.
Verify it yourself
- After path compression,
finddepth stays below 5 on a ten-million-id adversarial chain. - The count of distinct canonical ids equals the id count minus the number of non-redundant merges.
- No
UPDATEstatement touchesledger_entryoraccountanywhere in the solution.
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?
Compute peak held exposure from overlapping authorisation holds
Medium · sweep line · intervals · authorisation holds
An account has up to 2 million authorisations on one business date: (auth_id, amount_minor, created_at, expires_at) with the hold live over the half-open interval [created_at, expires_at), plus capture events (auth_id, captured_minor, captured_at) that reduce the hold at captured_at, and explicit reversals that drop the remainder to zero. Timestamps are microsecond-precision timestamptz. Return the maximum total held amount across the day and the earliest instant it is reached, with complexity. Then say what changes if the deliverable is the peak per minute instead.
Approach
- Expand each authorisation into signed delta events rather than reasoning about intervals:
+amountatcreated_at,-remainingatexpires_at,-captured_minorat eachcaptured_at,-remainingatreversed_at. The problem collapses to a running sum over a sorted event list. - Sort the 2n to 4n events by
(timestamp, sign)with negative deltas ordered first on a tie. The half-open convention forces that: at exactlyexpires_atthe hold is already gone, so a-must land before a+at the same instant or you report a one-microsecond peak that never existed. O(n log n) time, O(n) space. - Sweep once, tracking
running,bestandbest_at, taking the first instant that attains the maximum. Say out loud which tie rule you are using — 'the peak' is ambiguous when the same level is reached twice, and the caller needs to know which instant they are being handed. - If timestamps are bucketed (1,440 minute buckets for the per-minute variant), drop the sort for a difference array: add the delta at the start bucket, subtract at the end bucket, prefix-sum once. O(n + B) time and O(B) space, strictly better, at the cost of answering only at bucket resolution.
- Assert the invariant during the sweep:
runningmust never go negative. A negative total means a capture exceeded its authorisation, which is an invariant violation upstream rather than a sweep bug — fail loudly instead of clamping at zero and reporting a plausible number. - Handle carry-in: a hold created before the window contributes its remaining amount as the sweep's initial value, not as a
+event inside the window. Omitting that is the off-by-a-day that makes the first minute of every day look artificially quiet.
Follow-up
- An incremental authorisation raises an existing hold after the fact. Where does that event go, and does it disturb the tie rule?
- You now need the peak for 10 million accounts inside a nightly window. What changes, and what must the partitioning key be?
- The peak sizes a funding transfer. Does the business date or the timestamp decide which day that transfer lands on?
Rank merchants by unmatched break value in one streamed pass
Medium · top-k · heap · streaming · space-bounded
A reconciliation run streams up to 50 million break rows of (merchant_id, currency, delta_minor signed, break_type). Return the 50 merchants with the largest total absolute delta_minor in a single currency, from one pass, with memory that does not grow with merchant count — there are up to 8 million distinct merchants and room for roughly 200,000 counters. Give both an exact and an approximate design, state the error bound the approximate one actually guarantees, and say which you would put in the nightly job.
Approach
- Start by testing whether the constraint is real. Exact needs one hash map
merchant_id -> int64plus a size-50 min-heap: O(n) to aggregate, O(m log 50) to rank, O(m) space. At 8 million merchants and 16 bytes of payload that is a few hundred megabytes — quote the number before reaching for a sketch. - If it genuinely does not fit, exact still costs only two passes or an external group-by: partition by
hash(merchant_id) % Pto disk, aggregate each partition independently, then merge with a K-way heap. This is the answer whenever exactness is non-negotiable, which for money it usually is. - One-pass approximate: weighted Space-Saving with C counters. An item either hits an existing counter or evicts the minimum one and inherits its value as an over-estimate. With total weight W and C counters, every reported total over-estimates by at most W/C, and any merchant whose true total exceeds W/C is guaranteed to be in the table. Quote W/200,000 as a fraction of the day's break value.
- State what the bound does not give you: the ordering within the top 50 is not guaranteed, and a merchant just below the threshold can be missed entirely. The honest claim to an operations reader is 'contains everyone above 0.0005% of today's break value', not 'the top 50'.
- Decide the metric before either design, because
sum(abs(delta))andabs(sum(delta))are different questions: a merchant with offsetting +1,000,000 and -1,000,000 breaks is top-ranked under the first and invisible under the second. Break work wants the first. - Recommend exact (two-pass or external group-by) for the nightly job — it runs once, has hours of budget, and an analyst acts on its output. Keep the sketch for a live dashboard, where a cheap bounded approximation beats an exact number that is four hours stale.
Follow-up
- Totals are now needed per currency as well as overall. What does that do to the key and to the counter budget?
- Prove the Space-Saving over-estimate bound to me in two sentences.
- The nightly job restarts halfway. Is the aggregate idempotent, and what does the heap do with a partially consumed stream?
Rebuild a statement with a running balance from entries alone
Medium · window functions · frames · event tables
From ledger_entry(entry_id bigint and monotonic, account_id, direction, amount_minor, currency, business_date, posted_at) produce one month's statement for a single account: every entry in order, its signed amount, and the running balance after it, starting from a supplied opening balance. The account is a customer deposit, so a credit increases it. Also return the first business_date in the month on which the running balance went negative, or null if it never did. Write the query using window functions, state which frame you depend on, and say what makes the ordering deterministic.
Approach
- Sign the amount first: CASE WHEN direction = 'credit' THEN amount_minor ELSE -amount_minor END, because the sign lives in direction and never in the column. State the convention out loud, since a customer deposit is a liability of the institution and a credit increases it, while for an asset account the same expression inverts.
- Compute the running balance as SUM(signed) OVER (PARTITION BY account_id ORDER BY business_date, entry_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW), then add the opening balance as a scalar. The opening figure is a constant for the statement, not a second window.
- Be explicit about the frame, because it is the whole exercise: with an ORDER BY and no frame clause the default is RANGE UNBOUNDED PRECEDING AND CURRENT ROW, which includes every peer row tied on the ordering key. Ordering on business_date alone therefore reports the same end-of-day figure on every line of that day, and the statement still looks plausible.
- Make the order deterministic with a column that has no ties: business_date has ties by construction, entry_id is monotonic and unique, and posted_at is neither guaranteed unique nor correct as an ordering key when a backdated entry posts late.
- Get the first breach from the same computation rather than a second pass: wrap the running balance in a subquery and take MIN(business_date) FILTER (WHERE running_balance_minor < 0). LAG is the wrong tool here, because the question is about a level rather than a change.
- Support it with an index on (account_id, business_date, entry_id) so the partition and the order both come from the index; then confirm in EXPLAIN that there is no Sort node above the index scan.
Follow-up
- An entry for 3 March posts on 7 March, after the statement for that week was sent. Where does it appear, and what does the running balance do?
- The same statement has to be reproducible a year from now. What stops it changing, and what would silently change it?
- At what account size does deriving this per request stop being viable, and what would you materialise first, a daily closing balance or a monthly 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.
Worked solution (25 min)
- Insert a 12-instalment version 1 with effective_from at origination, then allocate payments against instalments 1 to 6.
- Apply the deferral: insert version 2 effective at the 2026-03-14 deferral instant, reproducing instalments 1 to 6 byte for byte and re-amortising 7 to 12, and set superseded_at on all twelve rows of version 1 to that same instant in the same transaction.
- Write the as-of query with the version-selection predicate and run it for instants resolved from 2026-03-01 and 2026-03-20 in the loan's booking zone, and for the deferral instant itself.
- Compare SUM(principal_minor) per version against the original principal and locate the rounding residual.
Expected result: Two schedule_versions for the loan. The as-of query returns version 1 for 2026-03-01 and version 2 for 2026-03-20, instalments 1 to 6 are identical across both versions, and SUM(principal_minor) equals the original principal exactly in each version.
Verify it yourself
- The live-version invariant is per version, not per row: SELECT COUNT(DISTINCT schedule_version) FROM loan_instalment WHERE loan_id = $1 AND superseded_at IS NULL returns 1, while COUNT(*) over the same predicate returns that version's instalment count - 12 here - so an assertion of one NULL row per loan would fail on the correct design.
- Run the as-of query for 200 random dates spanning the loan and assert it returns exactly one schedule_version every time.
- Run the as-of query at exactly the deferral instant and assert version 2 alone: the windows are half-open and version 1's superseded_at equals version 2's effective_from, so the boundary belongs to exactly one version.
- Attempt an UPDATE on an instalment with paid_principal_minor > 0 and confirm the trigger rejects it.
- Change the session TimeZone and re-run the boundary instant; with both columns timestamptz the selected version must not change, which is the test that the date column was actually retyped.
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?
Mention and describe one of the Object-Oriented Programming (OOP) concepts.
Medium
Approach
- Clarify what is being asked and what a complete answer contains.
- State your assumptions explicitly before working the problem.
- Work from the requirement backwards to the design.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
How do you apply specific design patterns or technical definitions in a real-world coding environment?
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
- How would you know your answer was wrong?
- What assumption would you test first?
What is the difference between .equals and hashcode?
Medium
Approach
- Say what you would check first and why it is the highest-information step.
- Work from the requirement backwards to the design.
- State your assumptions explicitly before working the problem.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
Redesign a capture endpoint whose retry produces two captures
Medium · idempotency · resource modelling · retries
The live contract is POST /payments/{intent_id}/capture with body {amount_minor}, returning 201 and a server-generated capture id. payment_intent holds amount_minor, captured_minor (CHECK captured_minor <= amount_minor) and version. The caller is a merchant batch job that captures at ship time, times out at 2 s and retries twice. Partial captures are legal, so several captures may sum to the authorised amount. Show exactly how this shape yields two captures from one intent, then redesign the resource and its identifiers so a retry is provably a no-op. State the uniqueness constraint and the second request's response.
Approach
- Trace the failure precisely. The server mints the identity, so nothing in the second request distinguishes a retry from a legitimate second partial capture: both are well-formed POSTs with the same amount to the same intent. Because partial captures are legal, no amount-based heuristic can tell them apart either, and at a 2 s timeout the caller cannot know whether the first attempt was lost on the way out or on the way back.
- Move identity to the caller: PUT /payments/{intent_id}/captures/{capture_id} with a client-chosen uuid. The operation becomes idempotent by construction, because the second PUT finds the existing row and returns the same representation with 200 rather than creating a second one with 201.
- Back it with a primary key on capture_id plus UNIQUE (intent_id, capture_id), and insert with ON CONFLICT DO NOTHING RETURNING so the duplicate reads the winner instead of erroring.
- Enforce the amount ceiling in the same statement rather than in application code: UPDATE payment_intent SET captured_minor = captured_minor + $amt, version = version + 1 WHERE intent_id = $1 AND captured_minor + $amt <= amount_minor. PostgreSQL re-evaluates that predicate against the newly committed row version when it unblocks, so two different capture ids racing cannot both pass. A read-then-check in application code lets both through.
- Decide what a PUT reusing a capture_id with a different amount returns: 409 with its own code. Ignoring the body and returning the stored capture turns a client bug into a silent wrong amount.
- Keep the POST form for callers that cannot mint identifiers, but require the idempotency key header and route it through the same uniqueness constraint. Two independent dedup mechanisms means two things to keep correct. Say in the docs that the capture_id is generated once per logical capture and reused across attempts, because that is now a correctness requirement living in the caller's code.
Worked solution (25 min)
- Reproduce the bug: call the POST form twice with the same amount against one intent and show two capture rows and captured_minor doubled.
- Implement the PUT form with a client-chosen uuid, primary key on capture_id, and ON CONFLICT DO NOTHING RETURNING feeding a read of the existing row.
- Fire 20 concurrent PUTs with one capture_id and assert one row, one processor call, 200 on every response after the first.
- Fire two concurrent PUTs with different capture_ids whose amounts together exceed amount_minor and assert exactly one succeeds while the other returns a ceiling violation.
- Repeat step 4 with the amount check written as SELECT-then-UPDATE and observe both succeeding, to show why the conditional UPDATE is the fix.
Expected result: One capture row and one increment of captured_minor for 20 concurrent identical PUTs, with 201 on the first response and 200 on the rest; for the two racing over-capture PUTs, exactly one commits and captured_minor never exceeds amount_minor.
Verify it yourself
- captured_minor equals the sum of capture rows after every test, with no CHECK violation logged.
- Replaying a capture_id with a changed amount returns 409 and leaves the stored capture untouched.
- The SELECT-then-UPDATE variant demonstrably fails the same test, which is the evidence that the constraint, not the code path, is doing the work.
Follow-up
- The scheme permits a small over-capture tolerance. Where does that live so the constraint stays a single statement?
- A caller retries with a new capture_id after a timeout. What does the system do, and what should the docs have said?
- How do you expose the list of captures so a caller recovering from a crash can tell what it already did?
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
- 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.
- 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.
- 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.
- 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.
- 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
For someone who has spent the last few years shipping features and reading other people's code, and who has not solved a timed problem from a blank file in a long time. Five days rebuild the primitives and the patterns that sit on them, working from invariants rather than remembered solutions, and the last two attach that back to the rest of the loop.
Day 1 — Rebuild the primitives by implementing them
- Implement a dynamic array with doubling growth and an operation counter, then change the growth rule to add a fixed sixteen slots instead, and time both for n of ten thousand, a hundred thousand and a million. The fixed-increment version resizes n/16 times at O(n) each, so its total work is quadratic; doubling is what makes append amortised constant.
- Implement a hash map with separate chaining and a load-factor resize, then insert ten thousand keys engineered to land in one bucket and record what happens to lookup time, so that average-case O(1) becomes a claim with a stated precondition rather than a reflex.
- For dynamic-array append and hash-map insert, write down which cost is amortised rather than worst-case, which single operation pays the whole bill, and what a system with a hard per-operation deadline would have to do instead.
Deliverable: Two working implementations plus a timing table showing the input at which each structure's advertised complexity stops holding.
Day 2 — Arrays under an invariant: two pointers, sliding window, binary search
- Solve longest-subarray-with-sum-at-most-K using a sliding window, then run it on an input containing negative numbers and watch it return the wrong answer: extending the window only moves the sum monotonically when every element is non-negative, and that precondition is the whole reason the technique works.
- Write the binary search that finds the first index satisfying a predicate rather than an exact value, put the loop invariant above the loop in a comment, and verify termination on the two inputs that break careless versions: the empty range, and a range where every element satisfies the predicate.
- Compute the midpoint as lo + (hi - lo) / 2 and write one line on why the obvious (lo + hi) / 2 is a genuine defect in a fixed-width integer type and a non-issue in a language with arbitrary-precision integers.
Deliverable: Three solved problems, each with its invariant written above the loop, plus one recorded input on which the sliding window is provably wrong.
Day 3 — Sorting, heaps, and the greedy argument that has to be proved
- Solve one top-k problem three ways, by full sort, by a size-k heap, and by quickselect, then write the values of n and k at which each becomes the right choice, along with quickselect's quadratic worst case and why a randomised pivot makes that unlikely rather than impossible.
- Implement bottom-up heapify and count sift-down steps to confirm it does linear work rather than n log n, because most nodes sit near the bottom of the tree and therefore move only a short distance.
- Take interval scheduling by earliest finishing time and write the exchange argument out in full: given any optimal schedule, swapping in the earliest-finishing interval keeps it feasible and no smaller. Then construct the weighted variant where that same greedy fails and name what has to replace it.
Deliverable: A three-way top-k comparison with measured crossover points, one written exchange argument, and one counterexample to a greedy rule that looks almost identical.
Day 4 — Recursion, memoisation, and the step to a table
- Take one problem with overlapping subproblems, such as edit distance or coin change, instrument the plain recursion with a call counter to show the blow-up, then add memoisation and re-count.
- Convert the memoised version to a bottom-up table and state the two properties you relied on: each subproblem's result depends only on its arguments, and the dependencies form a DAG you can enumerate in order.
- Rewrite one deep recursion with an explicit stack, then find the input length at which the original hits the interpreter's frame limit, which defaults to about a thousand frames in CPython, so you know when the rewrite is required rather than decorative.
Deliverable: One problem in three forms, naive, memoised and tabulated, with call counts for each and the input length at which recursion depth becomes the binding constraint.
Day 5 — Graphs, where most of the work is choosing the traversal
- Implement BFS and DFS over one adjacency list, then answer for each which finds a shortest path in an unweighted graph and which you would use to detect a cycle in a directed graph, including why the in-progress versus finished distinction matters for the second.
- Implement topological sort by in-degree, feed it a graph containing a cycle, and confirm the failure signature is that fewer than V nodes come out rather than an exception, then note that the order it produces is one of several valid ones.
- Run a shortest-path search on a graph with a single negative edge weight and show the wrong answer, then write the precondition Dijkstra actually needs, non-negative weights, because it finalises a node's distance the first time that node is popped, and name the algorithm you would switch to and its own limit.
Deliverable: A small graph library with BFS, DFS and topological sort, plus two inputs that produce documented wrong answers under the wrong algorithm choice.
Day 6 — One day for everything that is not an algorithm
- Sketch one system only to the depth a coding-heavy loop tends to reach: the endpoints, what the service stores, and the single query pattern that decides the schema. Stop at twenty-five minutes.
- Prepare the project answer for an interviewer who codes, which means rehearsing the two levels they push to: the specific thing you built, and why you chose that approach over the alternative they will name. Open with a number and be ready to say what it excludes.
- Prepare the answer to what you would do differently, choosing a real technical mistake with a specific fix rather than a complaint about process or staffing.
Deliverable: One design sketch at endpoint-and-schema depth, plus a project answer rehearsed to two levels of follow-up.
Day 7 — Solve out loud, under time
- Do three timed problems at twenty-five minutes each in a plain editor with no autocomplete and no execution until the end, then tally separately the failures that were syntax and the ones that were approach, because those two numbers call for different fixes.
- Narrate one solution from the first sentence, stating the approach and its complexity before writing any code, and rehearse the sentence you will use when you realise mid-solution that the approach is wrong.
- Re-solve from blank the two problems you were slowest on this week and compare the times against the day they first appeared.
Deliverable: A recording of one fully narrated solution and a tally that separates syntax failures from approach failures.
Know where your numbers came from
Bring the two or three numbers the story rests on and know how they were collected. A p99 whose timer starts inside your handler excludes the time a request spent queued, so it can sit flat while users wait longer. Give the window, the percentile and what the measurement left out, or drop the number.
Unblock an engineer on double-posted interest accrual
Easy · mentoring · idempotent batch · accrual
An engineer two years into their career has a nightly accrual job that double-posts interest for some accounts whenever the batch is partially re-run after a failure. They have spent two days on it and are now rewriting the batch runner. You have 30 minutes. Describe how you have unblocked someone without taking the keyboard: the question you asked first, how you chose between handing over the answer and handing over the method, what you left behind so the next person does not get stuck here, and how you knew they were unblocked rather than deferring to you.
Approach
- The probe is whether you grow people or absorb their work. Open with the diagnostic question rather than the solution: ask what identifies one unit of work, because the answer reveals immediately that accrual is keyed by (account_id, accrual_date) and that the job has no uniqueness on it.
- Redirect from the runner to the write. The rewrite is aimed at never re-running, which is unachievable; the property needed is that re-running posts nothing new, enforced by a unique index on (account_id, accrual_date) or by passing the same idempotency key to the ledger posting operation so the second attempt is a no-op rather than a second transaction.
- Choose deliberately between answer and method and say why. Two days in and blocked on the wrong layer is usually the moment to hand over the framing (restartable at account granularity, idempotent per unit) and let them write the code, because the lesson is the framing and the code is the easy part.
- Leave an artefact, not a conversation: a test that re-runs one account twice and asserts one posting, plus two lines in the runbook stating that per-account work must be idempotent because the batch is always partially re-run.
- Check that they are unblocked by asking them to predict the failure that the fix does not cover, such as a mid-run rate change producing two different correct amounts for the same key. If they can find the next edge themselves, they own it; if they ask you to confirm each step, they are deferring and you have hidden the block rather than removed it.
- Say what you deliberately did not do. Not fixing it yourself before the standup is the whole exercise, and a strong answer names the pressure it resisted.
Follow-up
- The unique index rejects the re-run, but the first run posted the wrong amount. How should the job behave now?
- How do you tell whether you taught them or just unblocked them, a month later?
- The same engineer is blocked again next week on a similar problem. What does that tell you about your first intervention?
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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?
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
- 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.
- 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.
- 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.
- 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.
- 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'.
- 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?