Interview guides · FTI Defense
FTI Defense Software Engineer Interview Questions & Guide 2026
Code reading practice for FTI Defense software engineer prep: work out what unfamiliar code does before judging it.
The 60-second brief
As a Software Engineer at FTI Defense, you will play a pivotal role in developing sophisticated technical solutions that support critical defense and national security operations. This position is not merely about writing code; it is about engineering high-stakes systems that require precision, reliability, and a deep understanding of complex requirements. You will work within a mission-driven environment where your contributions directly influence the efficacy of the tools used by FTI Defense's clients.
Ask how many rounds there are and what each one is before you plan your weeks, because the composition is what your hours should follow. A loop with three coding rounds and one short design conversation deserves a different split from the reverse, and whoever schedules it will usually say plainly which it is.
Focus areas
- Make every determination replayable from a frozen snapshot
- Version rules by statutory effective date, not release
- Enforce audit writes at the data-access boundary
Overview
The role demands a balance of robust technical execution and the ability to adapt to a fast-moving, high-impact industry. You will be expected to tackle challenging engineering problems, collaborate with multidisciplinary teams, and deliver results that meet rigorous standards. Whether you are working on legacy system integration or modern software development, your work will be central to the strategic objectives of FTI Defense.
Where to focus your preparation
Treat the sections below as preparation areas and confirm the format with your recruiter.
Preparation focus
No round sequence has been reported for this company, so work the categories below and confirm the format with your recruiter.
What to demonstrate
- Breadth across SQL, experimentation and product reasoning
- Ability to state assumptions before choosing a method
How to prepare
- Drill the practice exercises below and time yourself
- Prepare three quantified stories about decisions you drove
Where candidates lose points
Implementing eligibility rules as ordinary application code that ships on the release train, with thresholds as constants and reference tables edited in place
Rules change on statutory effective dates, are frequently backdated, and are sometimes corrected after decisions have already been issued under them. If the only record of a threshold is the value currently deployed, every historical determination becomes unreproducible the moment it changes, and an appeal cannot be adjudicated because nobody can demonstrate what the rule was. The fix is bitemporal: a rule_version keyed on the period it governs, plus the transaction time at which that text was recorded, so you can ask both 'what applied to this case' and 'what did we believe applied when we decided it'. Reference data the rules read has to be versioned the same way, because an UPDATE to a rate table is a silent rewrite of history.
Leaving accessibility conformance to a review at the end of the project
The obligation is usually statutory and expressed against WCAG success criteria at AA, and those criteria cut straight through component architecture rather than sitting on top of it. Custom select menus, modals and date pickers have to manage focus and expose correct roles and names; 2.4.11 Focus Not Obscured (AA) constrains sticky headers and cookie bars; 2.5.8 Target Size (Minimum) (AA) requires a 24 by 24 CSS pixel target unless a listed exception applies; 3.3.8 Accessible Authentication (Minimum) (AA) rules out a login step that depends on a cognitive function test with no alternative; and 2.2.1 Timing Adjustable (Level A) collides directly with the short session timeout a security review asked for, since the user must be able to extend it. Retrofitting these means rewriting the component layer and the session design, so they belong in the component contract and in the definition of done from the first sprint.
Hardcoding to the sample inputs
Solve the stated problem rather than the two examples; special-casing a literal to make a sample pass is obvious immediately and reads as either a misunderstanding or an attempt to fake progress. If you genuinely cannot generalise yet, say which part is a stub and what would replace it.
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
Find every determination a corrected reference table can change
Medium · graph-traversal · blast-radius · versioning
A reference table (a rate schedule, threshold list or jurisdiction list) is corrected: version v is superseded by v' governing [p1, p2]. You have a graph of about 1,800 rule_version nodes, 40,000 clause nodes and 5,000 reference-table-version nodes, with roughly 90,000 reads edges from clause to table version; each rule_version has a governing date range. Determinations number 60 million, each carrying rule_version_id and effective_from. Return the determination set that could change. Name the traversal, its complexity, and what you do if the graph contains a cycle.
Approach
- Traverse backwards from the corrected table version, not forwards from every rule. Reverse the reads edges once into an adjacency list keyed by table version, then breadth-first from v' to collect the clauses that read it and the rule_versions that own those clauses. One BFS is O(V + E), about 135k nodes and edges; the forward alternative asks reachability per rule_version and is O(V times E).
- Filter each edge by period intersection as you traverse. A rule_version only reads the corrected text if its governing range intersects [p1, p2]; an edge to a rule whose period ended before p1 is a historical fact, not a dependency, and following it inflates the replay set with determinations that provably cannot move.
- Intersect at the determination level with a second predicate, not by scanning 60 million rows: the affected rule_version_id set drives an index range scan on (rule_version_id, effective_from), and effective_from must fall inside [p1, p2]. The graph part is milliseconds; the determination part is what the index has to be built for.
- Detect cycles rather than assuming a DAG. A reference table derived from aggregate determination output and read back by a rule is a real configuration, and it is a defect, but a naive BFS with a visited set will terminate quietly and give an answer that hides it. Mark the cycle, report it, and cap the traversal depth so a malformed edge cannot stall the job.
- Report the set with its provenance: which clause chain connects each rule_version to the corrected table. A blast radius nobody can explain is a number an auditor will not accept, and the same chain is what lets a reviewer overrule an over-broad inclusion.
Follow-up
- The corrected table is read by a clause that is itself behind a feature guard never enabled in that period. Do you keep those rule_versions in the set?
- Two reference tables are corrected in the same maintenance window. Is the combined blast radius the union of the two, and when is it larger?
- The reads edges are inferred from a static scan of clause source rather than declared. What does that do to the soundness of your answer?
Replay sixty million determinations after a retroactive rule correction
Hard · bulk-replay · inverted-index · determinism
A regulator corrects three clauses of a ruleset with effect six years back. Sixty million determination rows fall under the affected rule family, each carrying rule_version_id, evidence_snapshot_hash and a trace of the clauses that fired. Evaluation is a pure function costing about 8 ms of CPU, but each replay must rehydrate the frozen evidence snapshot from a content-addressed store capped at 500 fetches per second shared with live traffic. Replaying everything is correct. Explain why it is not viable, and give the algorithm that is.
Approach
- Cost the naive run before optimising it. CPU is not the constraint: 60 million evaluations at 8 ms is about 133 CPU-hours and parallelises freely. The store is: 60 million fetches against a 500 per second ceiling is 120,000 seconds, roughly 33 hours of wall clock while consuming the entire budget live traffic also needs. The run is correct because replay is deterministic and unaffected determinations converge on the existing row through the partial unique index, so it is purely a cost problem.
- Build an inverted index from clause to determination. Either a separate determination_clause table indexed on clause_id, or a GIN index over the trace array. The three corrected clauses then yield a candidate set by index scan instead of a full scan, typically well under one percent of the 60 million rows.
- Recognise that the fired-clause filter alone is unsound. A correction that widens a guard makes a clause newly reachable, so determinations that never reached it before can change now, and those are precisely the denials the correction was issued to reverse. If the trace only records clauses that fired, the filter silently omits the population the regulator cares about.
- Close the gap with what the trace can actually support, and be explicit that the target is a sound superset, not an exact set. If the trace records evaluated-and-false clauses as well as fired ones, include both: every determination that can change is then selected, along with some that cannot — a determination that already passed the widened guard is selected and replays identical, because widening a guard cannot exclude an input it already admitted. That asymmetry is the correct trade. A false positive costs one rehydration; a false negative is an uncorrected determination. If the trace records only fired clauses, fall back to a wider superset: every determination under the affected rule_versions whose effective period intersects the corrected period, which is far larger than the clause filter and still orders of magnitude under 60 million.
- Deduplicate the rehydration, not the evaluation. Distinct evidence_snapshot_hash values are the unit of store work, and a single snapshot attached to several cases in a household or an appeal chain is fetched once and evaluated per determination. Order the candidate scan by hash so the cache hits are adjacent rather than scattered across the run.
- Make the run resumable and rate-limited by construction: a durable cursor over the candidate set, a token bucket sized to the share of the 500 per second ceiling you are permitted, and writes that rely on the uniqueness key rather than a read-then-insert, so a restart after a crash converges instead of producing a second current determination.
Worked solution (40 min)
- Write out the naive budget explicitly: 60,000,000 divided by 500 fetches per second, converted to hours, next to the 133 CPU-hours, and state which number is the constraint.
- Construct a ruleset with a guard clause G and a downstream clause C, where G is a pure reachability guard — it admits or blocks, and changing it changes nothing C reads. Build 1,000 determinations: 60 passed G and fired C, 90 evaluated G and failed it so C was never reached, and 850 took an earlier branch and never evaluated G at all.
- Apply a correction that widens G so all 90 previously-failing determinations now pass it and reach C. Establish ground truth first by replaying all 1,000 and recording which results differ; then run the fired-clause filter and count how many of that changed set it selects.
- Re-run with the filter extended to evaluated-and-false clauses, and compare its candidate set against the control's changed set in both directions separately.
Expected result: The control run changes exactly the 90 that the widened guard now admits. The fired-clause filter selects 60 and none of them changed, so it selects 60 useless candidates and misses all 90 real ones. The extended filter selects 150: a sound superset of the changed set, not a match for it. It contains all 90 changed determinations, and its other 60 candidates are the ones that already fired C — they are selected and replay byte-identical, which is the expected cost of soundness, not a defect. The 850 that never evaluated G are outside both filters and also replay identical, confirming they were correctly excluded rather than luckily unchanged.
Verify it yourself
- Every determination the control run reports as changed appears in the extended filter's candidate set: no false negatives on this fixture. The converse does not hold and must not be asserted — 60 of the filter's 150 candidates replay identical — so the filter is verified as a superset, and the run is sized for 150 rehydrations to correct 90 determinations.
- Re-running the extended filter twice produces the same candidate set and writes no second current determination for any case, which exercises the uniqueness key rather than trusting it.
- Killing the run at a random point and restarting from the cursor produces the same final state as an uninterrupted run.
Follow-up
- The correction changes an amount rather than an outcome. Does your candidate filter change, and does the notice obligation?
- A replayed determination comes back identical. Do you write anything at all, and what does an auditor need to see that you checked it?
- Halfway through the run the regulator issues a correction to the correction. What happens to the work already done?
Add N business days in constant time over amendable calendars
Hard · rank-select · civil-time · precomputation
Case Service computes sla_due_on at 1 to 2 thousand transitions per second, with higher bursts when overnight intake lands. A holiday table holds (jurisdiction_code, holiday_date, observed_date, calendar_version_id) across 200 jurisdictions and a 1990-2040 civil date span; a jurisdiction may not observe a given holiday, and calendar versions are amended over time. The statutory convention is that the period begins the day after the triggering act. Design the structure that answers 'add N business days to civil date D in jurisdiction J' in constant time, and state its memory cost.
Approach
- Work entirely in civil date ordinals, never in instants. A civil day is not always 86,400 seconds across a daylight-saving transition, so any arithmetic that adds an interval to a UTC timestamp is wrong twice a year in the direction that reports a late case as on time. Convert the triggering instant to a civil date in the case's zone once, at the boundary, and stay in ordinal space after that.
- Precompute two arrays per (jurisdiction, calendar_version): rank[d], the count of business days in the span up to and including ordinal d, and select[i], the ordinal of the i-th business day. Build both in one linear pass over the span using observed_date, not holiday_date, so a holiday shifted off a weekend lands where the jurisdiction actually observes it.
- The query is then two array reads: r = rank[D], answer = select[r + N]. This is correct for both cases without branching. If D is a business day, rank counts it and select[r + N] is N business days later; if D is a weekend or holiday, rank[D] equals rank of the last preceding business day, so select[r + N] still lands N business days after the period begins on the following business day. O(1) per query after an O(span) build.
- Cost it: 18,628 days in the span, two int32 arrays of roughly 74 KB and 52 KB, so about 126 KB per jurisdiction-version. Two hundred jurisdictions at six live versions each is 1,200 array pairs, roughly 150 MB resident. That is a deliberate trade of memory for a constant-time query at 2,000 per second; a day-walking loop is O(N) per query and, at a 45 business-day standard, is 45 bounds-checked iterations per transition instead of two.
- Select the calendar version bitemporally, because there are two different questions. A due date being set now uses the currently recorded version; recomputing a past due date for an audit uses the version recorded as in force when the original computation was made. Key the cache on calendar_version_id so an amendment invalidates exactly the affected arrays rather than the whole cache.
- Guard both ends of the span. A query for D outside [1990, 2040], or an r + N beyond the last business day, must raise rather than clamp: a clamped due date is a plausible wrong date, and this is the arithmetic that decides whether a case is late by law.
Follow-up
- A jurisdiction amends its calendar with retroactive effect, adding a holiday in a period whose sla_due_on values are already stored. Which stored dates are wrong and how do you enumerate them?
- The statute for one program counts calendar days, not business days, but still rolls a deadline off a non-business day. Does your structure answer that, and what changes?
- Two jurisdictions share a holiday list except for three dates. Is it worth sharing the arrays, and what does the sharing cost you when one of them amends?
A quarterly payout total that triples through two joins
Medium · join fan-out · aggregation grain · minor units · reversals
A quarterly report sums disbursed amounts per program. It joins case to determination (one current row per reopen cycle), determination to disbursement_instruction (one row per benefit period, plus compensating reversal rows carrying reversal_of_instruction_id), and case to case_event to count transitions. Totals come out roughly triple the payment rail's settled figure. Write the corrected query returning program_code, settled_minor and transition_count, and explain the arithmetic that broke. Amounts are integer minor units; do not reach for SUM(DISTINCT ...).
Approach
- Say the grain out loud for every table before writing a line:
caseis one row per case,determinationis one row per (case, reopen_cycle) once filtered tois_current,disbursement_instructionis one row per (determination, benefit period),case_eventis one row per transition. Joining a one-row-per-case anchor to two independent one-to-many children multiplies them: a case with 3 instructions and 12 events yields 36 rows, and every amount is counted twelve times. - Note the second fan-out most candidates miss:
is_currentis unique per (case_id, reopen_cycle), not per case, so a case reopened twice contributes three current determinations and multiplies the event branch again. - Reduce each branch to a common grain in its own CTE before joining. Aggregate instructions up to
case_idthroughdetermination, aggregatecase_eventtocase_id, then LEFT JOIN both ontocase— now each side contributes at most one row per case and the products cannot form. - Net the reversals arithmetically rather than by filtering:
amount_minorcarriesCHECK (amount_minor > 0), so a reversal is a positive row whose sign comes from its role —SUM(CASE WHEN reversal_of_instruction_id IS NULL THEN amount_minor ELSE -amount_minor END). Filtering reversals out instead leaves the original settlement in the total and overstates it. - Reject
SUM(DISTINCT amount_minor)explicitly: two genuinely separate benefit periods that happen to pay the same amount collapse into one, so it fixes the symptom by deleting real money.COUNT(DISTINCT case_event_id)is legitimate because ids are unique;SUM(DISTINCT)over a non-unique measure is almost never correct. - Keep every total in BIGINT minor units through the whole pipeline and divide only at render. A cast to float or a language-side
NUMERICrounded by the default rule reintroduces exactly the error the integer representation was chosen to avoid.
Worked solution (30 min)
- Build a fixture: one case with 3 settled instructions and 12 events, one case reopened twice, one case with a settled instruction and a later reversal, one case with events but no payment.
- Run the naive three-way join and record the inflated total, then compute by hand the multiplier for each fixture case to confirm the arithmetic matches the inflation.
- Rewrite with one CTE per child aggregated to
case_id, LEFT JOIN both, and re-run. - Add the reversal sign expression and confirm the reversed case nets to zero rather than to twice its amount or to nothing at all.
- Assert the result against a directly computed control total taken from
disbursement_instructionalone with no joins.
Expected result: settled_minor per program equals the control total computed from disbursement_instruction alone, with reversals netted; transition_count equals a direct COUNT(*) on case_event for the same window. The reopened case contributes its payments once, not three times, and the payment-free case appears with zero rather than being dropped.
Verify it yourself
- The query's
settled_minormatchesSUMover the instruction table computed with no joins at all — the only control total that cannot fan out. - Removing the reversal sign expression changes the total by exactly twice the reversed amount, confirming the expression is doing what you claim.
- Row counts of each CTE equal the distinct case count they cover, which is the direct proof that the grain is one row per case.
- The result type stays BIGINT end to end; no intermediate is a float or a rounded decimal.
Follow-up
- A reversal arrives in the following quarter for a benefit period inside this one. Which quarter's total moves, and how do you present a figure that changes after the books were shown to someone?
- Rewrite the instruction branch as a LATERAL subquery instead of a CTE. Does the plan change, and does the correctness argument?
- The report must also show cases with no payments at all. Which join becomes wrong if you move a predicate from the CTE's WHERE into the outer WHERE?
Measure time in status from an append-only case event log
Medium · window functions · event log · frame semantics · backdating
case_event(case_event_id, case_id, reopen_cycle, seq BIGINT, event_type TEXT, occurred_at TIMESTAMPTZ, recorded_at TIMESTAMPTZ) is append-only with UNIQUE (case_id, seq). Staff backdate occurred_at; recorded_at is when the row landed. A case can be reopened, so submitted and determined repeat. Write a query returning one row per decision episode with the interval from its submitted to its determined, plus a flag for episodes still open. State which column you order the window by and why the other one gives wrong answers, and say what your query returns for a cycle whose determined carries an occurred_at earlier than its submitted.
Approach
- Partition on the real grain,
(case_id, reopen_cycle). Withcase_idalone, an open cycle'ssubmittedtakes the next cycle'ssubmittedas its LEAD, so a cycle that never closed is reported closed with an interval spanning a cycle boundary. KeepLEAD(event_type)in the output so that is detectable rather than assumed. - Order by
seq, notoccurred_at.seqis unique per case by constraint, so the ordering is total and the result is deterministic.occurred_atis asserted by staff: adeterminedbackdated below its ownsubmittedswaps the two rows, thesubmittedrow becomes last in its partition, LEAD returns NULL, and a cycle that was decided is reported still open — it leaves the completed-cycle statistics entirely rather than showing up as a bad number. - Use LEAD over the filtered event set:
SELECT case_id, reopen_cycle, occurred_at AS submitted_at, LEAD(occurred_at) OVER w AS determined_at, LEAD(event_type) OVER w AS next_type FROM case_event WHERE event_type IN ('submitted','determined') WINDOW w AS (PARTITION BY case_id, reopen_cycle ORDER BY seq), then keep the rows whereevent_type = 'submitted'. WHERE is evaluated before window functions, so filtering to two event types first is both correct and cheap. - Separate pairing from measurement, because
seqonly fixes the first. Ordering byseqguarantees you paired the right two rows; it cannot repair a timestamp a human asserted, so a backdated cycle still yieldsdetermined_at - submitted_atbelow zero. Emit it as computed alongside(determined_at < submitted_at) AS asserted_interval_negative. Do not clamp withGREATEST(..., interval '0')and do not swap the endpoints: the negative value is the only evidence the entry is wrong, and the flag is what keeps it out of the published average without hiding it from the people who can correct it. - Carry the system-time measure beside the asserted one:
recorded_at - LEAD(recorded_at)inverted, i.e.LEAD(recorded_at) OVER w - recorded_at. Precondition for calling it non-negative:recorded_atis assigned by the database at insert and appends for one case are serialised — a per-caseseqissued under the case row lock gives you both properties at once. If a case can be appended to concurrently,recorded_atcan invert againstseqtoo and the flag must cover both measures. - Handle the open episode as data, not as a default: when LEAD returns NULL there is no
determinedyet, so emitdetermined_at IS NULLand astill_openflag. Coalescing tonow()silently converts a censored observation into a completed one and drags every average down. - Be explicit about frames. LAG and LEAD are not frame-sensitive — they address the partition directly — but the moment you add a running aggregate, the default frame is
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, where CURRENT ROW in RANGE mode means the last peer. Ordering on a tie-prone timestamp therefore makes peers of distinct events and sums them all at once; ordering on uniqueseqmakes RANGE and ROWS coincide.
Follow-up
- The published service standard is in business days in the case's jurisdiction. What does this query have to become, and where does the holiday calendar live?
- Two
determinedevents exist in one cycle because a correction was recorded. Which one does your query pair, and which one should it pair? - Rewrite the still-open flag so a cycle with
withdrawnbetweensubmittedanddeterminedis excluded rather than counted as open.
What are the fundamental differences between Java and C++?
Medium
Approach
- State your assumptions explicitly before working the problem.
- Clarify what is being asked and what a complete answer contains.
- Work from the requirement backwards to the design.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
How do you approach debugging complex technical issues?
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?
Partial form save that survives a three-day gap
Easy · write-heavy · optimistic concurrency · retention
Intake renders versioned form definitions and must persist partial submissions that constituents resume days later from a different device, a library machine, or a contact-centre agent's screen. Steady state is 80,000 form sessions a day; the final day of an enrolment window is eight times that, with 30 percent of it in the closing hour. Assume a 25-field form autosaved once per section. Specify the storage grain and key, the peak write rate you design for, how a resumed draft binds to a rule version, and when an abandoned draft is destroyed.
Approach
- Size it before designing it. 640,000 sessions at eight section saves each is 5.1 million writes; spread over a 10-hour active day that is about 140 saves a second, and the closing hour carrying 30 percent of volume is roughly 430 a second. That is a small write rate, so the binding constraint is durability and resumability, not throughput.
- Choose one row per draft holding the whole answer document as JSONB plus a row_version, not one row per field. A section save becomes a single UPDATE ... SET answers = $1, row_version = row_version + 1 WHERE draft_id = $2 AND row_version = $3, so a second open tab affects zero rows and is told it lost, instead of interleaving two versions of one person's answers.
- Make the resume key independent of the browser. Bind the draft to constituent_id when the session is authenticated; otherwise issue a claim code the constituent can write down or receive by SMS. A cookie does not survive a public terminal, and the population this form exists for is disproportionately on public terminals.
- Separate the two versions in play. Pin form_definition_version on the draft so the rendered questions stay stable across the gap, but do not freeze rule_version_id until submission, since the case is adjudicated against the ruleset in force at submission. If the ruleset changes mid-draft, re-render the affected sections and require re-confirmation rather than carrying answers to questions that no longer exist.
- Give the draft its own retention. An abandoned draft holds protected personal data, so assign a retention_class at creation with the clock running from last_touched_at, and destroy on schedule rather than leaving an unsubmitted form in the table forever.
Worked solution (20 min)
- Write the DDL for form_draft(draft_id, form_definition_version, constituent_id NULL, claim_code TEXT NULL, answers JSONB, row_version INT NOT NULL, last_touched_at TIMESTAMPTZ, retention_class) with a CHECK that constituent_id or claim_code is non-NULL.
- Derive the peak-day write rate from 640,000 sessions and eight section saves each, stating both the daily mean and the closing-hour figure.
- Write the conditional UPDATE with the row_version predicate and state explicitly what zero rows affected means to the caller.
- Write the submission step: freeze rule_version_id and the evidence snapshot hash, insert the case, and leave the draft in place under its retention class instead of deleting it.
Expected result: One row per draft with optimistic concurrency; roughly 140 saves a second mean and about 430 a second in the closing hour; a resume path that works with no cookie; form_definition_version pinned at draft creation while rule_version_id is frozen only at submission; and a retention class whose clock starts at last touch.
Verify it yourself
- Two concurrent saves from two tabs: exactly one commits, the other sees zero rows affected and is not silently discarded.
- A draft started before a rule change and submitted after it carries the post-change rule_version_id, and you can state the rule that makes that correct.
- A resumed draft is reachable on a different device with no cookie and no prior session.
Follow-up
- The form is open in two tabs and the constituent submits from the stale one. What does the server do, and what does the constituent see?
- A draft is saved at 23:58:30 on the closing day and submitted at 00:00:04. Which rule version applies, and is that a code decision or a policy decision?
- The answer document grows to 40 KB. What does each autosave cost in the storage engine, and would you split it?
Nightly transition batch collapses into deadlocks at peak
Medium · deadlock · lock contention · transaction size
The overnight batch applies 1,000 to 2,000 case transitions per second. At 02:10 the log fills with SQLSTATE 40P01, about 200 a minute, and the retry loop makes it worse. Each worker takes a list of case_ids, runs SELECT ... FOR UPDATE on case, inserts case_event rows, then updates case.status — 50,000 rows inside one transaction. Workers are sharded by hash of case_id but their input lists overlap where a case appears in more than one source file. Deliver an ordered checklist, name two independent causes, and give the fix for each.
Approach
- Read the deadlock report before theorising. The server log names both transactions, both blocking statements and both relations; classify each incident as same-table-different-row-order or different-tables-different-lock-modes. Guessing at this stage is how teams end up tuning the wrong knob.
- Cause one, row ordering: two transactions touching an overlapping set of case_id values acquire those rows in whatever order their input lists happen to be in, so one holds A waiting for B while the other holds B waiting for A. Sorting each transaction's target rows by primary key before acquiring makes a cycle between two same-shaped transactions impossible, and costs one sort.
- Cause two, lock modes: SELECT ... FOR UPDATE takes FOR UPDATE on the case row, while inserting a child case_event row takes FOR KEY SHARE on the referenced parent row, and FOR KEY SHARE conflicts with FOR UPDATE. Replace the read-then-write guard with one conditional statement — UPDATE case SET status = $1, row_version = row_version + 1 WHERE case_id = $2 AND row_version = $3 — which takes FOR NO KEY UPDATE, does not conflict with the child insert's key-share lock, and returns zero rows affected on conflict, which the caller must treat as a conflict rather than a success.
- Shrink the unit of work. Fifty thousand rows in one transaction holds every one of those locks until commit, so hold time is the whole batch and the probability that two transactions overlap approaches one. Chunk to a few hundred rows per transaction and commit; deadlock probability and everyone else's wait time fall together, and a failure now costs one chunk instead of the run.
- Keep retries but bound them: a deadlock victim is a normal outcome, so retry with jittered backoff at the chunk level, cap the attempts, and emit a counter so the effect of each change is measured rather than asserted.
- State what isolation does and does not buy here: raising to SERIALIZABLE adds serialization failures (SQLSTATE 40001) to handle and prevents write skew, but it does not remove a lock-ordering cycle between explicit row locks. That is an ordering fix, not an isolation fix.
Follow-up
- The retry loop re-runs the entire 50,000-row transaction. Walk through what that does to the deadlock rate over the next ten minutes.
- Which of your two causes would SERIALIZABLE change, and which would it leave exactly as it is?
- Chunked commits mean the batch is no longer atomic. What does a half-applied batch mean for case_event, and how do you make the batch resumable?
Suggested practice checklist
For a candidate senior enough that the loop turns on design and judgement rather than on whether the coding round gets finished. Five days build one system properly and then stress it; coding gets a single maintenance day, on the assumption that the risk at this level is an unexamined tradeoff rather than a missed algorithm.
Day 1 — Numbers before diagrams
- Build your own reference card of the figures you will re-derive all week: bytes for a realistic record, requests per second implied by a given daily active count, and the storage that a year at a given write rate produces. Derive each one rather than copying it, because the derivation is what survives a follow-up.
- Turn one product statement into capacity requirements. From ten million daily users at four writes and forty reads each, state the peak-to-average factor you are assuming and why, then produce peak write QPS, peak read QPS and a year of storage.
- Write the two numbers whose order of magnitude changes the design, the read-to-write ratio and the working-set size against memory per node, and state the threshold at which each one flips your answer.
Deliverable: A one-page numbers card and one worked capacity estimate with every assumption written down.
Day 2 — One system, from requirements to schema
- Spend the first ten minutes producing only functional requirements, non-functional targets with numbers attached, a p99 latency, a durability expectation, a consistency requirement, and an explicit out-of-scope list.
- Define the interface before the boxes: the three or four endpoints, their parameters, what each returns, and which of them are idempotent.
- Write the data model, then write the single access pattern that justifies it, and state what the schema would have to become if the dominant access pattern were the other one.
Deliverable: One design carried to endpoint-and-schema depth, with non-functional targets expressed as numbers and a written out-of-scope list.
Day 3 — The consistency you are actually buying
- Write out what a client sees under asynchronous replication when its write commits on the leader and its next read is served by a lagging follower, then write the two fixes, pinning that session's reads to the leader for a bounded window or carrying a version token the replica must reach, and the cost of each.
- Work the quorum arithmetic on paper for N of three with W and R of two, and separate what R + W > N does guarantee, that any read set intersects any write set, from what it does not: on its own it is not linearizability, and a sloppy quorum that accepts writes on nodes outside the preference list breaks even the intersection.
- Take two storage choices with different defaults, a single-leader relational store committing synchronously and a quorum-replicated store that converges eventually, and write the specific product behaviour that would be wrong under each, rather than a general statement about which is stronger.
Deliverable: A page separating what quorum overlap guarantees from what it does not, with one concrete product misbehaviour attached to each gap.
Day 4 — Failure is the design
- For one write path, work through the case where the client times out after the server has already committed, then design the idempotency key: who generates it, how long it is retained, and what the duplicate request returns.
- Express the retry policy as parameters rather than as a word: maximum attempts, base delay, backoff factor, jitter, and which error classes are retried at all. Then state why retrying a non-idempotent write without a key is a correctness bug and not merely waste.
- Compute the fan-out effect on tail latency. If a request waits on ten backends and each independently exceeds its p99 one percent of the time, the chance at least one is slow is 1 - 0.99^10, about ten percent. Then write why independence is the optimistic assumption and what correlates them in practice.
- Name the backpressure mechanism for one queue or one dependency in the design, a bounded queue with shedding or a concurrency limit, and write what the caller is told when it engages.
Deliverable: One write path with an idempotency design, a parameterised retry policy, and a written tail-latency calculation with its assumption named.
Day 5 — Scaling the hot path
- Choose cache-aside or write-through for one read path and write the staleness window each produces, then name the invalidation event and what the system does when that event is lost.
- Design against the stampede: either coalesce requests so only one recomputes a missing key, or refresh early with jittered expiry, and write why identical TTLs on keys populated in the same moment produce a synchronised expiry and a thundering herd.
- Shard one table by a key you choose, then answer the two questions that break the choice: which queries now require a scatter-gather, and what happens to the distribution when one tenant is a hundred times larger than the median.
- Write the cost of adding a node under plain modulo placement, where nearly every key moves, against consistent hashing, where roughly one key in n+1 moves, and state what virtual nodes are for.
Deliverable: A caching and sharding decision for one path, each with its failure mode and its rebalancing cost written beside it.
Day 6 — Keep the coding hand in, at the bar that applies to you
- Solve one medium problem in thirty minutes, then spend twenty more making it production-shaped: named invariants, validation at the boundary, and errors that distinguish a caller mistake from an internal fault.
- Write the tests you would require of a colleague's version of that function: one for empty input, one for the boundary, and one for the case the implementation is most likely to get wrong.
- Read a piece of your own code from six months ago and write the change you would ask for, phrased as you would actually phrase it in review.
Deliverable: One problem hardened to review standard, with its test list and one written review comment.
Day 7 — Defend it while being interrupted
- Run a forty-five-minute design mock with an interviewer briefed to change a requirement halfway, a tenfold traffic increase or a new strict consistency requirement, and to push on one number you estimated.
- Rehearse the two sentences a senior loop is listening for: naming the tradeoff you are choosing against and why, and saying what you would measure to learn that the choice was wrong.
- Prepare the design you regret: a real decision, the constraint that produced it, what it cost, and what you changed afterwards.
Deliverable: Mock notes recording how the design changed under the new requirement, plus a written account of one regretted decision.
The bug you decided not to fix
Engineers over-index on what they repaired. A stronger answer covers something you knowingly left broken: the alert you tuned down, the data inconsistency you documented instead of chasing, the cleanup you deferred past two quarters. Give the reasoning and the condition that would have reopened it, so it reads as a decision and not as neglect.
Can you explain your experience with specific software development lifecycles?
Medium · behavioural and engineering judgement
Approach
- State the situation in two sentences and spend the rest on the reasoning.
- Name the disagreement and how you resolved it with evidence.
- Close with what you would do differently, concretely.
Follow-up
- How did you know your change caused the improvement?
- What did you decide not to do, and why?
How do you handle working in a fast-paced environment with shifting priorities?
Medium · behavioural and engineering judgement
Approach
- Close with what you would do differently, concretely.
- State the situation in two sentences and spend the rest on the reasoning.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- What would you do differently if you ran that again?
- What did you decide not to do, and why?
Why do you want to work at FTI Defense?
Medium · behavioural and engineering judgement
Approach
- Give the blast radius: what could have broken, and what you measured.
- Pick a story where you made the decision, not one where you watched it.
- State the situation in two sentences and spend the rest on the reasoning.
Follow-up
- What did you decide not to do, and why?
- What would you do differently if you ran that again?