Interview guides · Bank Neo Commerce
Bank Neo Commerce Software Engineer Interview Guide
Bank Neo Commerce Software Engineer interview guide: Java live coding, concurrency, payment system design, a 7-day plan and reported questions.
The 60-second brief
Candidates describe Bank Neo Commerce as a neobank serving customers in Indonesia. Software engineers build, scale and maintain the backend systems behind its digital banking features, so the work is services that move and record money: optimising existing backend services, designing the architecture for new features, and turning product requirements into technical solutions alongside cross-functional teams. The requirements candidates describe list Java, data structures, microservices and relational databases as must-haves, with AWS or GCP, Docker or Kubernetes and event-driven architecture as extras.
This guide is for candidates interviewing for the Software Engineer role at Bank Neo Commerce. It covers the reported topics (Java live coding, concurrency, database optimisation, payment system design and a production-incident story), explains how to prepare each one, and ends with a seven-day plan and a behavioral set. No fixed round-by-round order is reported, so the guide is organised by topic rather than by stage.
Candidates report an HR screen first, then technical conversations with senior engineers and engineering leadership, including a Java live coding assessment and system design. Some stages are remote.
Focus areas
- Live Coding Interview
- Java
- System Design
Overview
The reported work is backend engineering for a bank, where a half-finished operation means money in the wrong place. Responsibilities candidates describe include building features end to end, taking part in code reviews, working with product managers on technical feasibility and with QA on test coverage, and monitoring services in production to track down performance bottlenecks. That is why the reported questions pair classic coding material with concurrency, data consistency and an incident story.
The technical areas candidates name are data structures and algorithms with attention to efficiency and memory, concurrency and threading, database optimisation (indexing, query performance, ACID), microservices communication (synchronous versus asynchronous calls), caching strategies including Redis, and protecting sensitive user data. Transaction atomicity and data security are the banking-domain themes candidates are told to study. Treat these as one connected story: a payment is a database transaction, a concurrency problem, a distributed-systems problem and a security problem at the same time.
The most concrete warning in the reports is about language. The live coding assessment is reported to require Java, and a candidate who is not proficient in the requested language may be moved to system design instead, which changes how the session is judged. So the first preparation step is administrative: confirm the language with your recruiter, then practise in it until collections, generics, streams and the java.util.concurrent package need no looking up.
Preparation depth matters more than breadth here. Pick a small set of artefacts you can reproduce from memory: an LRU cache, a thread-safe account transfer, a ledger schema with the right indexes and constraints, and a payment-processing design with idempotency and a clear consistency story. Then rehearse explaining the why behind each choice, because candidates are repeatedly told to justify technology and architecture decisions and to ask clarifying questions before designing.
Where to focus your preparation
No strict round-by-round sequence is reported. The topics below are the stages and subjects candidates name, in the order the process is usually described.
Preparation focus
Candidates describe a process that often opens with an HR screen covering your background, salary expectations and general fit, followed by sessions with senior engineers and engineering leadership. Topic labels attached to the reports are Live Coding Interview, Java, System Design, Technical Interview (Senior Engineer) and Technical Interview (Engineering Leader). Some stages are remote, and the live coding is reported to require Java. Because the sequence and the length of each stage are not reported, treat this as a preparation map: confirm the format and the coding language with your recruiter, then cover Java coding, concurrency, relational database behaviour, microservices and caching, and a payment-style design. Reported advice also includes asking clarifying questions before you design and being ready to explain why you chose a technology or pattern.
What to demonstrate
- Working Java code written live, with complexity stated and defended
- Concurrency and threading reasoning for high-transaction banking systems
- Database fundamentals: indexing, query performance and ACID guarantees
- System design choices across databases, caching layers, load balancers, API design and service communication
How to prepare
- Message your recruiter to confirm the coding language, whether the session is live and which stages are remote
- Re-implement an LRU cache, a bounded blocking queue and a thread-safe account transfer in Java without an IDE hint, stating the complexity of each
- Write a payment design on one page: components, the idempotency key, the ledger table, what is cached in Redis and what is not, and which calls are asynchronous
- Prepare a spoken account of one production bug and a one-minute answer to your background and salary expectations for the HR screen
Where candidates lose points
Arriving at the live coding session without having confirmed or practised the requested language
Ask the recruiter which language the session uses before anything else. Candidates report Java, and being weak in the required language can move you into a system design conversation instead. Practise collections, generics, streams and java.util.concurrent in plain Java without autocomplete.
Stating a complexity without saying which data structure operation it comes from
When asked how a solution could be optimised, name the dominant operation, the structure behind it (HashMap lookup, heap push, sorted traversal) and its amortised or worst-case cost, plus the space trade-off. Practise this on an LRU cache and a top-k problem until it takes two sentences.
Answering the concurrency question with "I would use synchronized" and nothing else
Name the shared state, the invariant that is briefly false, and the smallest tool that protects it: an atomic, a ConcurrentHashMap compute, a lock with consistent ordering for two accounts, or a database row lock. Mention visibility, check-then-act races and deadlock, and say how you would test it with many threads.
Designing a payment system with no answer for retries, partial failure or duplicate requests
Put an idempotency key, a ledger written inside one database transaction, and an outbox or saga for downstream calls on the page before you draw caches and load balancers. Say explicitly which data must be strongly consistent and where staleness is acceptable.
Telling the production-bug story as a vague tale of long hours rather than a sequence of decisions
Structure it as symptom, hypothesis, evidence you gathered, fix, and what you changed so it could not recur. Keep your own actions and the tools you used (logs, metrics, a reproduction, a rollback) in the foreground.
Questions & practice
Can you explain the complexity of your solution and how it could be further optimized?
Medium · Technical and Coding Proficiency
Approach
- State time and space separately in named variables (n items, k distinct keys, m queries), and count auxiliary space honestly: a HashMap of n entries is O(n) extra, and a recursive DFS adds O(depth) stack, which is O(n) on a degenerate tree.
- Separate worst, expected and amortized cases: ArrayList.add is amortized O(1) because the backing array grows by about 1.5x; HashMap.get is expected O(1), and since Java 8 heavily colliding buckets are converted to trees, which bounds lookups in them at O(log n) when keys are Comparable.
- Find the dominant term and attack only that: a nested-loop pair search at O(n^2) becomes O(n) time with a HashSet (paying O(n) memory), or O(n log n) with sort plus two pointers if the input may be reordered; repeated range sums drop to O(1) per query after an O(n) prefix-sum pass.
- Argue the lower bound before promising more: if every element must be read the answer is at least Omega(n), and comparison-based sorting cannot beat Omega(n log n), so past that point the remaining wins are constant factors.
- Name Java constant-factor costs: boxing int into Integer in a HashMap<Integer,Integer> allocates objects and adds GC pressure where an int[] indexed by value would not; concatenating a String with + inside a loop copies it every iteration (O(n^2) total) where a StringBuilder is amortized O(n).
Follow-up
- What changes if the input arrives as a stream and you cannot hold all of it in memory?
- Your solution is O(n log n); for what input sizes would the simpler O(n^2) version actually run faster, and why?
- Which input drives your worst case, and how likely is real data to hit it?
Implement a solution for [X] data structure problem using your preferred language.
Medium · Technical and Coding Proficiency
Approach
- Clarify the operations and how often each runs before choosing: HashMap for keyed lookup, ArrayDeque as a stack or queue (preferred over the synchronized legacy Stack and over LinkedList), PriorityQueue for repeated min or max, TreeMap when floorKey, ceilingKey or ordered iteration are needed at O(log n).
- Know the standard compositions: top-k of n items with a min-heap capped at size k runs in O(n log k); an LRU cache is a HashMap from key to node plus a doubly linked list with sentinel head and tail, giving O(1) get and put. LinkedHashMap with accessOrder=true and removeEldestEntry is the library shortcut, so ask whether a hand-rolled version is wanted.
- Avoid Java traps that break otherwise-correct code: comparing Integer objects with == (only -128 to 127 are cached by default), mid = (lo + hi) / 2 overflowing (use lo + (hi - lo) / 2), a comparator written as a - b overflowing (use Integer.compare), and removing inside a for-each loop, which throws ConcurrentModificationException (use Iterator.remove or removeIf).
- State the class invariants before writing methods, then trace small cases by hand: empty input, one element, duplicates, capacity of 1, and the call sequence that triggers eviction or resizing.
- Close with per-operation time and memory, and with thread safety: the standard collections are not thread-safe, so a shared instance needs ConcurrentHashMap, a lock around compound operations, or confinement to a single thread.
Follow-up
- How would you make this structure safe to share across request threads, and what does one global lock cost under load?
- Why ArrayDeque rather than Stack or LinkedList here?
- What changes if the data no longer fits in one JVM's heap?
How do you handle concurrency and multi-threading issues in a high-traffic banking application?
Medium · Technical and Coding Proficiency
Approach
- Name the shared mutable state and the exact race: two debits both read a balance of 100, both pass the check amount <= 100, both write 100 - amount, and one update is lost. Check-then-act and read-modify-write sequences are the patterns to look for.
- Inside one JVM: synchronized or ReentrantLock around the compound operation, AtomicLong or LongAdder for counters, ConcurrentHashMap.compute for atomic per-key updates, and volatile only for visibility, since it makes no compound operation atomic. Then point out that with several application instances behind a load balancer, in-JVM locks protect nothing across instances.
- Move the invariant into the database: one conditional statement such as UPDATE account SET balance = balance - ? WHERE id = ? AND balance >= ? with an affected-row check, SELECT ... FOR UPDATE for pessimistic locking, or a version column for optimistic locking with retry. Under READ COMMITTED a plain read-then-write in application code can still lose updates.
- Prevent deadlock on transfers by locking both accounts in a fixed order (lower account id first), keep transactions short, never hold a row lock or pooled connection across a call to an external service, and set lock and statement timeouts.
- Make retries safe and overload bounded: a client-supplied idempotency key stored under a unique constraint means a retried request returns the first result instead of debiting twice; size thread pools and connection pools together, with bounded queues so excess load is rejected rather than queued indefinitely.
Follow-up
- Transfers from A to B and from B to A start at the same moment: walk through what each one locks and whether they can deadlock.
- One merchant account receives most of the traffic; how do optimistic and pessimistic locking each behave under that contention?
- How would you write a test that reliably exposes this race?
Describe a challenging bug you encountered in production and your step-by-step resolution process.
Medium · Technical and Coding Proficiency
Approach
- Choose a bug with a real mechanism and real stakes, and open with the symptom, what was affected (failed or duplicated transactions, latency, error rate) and how it was detected, in two or three sentences.
- Show containment before root cause: what I did first to stop the damage (rollback, feature flag, pausing a consumer, scaling out) and why that step was safe to take.
- Walk the narrowing sequence with evidence at each step: line up the start time with deploys and config changes, follow one failing request through logs by trace or correlation id, then use the matching tool (jstack thread dumps for hangs or pool exhaustion, a heap dump for growing memory, GC logs for pauses) and reproduce with the same data shape outside production.
- State the root cause as a mechanism, for example a transaction held a pooled connection while calling a slow external API, so the pool drained and unrelated requests timed out; then the fix, the regression test, and the production metric that confirmed it worked.
- End with prevention (alert, dashboard, runbook, postmortem action item) and keep my own actions distinct from the team's. The weak version to avoid is a story with no mechanism, or a fix claimed without evidence that it worked.
Follow-up
- How did you know the fix addressed the cause rather than coinciding with traffic dropping?
- What would have caught this before it reached production?
- What would you have done if you could not reproduce it?
Explain why the owner filter ignores the listing index
Medium · composite index · query plans · sort elimination
The only index on resource is (tenant_id, status, updated_at DESC, resource_id DESC). A new endpoint returns one user's resources across all statuses, newest created first: WHERE tenant_id = $1 AND owner_user_id = $2 ORDER BY created_at DESC LIMIT 20. On a tenant with 2M rows it takes 900 ms and EXPLAIN shows a sort above a large scan. Explain precisely why the existing index cannot serve it, give the index that can, and state which of these the new index still will not help: owner_user_id alone across tenants; the same query ordered by updated_at. PostgreSQL 16.
Approach
- Separate the two jobs an index does. For filtering, a composite btree is seekable only on a left prefix, so with no predicate on status the scan can at best range over tenant_id and test owner_user_id per row; PostgreSQL 16 has no btree skip scan to jump the unconstrained column.
- For ordering, the index is sorted by (status, updated_at) within a tenant and not by created_at, so the LIMIT cannot stop early: every matching row is read and then sorted. That is the 'Sort Method: top-N heapsort' line, and it is why the plan reads 2M rows to answer with 20.
- Derive the replacement from the access path — equality, equality, then the ordering column: CREATE INDEX CONCURRENTLY ON resource (tenant_id, owner_user_id, created_at DESC). The scan seeks to the (tenant, owner) range and walks 20 entries in order, so the Sort node disappears along with the row-read.
- Treat INCLUDE (title, status) as conditional, not free. An index-only scan still visits the heap for any row whose page is not marked all-visible, so on a table taking 1.2k writes/second the win depends on autovacuum keeping the visibility map current, and the wider index costs more on every insert.
Follow-up
- 90% of rows are status='active'. Would a partial index WHERE status = 'active' change your answer, and for which of the three queries?
- A dashboard runs this for 40 owners in one page load. What changes about the design?
Stop tag and share joins from fanning out a page
Medium · join fan-out · semi-joins · many-to-many
resource_tag is (resource_id, tag_id) with PK (resource_id, tag_id); resource_share is (resource_id, shared_with_user_id, permission). The tagged-and-shared listing inner-joins resource to both, filters tenant_id, tag_id = ANY($2) and shared_with_user_id = $3, orders by updated_at DESC and takes 50. Pages come back with fewer than 50 distinct resources and the total in the header is far too high. Explain the row multiplication, rewrite both the page query and the count query so each is correct, and name the index each one needs. PostgreSQL 16.
Approach
- Do the arithmetic against the predicates that are actually there. An inner join emits one row per matching child row, and both joins are filtered: tag_id = ANY($2) admits only the requested tags, shared_with_user_id = $3 admits one user's share rows. So a resource holding three of the requested tags and shared with $3 once yields three rows, not one — the multiplier is its count of matching tags times its share rows for that single user, and that second factor is 1 unless the table admits duplicate (resource_id, shared_with_user_id) pairs. LIMIT 50 then limits rows rather than resources, and COUNT(*) counts pairs — the header is the product, not the population.
- Reject DISTINCT as the fix. It deduplicates after the product has been built, so the planner must materialise and sort the fanned-out set before the LIMIT can apply, and it leaves any SUM or AVG in the same select list wrong.
- Rewrite both filters as semi-joins, keeping resource as the only row source: AND EXISTS (SELECT 1 FROM resource_tag rt WHERE rt.resource_id = r.resource_id AND rt.tag_id = ANY($2)) and the same shape against resource_share. A semi-join stops at the first match per resource and preserves the driving index order, so ORDER BY updated_at DESC, resource_id DESC LIMIT 50 still stops after 50 rows.
- Count with the same predicates and no join at all: SELECT count(*) FROM resource r WHERE r.tenant_id = $1 AND r.status = 'active' AND EXISTS (...) AND EXISTS (...). Nothing multiplies a resource, so the number is the population.
Follow-up
- The filter changes from 'any of these tags' to 'all of these tags'. Rewrite it and state what it costs relative to the ANY form.
- A resource can be shared with the same user twice under different permissions. Does your count change, and should it?
Design a high-concurrency payment processing system. How do you ensure data consistency?
Medium · System Design and Architecture
Approach
- Fix the scope: what a payment means here (account-to-account transfer, card charge through an external processor, or both), peak requests per second, and the consistency requirement in concrete terms: no double debit, no lost money, and no balance going below its allowed limit.
- Make every write idempotent: the client sends an idempotency key, the service inserts it under a unique constraint together with a request hash, and a retry returns the stored outcome; a retry that arrives while the first attempt is still in flight finds an in-progress record instead of starting a second payment.
- Keep money in a relational double-entry ledger: each payment writes balanced debit and credit entries plus the balance updates in one ACID transaction, entries are append-only, and corrections are new reversing entries. Drive the payment through an explicit state machine (created, pending, succeeded, failed, reversed) with each transition a conditional update on the current status.
- Avoid distributed transactions across services: use a transactional outbox (payment row and event committed together, a relay publishing to the message broker at least once) with consumers that deduplicate by event id, and a saga with compensating steps when a later step such as an external transfer fails.
- Handle unknown outcomes and scale: on a processor timeout, mark the payment pending and resolve it by status query and daily reconciliation against settlement records, never by blind retry; partition by account id so one account's writes serialize, serve history from replicas or a cache but check balances on the primary, and name hot accounts as the contention point.
Follow-up
- The processor times out and you do not know whether the charge went through: what does the user see, and what resolves it?
- One merchant account receives a large share of incoming payments; how do you keep its balance row from becoming the bottleneck?
- How do you prove at the end of the day that your ledger and the processor's records agree?
Evolve the resource contract without breaking integrations you cannot upgrade
Hard · versioning · backward compatibility · deprecation
GET /v1/resources/{id} returns status from a four-value enum ('draft','active','archived','deleted'), a numeric version, and the body inline. Consumers are a browser app you deploy and roughly 300 server-side integrations, some untouched for two years, that switch exhaustively on status and parse ids as JSON numbers. You must add a 'pending_review' status, move bodies over 256 KB to a body_ref pointer, and expose per-field change history from resource_revision. Specify the compatibility policy, the wire changes, how both generations are served, and the evidence that lets you remove the old shape.
Approach
- Write the policy first and date it: fields are added, never retyped or repurposed; consumers ignore unknown fields; an unknown enum value maps to a documented fallback; nothing is removed until telemetry shows no caller reads it. Then say the uncomfortable part out loud - v1 shipped without the unknown-value rule, so 300 running integrations have no fallback, and no server change can install one into code that is already deployed.
- That single fact forces per-request negotiation rather than a server-side default. Keep one internal model and select a serialiser from an explicit version in the request, and default a caller that sends nothing to the oldest supported version. Defaulting to the newest is the change that breaks every integration that never asked for anything, on the day you ship.
- Downgrade 'pending_review' for old callers to the nearest state they already handle, 'draft', and state the loss explicitly: those integrations cannot see review state and will treat the resource as editable. If that is unacceptable for one integration, the remedy is moving it to the new version, not a cleverer projection - there is no mapping that invents a state the client has no code for.
- Make the body change additive. Old callers keep
bodyinline; the new shape addsbody_refand a size field, and resources over the limit are served to old callers by resolving the pointer server-side or by refusing with a documented code, chosen once and published. Never repurposebodyto carry the pointer: a client that renders it shows a storage key to a user, and that failure is silent, where a missing field would have been loud. While you are here, serialise BIGINT ids as strings in the new shape - a browser parsing JSON numbers gets IEEE-754 doubles, exact only to 2^53 - and treat that as its own breaking change requiring the same negotiation, not a quiet fix.
Follow-up
- An old integration submits a status transition while the resource is really in 'pending_review'. What does the write path accept, and what does it reject?
- Two years on you want to delete the v1 serialiser. What evidence makes that safe, and who must be contacted before it happens?
Read latency spikes on a sixty-second sawtooth
Medium · cache stampede · single-flight · ttl
The cached listing read path serves about 14k reads/second at an 85% hit rate. p99 sits at 35 ms for 57 seconds, jumps to 900 ms for 3, and repeats. During each spike the primary shows several hundred identical listing queries starting within the same millisecond, all carrying one large tenant's id. Cache entries use a 60-second TTL. Give the mechanism, the ordered checks, the fix, and the correctness hazard your fix must not introduce.
Approach
- Match the period to a configured number before theorising about load. A spike every 60 seconds against a 60-second TTL is an entry expiring, and you confirm it by correlating spike timestamps with the entry's write time rather than with the traffic curve. If the period had matched a cron or a GC interval instead, this is a different investigation.
- Establish the concurrency of the miss. Several hundred identical queries in one millisecond means the miss path has no coalescing: every request that arrives between expiry and repopulation recomputes. The herd size is that key's arrival rate times its recompute time, so at 1.2k reads/second for the hot key and a 250 ms recompute you expect about 300 concurrent misses, which matches what is observed.
- Add single-flight on the miss path so one caller per key recomputes under a short-lived lock while the rest wait for its result. Prefer stale-while-revalidate where the read tolerates it: return the expired value immediately and refresh asynchronously, which removes the latency spike rather than serialising it into a queue of waiters.
- De-synchronise the keys. Write TTLs with jitter, for example 60 seconds plus or minus 10%, so a deploy or a mass invalidation does not align every key on the same second and turn a per-key herd into a fleet-wide one.
Follow-up
- The same sawtooth appears on a key that is invalidated on write rather than expired. Is that the same bug?
- How does your answer change if the recompute takes 4 seconds instead of 250 ms?
Suggested practice checklist
The plan follows the reported topics: confirm the format, build Java fluency, then concurrency, database behaviour, payment design, the incident story and a full mock.
Day 1 — Confirm the format and set up Java
- Message your recruiter to confirm the coding language, which stages are live and remote, and who you will meet
- Set up a plain Java project with JUnit and no autocomplete helpers, and run one test
- Write a one-minute answer to the HR screen: your background, what you want next and how you will handle the salary expectations question
Deliverable: A sent recruiter message, a Java project with one passing test, and a written one-minute HR screen answer.
Day 2 — Data structures in Java with complexity
- Implement an LRU cache two ways: with LinkedHashMap, and by hand with a HashMap plus a doubly linked list
- Solve a top-k problem with a PriorityQueue and a sorted-array alternative, and write each solution's time and space complexity in comments
- Record yourself explaining how each solution could be improved further: dominant operation, structure, cost, trade-off
Deliverable: Two LRU cache implementations and a top-k solution, each with a complexity note, plus one recorded optimisation explanation.
Day 3 — Concurrency for a banking workload
- Write an Account and Transfer class that moves money between two accounts with lock ordering to avoid deadlock
- Write a test that runs thousands of concurrent transfers and asserts total balance is conserved
- Rewrite one counter three ways (synchronized, AtomicLong, LongAdder) and note when each fits and what a check-then-act race looks like
- Write a short note on volatile, happens-before and what the JVM does with memory under load
Deliverable: A tested transfer class, a three-way counter comparison and a one-page note on visibility and JVM memory.
Day 4 — Relational database behaviour and ACID
- Design a ledger and transfers schema, with the unique constraint that makes a retried request safe, and list the indexes you would add
- Explain isolation levels and when you would use SELECT FOR UPDATE versus an optimistic version column
Deliverable: A ledger schema with indexes and constraints, plus written explanations for both SQL practice questions and a lock-versus-version comparison.
Day 5 — Payment processing system design
- Design a high-concurrency payment processing system on one page: API, idempotency, ledger, queue, cache, and the consistency each part needs
- Decide where synchronous calls stop and asynchronous events start, and describe an outbox or saga for the downstream step
- Add a security pass: which sensitive data is stored, encrypted or masked, and what you log
Deliverable: A one-page payment design with a consistency statement and security notes, plus a written answer to the contract-evolution practice question.
Day 6 — The production bug story and debugging
- Write your challenging-production-bug story as symptom, hypothesis, evidence, fix and prevention, then say it aloud twice
Deliverable: A written bug-story outline, a debugging sequence for the latency spikes, and spoken answers to two behavioral practice prompts.
Day 7 — Full mock and questions to ask
- Run a full mock: one Java problem coded live with complexity explained, then the payment design, then the bug story
- Write the questions you will ask senior engineers and engineering leadership about the stack, services and on-call
Deliverable: A completed mock with notes on weak spots, and a list of questions for both technical conversations.
Behavioural preparation
Candidates report that the process starts with an HR screen about your background, salary expectations and fit, and that the work involves explaining technical decisions to engineers and to non-technical stakeholders. Prepare stories where you made a technical decision, communicated it, and handled a production problem, and be ready to say why you chose what you chose.
- Describe a challenging bug you encountered in production and your step-by-step resolution process.
- Tell me about a time you explained a technical decision to a product manager or another non-technical stakeholder. What did you change in how you explained it?
- Describe an architecture or technology choice you made and the alternative you rejected. Why did you choose it?
- Tell me about a time you monitored a service in production and found a performance bottleneck. How did you find it and what did you change?
- Describe a disagreement in a code review and how it was resolved.
- Walk me through your background and what you are looking for in your next role.
Turn a code review disagreement into a decision
Easy · code review · optimistic concurrency · communication
A colleague's change updates a row with UPDATE resource SET version = version + 1 WHERE resource_id = $1 AND version = $2 and treats an affected-row count of zero as a successful no-op. You read that as a silently lost update; they think returning 200 is friendlier to clients than returning a conflict. Describe how you have handled a review disagreement of this shape: what goes in the comment, when you leave the thread, and who decides. Then write the comment you would leave here, in under 80 words.
Approach
- Sort the disagreement before writing anything. A silently discarded write is a correctness claim about data; the choice between 409 and 412 is taste. Only the first justifies blocking a merge, and saying which one you are doing is most of the value of the comment.
- Make the claim reproducible in the comment itself with an interleaving rather than a principle: A reads version 7, B reads version 7, B commits version 8, A's predicate matches zero rows, A is told it succeeded and A's edit is gone.
- Offer the alternative with its cost attached: return 409 carrying the current version and the revision that won, so the client can re-read and re-apply. Note that automatic retry is not the fix, because a retry re-reads the winner's state and reapplies an intent formed against data that no longer exists.
- Apply an escalation rule you can state: two round trips on the thread, then a call, and the service's owner decides rather than the reviewer. A reviewer who cannot be overruled is a bottleneck with extra steps.
Follow-up
- Where would you put the test that fails if someone reintroduces the swallowed zero rowcount?
- The author says clients cannot handle a 409. How do you check whether that is true?
Ship under a deadline and bound the debt you chose
Medium · pagination · technical debt · tradeoffs
You have four days to ship a tenant-facing listing endpoint. The version you would defend uses keyset pagination over (tenant_id, status, updated_at DESC, resource_id DESC); the version you can finish uses LIMIT/OFFSET with no matching index. Describe a deadline call you actually made of this shape: what you shipped, what you knowingly deferred, how you bounded the damage with a mechanism rather than an intention, and the specific numeric condition that would force the follow-up. Name who you told and where you wrote it down.
Approach
- Name the deferred failure precisely instead of calling it slow. OFFSET n makes the database produce and discard n rows, so cost grows with page depth; without an index matching the sort, every matching row is read and sorted before the limit applies; and rows inserted between two page fetches shift across the boundary so items are skipped or repeated with nothing in the response to signal it.
- Bound the blast radius with something mechanical rather than a promise: cap maximum page depth, cap page size, restrict the endpoint to one internal caller, or keep it behind a flag. State which failure each cap removes and which it leaves standing.
- Attach a number to the trigger and wire it to an alarm: the first tenant crossing N resources, or the endpoint's p99 crossing its share of the 400 ms budget, so the debt announces itself instead of waiting to be remembered.
- Write it where the next engineer looks, which is the code and the ticket, not a chat message: what was deferred, why, the cap, and the trigger.
Follow-up
- At what page depth does the offset version breach your latency budget, given your page size and row counts?
- What breaks first when you switch to keyset pagination later, and what does a client holding an old page token see?
Argue against a design, lose, and commit anyway
Medium · disagreement · service boundaries · decision records
Describe a design you argued against and lost. State the failure you predicted as a named mechanism, not a feeling about complexity: two services that would need one transaction, a projection with no rebuild path, a write path with no idempotency key. Say what evidence you brought, what the decision maker weighed instead, and what you did after the decision was made: what you instrumented, what you wrote down, and whether the prediction came true. Five minutes.
Approach
- State the prediction in falsifiable form up front: the mechanism, the condition that triggers it, and the observable outcome. A prediction that cannot be checked also cannot be credited to you later.
- Show the evidence you had at the time and label each piece honestly as measured, analogous, or intuition. Keeping the intuition is fine; disguising it as data is the thing that erodes your standing in the next argument.
- Represent the opposing case at full strength, including the constraint you did not control: a fixed date, a team boundary, or the fact that the decision was cheap to reverse and yours was not.
- Make disagree-and-commit concrete. Name the artefact you left behind so the prediction could be settled without you: the alert and its threshold, the counter on the dashboard, the decision note that recorded the trade-off and the condition that would revisit it.
Follow-up
- What threshold on that alert would have proved you right, and did anyone ever look at it?
- If the same proposal arrived tomorrow with the same deadline, would you argue it the same way?