InterviewLegend

Build a System, Feature by Feature

Alex Chen35 min readUpdated 94 real interview reports
In this lesson
  • Know the CodeSignal Industry Coding Framework screen, a single multi-level problem where earlier tests keep running
  • Read every level before you type, so each new level adds code instead of rewriting it
  • Build two reported archetypes: banking (accounts, transfers, top spenders, cashback) and a database (records, prefix scans, TTL, backup)

Coinbase opens with a coding screen that looks nothing like an algorithm contest. The assessment runs on CodeSignal's Industry Coding Framework, and it is one problem presented in levels. You build a small system, an in-memory banking ledger or a key-value database, and each new level layers a feature onto the same class you wrote before. Level 1 might be create an account and deposit. The next adds transfers, then ranking, then scheduled payments or expiry. The catch that defines the round is that every earlier test keeps running against your new code. A clean foundation you can extend without touching beats a clever first stab you tear apart three rounds later. Read ahead, or pay for it later.

That changes what you optimize for. The work is turning a growing spec into a class whose state is modeled once and whose operations stay small enough that the next feature slots in beside them. No trick to spot, no log factor to shave. The data structures are plain: a dictionary of accounts, a list of versioned records, a sentinel for a deleted field. Difficulty lives in keeping the design extensible while the clock runs and the requirements grow under you. The two problems below are the archetypes engineers report most from the Coinbase screen, each built the way the round escalates.

How to attack an ICF problem

Four moves carry every Industry Coding Framework problem, and the first one happens before you write any code. The format rewards engineers who design for a system they have already read to the end. The order of operations matters more here than in a standard coding round. Sequence is the skill.

Read every level before you write a line

The levels are visible from the start, so read all of them first. The banking problem ends in scheduled cashback and account merges; the database problem ends in time-to-live and restore-from-backup. Knowing the destination changes the foundation you pour. The ending shapes the start. If Level 4 keys cashback off a payment record, Level 1 should already store enough about each operation to grow into that. Track a bare integer balance and you pay for the retrofit later. The engineers who skip ahead and skim the final level write a Level 1 that survives. The ones who code Level 1 in isolation often rewrite it twice.

Model the state once, cleanly

Decide what your class holds before you write a method. A banking system is a map from account id to balance, a second map for total outflow once ranking arrives, and a list of payment records once scheduling arrives. A database is a map from key to field to a value-and-expiry pair. Name these in the constructor and let every operation read and write through them. When the state lives in a few well-named structures, a new feature is usually a new structure beside the old ones. The structures already passing tests stay put. State you have to reshape mid-problem is where the earlier tests break.

Keep each operation a small method

Every spec line becomes one method that does one thing. deposit adds to a balance and returns it. transfer moves money and records outflow. A shared concern, settling cashback that has come due or checking whether a field has expired, becomes a small private helper that the public methods call. Route every write through that handful of helpers. When Level 4 says cashback now lands in the surviving account after a merge, you repoint one field in one place. Every future settlement follows, because it reads that field instead of a hard-coded account. Small methods with a few shared seams are what let level N+1 be an addition.

The Coinbase angle
The CodeSignal screen is roughly 90 minutes in a single proctored sitting, scored by running your code against hidden test batches with partial credit per batch. The defining mechanic is that every level re-runs all the earlier levels' tests. A change you make at Level 3 that quietly breaks a Level 1 case costs you those points back. Regressions are expensive here. The grade is cumulative, which is why Coinbase's screen rewards extensible design over a fast first answer. A foundation you can build on outscores an optimal Level 1 you later have to tear up. Read to the last level, model the state to reach it, and add features beside what already passes.

Make level N+1 an addition

When a new level arrives, the goal is to add code rather than edit code that already passes. Ranking is a new map you update inside the methods that already move money. Expiry is a new field on each stored value plus one helper that reads it. Backup is a snapshot list and two methods that never touch the existing read path. Each one is pure addition. Say a level forces you to reopen and rewrite a method that earlier tests depend on. Treat that as a sign your earlier model was too narrow, and widen the state instead of rewriting the logic. The engineers who finish all four levels are usually the ones whose Level 4 diff is almost entirely new lines.

The Coinbase angle
Coinbase grades the screen on code quality more than on asymptotic cleverness, and its own engineering writing is explicit that it is not hunting for a minimal contest-optimal answer. Inside the ICF that means clear method names that match the spec and state kept in a few obvious structures. Return types stay consistent, and edges get handled inside the method rather than in a comment. A linear-scan ranking that reads cleanly and passes every batch beats a heap-backed version that is hard to follow and cracks on the merge level. Readable wins. Spend your minutes making each level a clean addition; reach for a custom data structure only when a batch is timing out.

The unit of work in an ICF problem is the system rather than a single function. You are graded on whether each level slots into the last one without breaking it, because the earlier tests keep running. Read all the levels, then model the state once to reach the final one. Keep every operation a small method over a few shared seams, and make each new level new code. Do that and the cumulative test reruns become your safety net instead of your trap.

The Coinbase angle
Design for the next level even before you can see its tests. The banking archetype almost always ends in scheduled cashback that settles on a delay, plus account merges that move that cashback. The database archetype ends in per-field expiry and a restore that recomputes remaining lifetimes. Both endings are far easier when Level 1 already stores operations as records and routes settlement and expiry through one helper each. One helper, many levels. Candidate reports converge on the same failure mode. Level 1 tracks only the bare minimum, then a frantic rewrite at Level 3 or 4 breaks cases that were already green. The refactor tax is the thing the format is built to charge, so design to avoid it from the first method.

Asked at Coinbase

Two systems, each built across the four levels the ICF screen escalates through. The banking system grows from accounts and balances to transfers, then top-spender ranking, then scheduled payments that pay cashback on a delay. The in-memory database grows from field records to prefix scans, then per-field expiry, then backup and restore. Write each one yourself before opening the walkthrough, narrating the state and the seam for the next level the way you would with the interviewer watching. Each ICF test case is a list of operations, and the harness records what every operation returns, using null for a constructor or write that returns nothing.

1

In-Memory Banking System: Incremental OOP

HardSignature problem
Problem
Build an in-memory banking system one level at a time. All four levels share one class and one set of state. Every operation takes a timestamp (an integer in milliseconds) as its first argument.

Level 1, accounts and balances.
create_account(timestamp, account_id) opens an account with balance 0. Returns true if created, false if the id already exists.
deposit(timestamp, account_id, amount) adds amount to the account and returns the new balance, or null if the account does not exist.
withdraw(timestamp, account_id, amount) subtracts amount and returns the new balance, or null if the account is missing or has less than amount.

Level 2, transfers.
transfer(timestamp, source, target, amount) moves amount from source to target and returns the source's new balance, or null if either account is missing, the two are the same, or the source has less than amount.

Level 3, ranking.
top_spenders(timestamp, n) returns the n accounts with the most total outgoing money, each as the string "account_id(outflow)", highest first, ties broken by account id ascending. Outgoing money is the sum of withdrawals, transfers out, and payments (deposits and transfers in never count).

Level 4, scheduled cashback.
pay(timestamp, account_id, amount) withdraws amount immediately (it counts as outgoing), then schedules a cashback of 2 percent of amount rounded down, to be credited 86400000 ms (24 hours) later. Returns a payment id "payment{N}" (N counting from 1 across the whole system), or null if the account is missing or underfunded.
get_payment_status(timestamp, account_id, payment_id) returns "CASHBACK_RECEIVED" if that payment's cashback has been credited by timestamp, otherwise "IN_PROGRESS". Returns null if the account or payment does not exist, or the payment belongs to another account.

A cashback is credited the moment the system observes a timestamp at or past its due time, during whatever operation runs then.

Input encoding. Each test case is a JSON array of operations. The first is ["BankingSystem", []]; the rest are [method, [args...]]. Writes that return nothing record as null; every other call records its return value.

Input: BankingSystem(); create_account(1,"a"); deposit(2,"a",1000); withdraw(3,"a",300); withdraw(4,"a",5000)

Output: [null, true, 1000, 700, null]

The account opens, takes a deposit to 1000, drops to 700 on a valid withdrawal, then the over-withdrawal of 5000 is refused with null.

Input: BankingSystem(); create_account(1,"a"); deposit(2,"a",2000); pay(3,"a",1000); get_payment_status(4,"a","payment1"); get_payment_status(86400003,"a","payment1")

Output: [null, true, 2000, "payment1", "IN_PROGRESS", "CASHBACK_RECEIVED"]

pay drops the balance to 1000 and schedules 20 (2 percent of 1000) for 86400003. At time 4 it is still pending; at 86400003 the system observes the due time, credits 20, and reports it received.

How this uses the pattern

One class, one set of state: a balance map, an outflow map for ranking, and a list of payment records for cashback. Run a single settle-cashbacks helper at the top of every method so due credits land before that operation reads any balance.

In-Memory Banking System: Incremental OOP

Loading...
Click "Run" to execute your code

Test Cases

Input: [["BankingSystem",[]],["create_account",[1,"acc1"]],["create_account",[2,"acc1"]],["deposit",[3,"acc1",1000]],["deposit",[4,"missing",100]],["withdraw",[5,"acc1",300]],["withdraw",[6,"acc1",1000]]]Expected: [null,true,false,1000,null,700,null]
Input: [["BankingSystem",[]],["create_account",[1,"a"]],["create_account",[2,"b"]],["deposit",[3,"a",500]],["transfer",[4,"a","b",200]],["transfer",[5,"a","b",1000]],["transfer",[6,"a","c",50]],["transfer",[7,"a","a",10]]]Expected: [null,true,true,500,300,null,null,null]
Input: [["BankingSystem",[]],["create_account",[1,"a"]],["create_account",[1,"b"]],["create_account",[1,"c"]],["deposit",[2,"a",1000]],["deposit",[2,"b",1000]],["deposit",[2,"c",1000]],["withdraw",[3,"a",500]],["transfer",[4,"b","a",300]],["withdraw",[5,"c",500]],["top_spenders",[6,3]],["top_spenders",[6,1]]]Expected: [null,true,true,true,1000,1000,1000,500,700,500,["a(500)","c(500)","b(300)"],["a(500)"]]
Input: [["BankingSystem",[]],["create_account",[1,"acc"]],["deposit",[2,"acc",2000]],["pay",[3,"acc",1000]],["get_payment_status",[4,"acc","payment1"]],["get_payment_status",[4,"acc","payment2"]],["get_payment_status",[4,"missing","payment1"]],["get_payment_status",[86400003,"acc","payment1"]],["withdraw",[86400004,"acc",1020]]]Expected: [null,true,2000,"payment1","IN_PROGRESS",null,null,"CASHBACK_RECEIVED",0]
The Coinbase angle
The Coinbase screen runs at full size here: one banking class grown across four levels, with every earlier level's tests re-running as you add the next. Everything turns on designing Level 1 for the levels you have read ahead to. Track outflow from the first withdrawal even though ranking does not arrive until Level 3. Store each payment as a record from the start, and cashback at Level 4 becomes a new helper rather than a new data model. The grade rewards a settle-cashbacks seam that every method calls, because that one funnel is what makes the merge follow-up land cashback in the right account without touching the rest of the class. Clean methods over a heap-of-the-week is the bar here.
Take it further
Add account merge without disturbing any level that already passes. A later level asks for merge_accounts(timestamp, id1, id2): fold id2's balance and outflow into id1, repoint id2's scheduled cashback to id1, then drop id2. Every method already settles cashback through one helper, and every payment stores its owner in one field. So the merge is a few new lines that reuse the existing state rather than a rewrite. Worked through below.
2

In-Memory Database: TTL and Versioned Restore

HardFrequently asked
Problem
Build an in-memory database of records, one level at a time. A record is identified by a key and holds named field to value pairs. All four levels share one class. Every operation takes a timestamp (an integer) as its first argument.

Level 1, field records.
set(timestamp, key, field, value) sets a field on a record, creating the record if needed. Returns nothing.
get(timestamp, key, field) returns the field's value, or null if the record or field does not exist.
delete(timestamp, key, field) removes the field and returns true if it existed and was live, otherwise false.

Level 2, prefix scan.
scan_prefix(timestamp, key, prefix) returns the live fields of key whose name starts with prefix, each as "field(value)", sorted by field name ascending. An empty prefix matches every field.

Level 3, expiry.
set(timestamp, key, field, value, ttl) may take an optional ttl. The field is live during [timestamp, timestamp + ttl) and is gone at or after timestamp + ttl. A set with no ttl never expires. Every read (get, delete, scan_prefix) must treat expired fields as absent at the operation's timestamp.

Level 4, backup and restore.
backup(timestamp) saves a snapshot of all live fields and returns the number of keys that have at least one live field. Each saved field keeps its remaining lifetime, not its absolute expiry.
restore(timestamp, restore_to) replaces the current state with the most recent backup taken at or before restore_to. Every restored field's expiry is recomputed from the restore timestamp plus its saved remaining lifetime, so a field with 90 left at backup expires 90 after the restore.

Input encoding. Each test case is a JSON array of operations. The first is ["InMemoryDB", []]; the rest are [method, [args...]]. Writes that return nothing record as null; every other call records its return value.

Input: InMemoryDB(); set(0,"u","name","alice"); get(0,"u","name"); set(0,"u","tmp","x",10); get(5,"u","tmp"); get(10,"u","tmp")

Output: [null, null, "alice", null, "x", null]

"name" is permanent. "tmp" lives during [0, 10), so it reads "x" at time 5 and is gone at time 10.

Input: InMemoryDB(); set(0,"r","name","bob"); set(0,"r","nickname","bobby"); set(0,"r","age","30"); scan_prefix(0,"r","n")

Output: [null, null, null, null, ["name(bob)", "nickname(bobby)"]]

Fields are scanned in ascending name order; only "name" and "nickname" start with "n", and "age" is excluded.

How this uses the pattern

Store key to field to a (value, expire_at) pair, with expire_at None for no expiry. One _live helper decides whether a pair is alive at a timestamp, and every read calls it. Backup saves remaining lifetime so restore can rebuild absolute expiry from the restore time.

In-Memory Database: TTL and Versioned Restore

Loading...
Click "Run" to execute your code

Test Cases

Input: [["InMemoryDB",[]],["set",[0,"user1","name","alice"]],["get",[0,"user1","name"]],["get",[0,"user1","email"]],["get",[0,"user2","name"]],["delete",[0,"user1","name"]],["get",[0,"user1","name"]],["delete",[0,"user1","name"]]]Expected: [null,null,"alice",null,null,true,null,false]
Input: [["InMemoryDB",[]],["set",[0,"r","name","bob"]],["set",[0,"r","nickname","bobby"]],["set",[0,"r","age","30"]],["scan_prefix",[0,"r","n"]],["scan_prefix",[0,"r","na"]],["scan_prefix",[0,"r","z"]],["scan_prefix",[0,"missing","n"]]]Expected: [null,null,null,null,["name(bob)","nickname(bobby)"],["name(bob)"],[],[]]
Input: [["InMemoryDB",[]],["set",[0,"k","f","v",10]],["get",[5,"k","f"]],["get",[10,"k","f"]],["get",[15,"k","f"]],["set",[0,"k","g","permanent"]],["get",[1000,"k","g"]],["scan_prefix",[5,"k",""]],["scan_prefix",[12,"k",""]]]Expected: [null,null,"v",null,null,null,"permanent",["f(v)","g(permanent)"],["g(permanent)"]]
Input: [["InMemoryDB",[]],["set",[0,"a","x","1",100]],["set",[0,"b","y","2"]],["backup",[10]],["set",[20,"a","x","updated"]],["delete",[20,"b","y"]],["backup",[20]],["restore",[200,15]],["get",[250,"a","x"]],["get",[295,"a","x"]],["get",[250,"b","y"]]]Expected: [null,null,null,2,null,true,1,null,"1",null,"2"]
The Coinbase angle
The in-memory database is the banking archetype's sibling on the Coinbase screen, and the same design discipline carries it. The expiry level is far easier when Level 1 already stores each value as a value-and-expiry pair instead of a bare value. A permanent field is just one with no expiry, and a TTL field slots in with no reshape. The grade rewards one _live helper that every read routes through, because that is what makes prefix scan, get, and delete agree on what counts as expired. Backup and restore then earn their points by saving remaining lifetime rather than absolute time, so a restored field keeps the lifetime it had left rather than reviving already-dead. Read the levels first and the value-and-expiry pair is obvious from method one.
Take it further
Reclaim the memory that expired fields still hold. Expired fields linger in the maps, read as absent but never freed, which matters once a system runs long enough. Add compact(timestamp) that drops every field already dead at timestamp and removes any key left with no fields, reusing the same _live helper the reads use. It is pure addition: no read or write path changes. Worked through below.

Key Takeaways

  • ✓Coinbase's screen is one CodeSignal ICF problem built in levels, with every earlier level's tests re-running as you add code. The grade is cumulative, so a foundation you can extend without disturbing beats a fast or clever first answer you have to tear apart later.
  • ✓Read all the levels before you write a line, then model the state once to reach the final one. Track outflow from the first withdrawal, store each value as a value-and-expiry pair from the first set, and the ranking and TTL levels stop being rewrites.
  • ✓Route shared concerns through one seam. Every banking method calls one settle-cashbacks helper, and every database read calls one _live helper. That lets level N+1 land as new code beside what already passes, instead of edits to it.
  • ✓Coinbase grades code quality over Big-O. Clear method names that match the spec, state in a few obvious structures, consistent returns, and edges handled inside the method earn the round. Reach for a custom data structure only when a test batch is timing out.
Had your Coinbase interview?Tell us what they asked. Reviewed before it goes in, never with your name.
Coinbase interview guide

Know what Coinbase asks before you walk in.

13 coding · 4 system design · 37 behavioral questions

Get Premium$49 a year · Cancel anytime
Or start freeNo card.

Get their questions first.

InterviewLegend is independent and not affiliated with or endorsed by any company it covers.