ATM Take-Home β€” Study Handbook
🏦 Handbook
DKatalis Β· ATM take-home

Study Handbook

Thirteen companion docs to the ATM CLI submission β€” Java, TDD, and the production story behind every seam. Read in order, or jump to what the interview needs.

Fastest interview prep path
  1. Run ./start.sh and play a full session by hand.
  2. Read Bank.java until you can narrate transfer (net β†’ pay β†’ owe) and deposit (repay oldest-first) without looking.
  3. Skim every test name β€” they are the requirements checklist.
  4. Practice one extension end-to-end with TDD: add a balance command β€” failing test β†’ handler β†’ registry line β†’ green.
Part I β€” Foundations
01Java EssentialsClasses, records, exceptions, collections, Optional, streams β€” each mapped to the ATM codeβœ“ 02JUnit 5 EssentialsTest anatomy, four assertions, parameterized tests, fakes over mocksβœ“ 03TDD β€” Practical GuideRed-green-refactor, a worked example, rules of thumb, pairing habitsβœ“ 04Maven Essentialspom anatomy, lifecycle, the wrapper, troubleshootingβœ“ 05Shell & Git Essentialsstart.sh dissected, piping stdin, the git loop, bundle vs archiveβœ“
Part II β€” The Interview
06Pairing Session PrepLikely questions, live-extension drills, etiquette, pre-session checklistβœ“
Part III β€” Production Engineering
07Production EvolutionDDD, scaling & LB, cache, WAF/security, observability, deploymentβœ“ 08Testing StrategyThe pyramid, Testcontainers, property/mutation tests, load/soak/chaosβœ“ 09CI/CD & DevOpsPipelines, Docker/K8s/GitOps, canary & flags, IaC, DORAβœ“ 10Backend FundamentalsACID, locking, idempotency, indexing, pools, sharding, outbox/sagaβœ“ 11API ProtocolsHTTP/REST, gRPC, WebSocket vs SSE, webhooks, GraphQL, queuesβœ“ 12SQL & NoSQLHand-written SQL on the ATM schema; the four NoSQL familiesβœ“ 13Java ConcurrencyTranslated from Go: virtual threads = goroutines, locks, happens-beforeβœ“

Java Essentials

Every concept here appears in the ATM codebase β€” study theory and usage together.

1. Classes, interfaces, final

Where to look: Customer.java, CustomerRepository.java

2. Records

A record is an immutable data carrier the compiler writes for you:

public record DebtView(String counterparty, long amount) {}

You get: constructor, accessors (view.amount() β€” no get prefix), equals, hashCode, toString. Two records with the same values are equal β€” which is why tests can write:

assertEquals(List.of(new Repayment("Alice", 30)), result.repayments());

Use records for values that are their data (results, views, parsed commands). Use classes when there's identity or mutation (a Customer's balance changes; two customers with balance 0 are not the same person).

Where to look: AccountSummary.java, TransferResult.java, ParsedCommand.java

3. Exceptions

Where to look: CommandLoop.java (the catch ladder), InsufficientFundsException.java

4. Collections

5. Optional

Optional<Customer> holds one value or nothing β€” a type-level replacement for null:

customers.findByName(name)                       // Optional<Customer>
    .orElseThrow(() -> new UnknownCustomerException(name));

customers.findByName(name)
    .orElseGet(() -> customers.save(new Customer(name)));

find(debtor, creditor).map(Debt::amount).orElse(0L);

Rules of thumb: return Optional from lookups that can miss; never pass it as a parameter; never call bare .get().

6. Streams

ledger.debtsOwedBy(name).stream()
    .map(d -> new DebtView(d.creditor(), d.amount()))
    .toList();

debts.stream()
    .filter(d -> d.debtor().equals(debtor))
    .findFirst();                     // Optional<Debt>

The pieces you need: stream(), filter, map, toList(), findFirst(), forEach. Lambdas (d -> d.amount()) and method references (Debt::amount) are inline functions.

When a loop mutates several things at once (like deposit's repayment loop), a plain for loop is clearer than a stream. Knowing when not to use streams is part of the skill.

7. Odds and ends the codebase uses

JUnit 5 Essentials

The complete set of JUnit features the ATM uses β€” which is the set you need for most projects.

Anatomy of a test class

class BankWithdrawTest {                 // package-private is idiomatic
    private Bank bank;

    @BeforeEach                          // runs before EVERY @Test
    void setUp() {
        bank = new Bank(new InMemoryCustomerRepository());
        bank.login("Alice");
        bank.deposit("Alice", 100);
    }

    @Test                                // one test = one behavior
    void withdrawReducesBalance() {
        AccountSummary summary = bank.withdraw("Alice", 40);
        assertEquals(60, summary.balance());
    }
}

The four assertions you need

import static org.junit.jupiter.api.Assertions.*;

assertEquals(expected, actual);          // expected FIRST
assertTrue(condition);  assertFalse(condition);
assertSame(a, b);                        // identity (==), not just equal

var ex = assertThrows(InsufficientFundsException.class,
        () -> bank.withdraw("Alice", 101));
assertEquals(100, ex.balance());

Two idioms doing heavy lifting in this project:

// records + assertEquals = whole-value assertions
assertEquals(List.of(new Repayment("Alice", 30)), result.repayments());

// asserting a full output sequence pins exact order AND content
assertEquals(List.of("Hello, Alice!", "Your balance is $0"), run("login Alice"));

Parameterized tests β€” same test, many inputs

@ParameterizedTest
@ValueSource(strings = {"abc", "1.5", "-5", "0", "10,50", ""})
void rejectsEverythingElse(String raw) {
    assertThrows(InvalidAmountException.class, () -> Amounts.parsePositive(raw));
}

Each value runs and reports as a separate test. Perfect for validation edge cases.

Test doubles without a mocking framework

The ATM uses fakes β€” tiny real implementations of an interface β€” instead of Mockito:

final class FakeConsole implements Console {
    private final Iterator<String> input;
    final List<String> output = new ArrayList<>();
    // readLine() feeds scripted lines; println() captures output
}

This is only possible because Console is an interface the production code depends on. Testability came from the design, not a library β€” saying that sentence in an interview is worth more than knowing any mocking API.

Naming and structure conventions

Running them

./mvnw test                              # all tests
./mvnw test -Dtest=BankTransferTest      # one class
./mvnw test -Dtest=BankTransferTest#transferShortfallBecomesDebt

TDD β€” A Practical Guide

Written alongside the ATM take-home; examples reference that codebase.

The one rule

Never write production code except to make a failing test pass. Everything follows from that rule, applied in a three-beat rhythm:

BeatWhat you doWhy
πŸ”΄ RedWrite ONE small test for behavior that doesn't exist. Run it. Watch it fail.A test you've never seen fail proves nothing.
🟒 GreenWrite the minimum code to pass. Dumb is fine.Elegance comes later, under test protection.
πŸ”΅ RefactorClean up names, duplication. Re-run tests after every change.This is where good design happens β€” safely.

Repeat until done. A compile error counts as Red.

Worked example: Bank.withdraw

Cycle 1 β€” happy path. Red:

@Test
void withdrawReducesBalance() {
    bank.login("Alice");
    bank.deposit("Alice", 100);
    assertEquals(60, bank.withdraw("Alice", 40).balance());
}

Doesn't compile β€” that's a failing test. Green:

public AccountSummary withdraw(String name, long amount) {
    customerOf(name).subtract(amount);
    return summaryOf(name);
}

Note what's missing: no validation, no insufficient-funds check. Deliberate β€” no test demanded them yet.

Cycle 2 β€” the business rule. Red:

@Test
void withdrawBeyondBalanceIsRejected() {
    bank.login("Alice");
    bank.deposit("Alice", 100);
    var ex = assertThrows(InsufficientFundsException.class,
            () -> bank.withdraw("Alice", 101));
    assertEquals(100, ex.balance());
}

Fails β€” no exception thrown. Green β€” add the guard:

if (amount > customer.balance()) {
    throw new InsufficientFundsException(customer.balance());
}

Cycle 3 β€” validation. Red: rejectsNonPositiveAmounts. Green: requirePositive(amount). By the end, every line exists because a specific test demanded it. The test suite is the spec.

Rules of thumb

  1. Test behavior, not implementation. Assert what a caller observes β€” never internals. This is why Bank returns result records instead of printing: printing isn't assertable; a returned value is.
  2. One failing test at a time.
  3. Name tests as sentences about rules. transferToCreditorNetsDebtWithoutMovingMoney.
  4. Watch it fail β€” always. The #1 way tests silently pass on broken code is skipping this.
  5. Size steps by confidence. Lost in a tricky rule? Shrink: trivial case, then edges, then the weird ones.
  6. Never bend a failing test to match the code. The test is the spec; the code moves.
  7. Commit on green, every cycle. Free undo points; history tells the design story.

What TDD buys you (say this in the interview)

Common beginner mistakes

  1. Writing all tests up front, then all code β€” waterfall in a TDD costume. Alternate.
  2. Skipping Red.
  3. Chasing coverage β€” test rules, not getters.
  4. Elegant Green. Make it pass stupidly, then refactor under protection.
  5. Asserting too much per test β€” one behavior each.

For the pairing session

Maven Essentials

The 20% you actually use. Everything maps to the project's pom.xml.

The mental model

Maven is convention over configuration β€” follow its layout and it just works:

pom.xml                    ← the only config file
src/main/java/...          ← production code
src/test/java/...          ← test code (not shipped in the jar)
target/                    ← ALL build output (gitignored, safe to delete)

Reading the pom.xml

<groupId>com.dkatalis</groupId>      <!-- who owns it -->
<artifactId>atm</artifactId>         <!-- project name -->
<version>1.0.0</version>

These three = the project's coordinates. Every library in the world is identified the same way.

<maven.compiler.release>21</maven.compiler.release>

release guarantees the output runs on Java 21+ even when compiled with JDK 24.

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.10.2</version>
  <scope>test</scope>      <!-- tests only, not shipped -->
</dependency>

scope=test is why the production jar has zero dependencies. Downloads cache in ~/.m2/repository.

Plugins do the work: surefire runs tests during the build; the jar plugin sets Main-Class: com.dkatalis.atm.Main so java -jar target/atm.jar knows where to start. finalName names the jar.

The lifecycle β€” why package also runs tests

validate β†’ compile β†’ test β†’ package β†’ verify β†’ install

Asking for a later phase runs everything before it:

Flags: -q quiet, -N don't recurse into modules.

The wrapper (mvnw)

./mvnw downloads the exact Maven version pinned in .mvn/wrapper/maven-wrapper.properties and forwards your command. Consequences:

Troubleshooting one-liners

SymptomMove
Weird stale behavior./mvnw clean test
One test class only./mvnw test -Dtest=BankTransferTest
Flaky downloadscheck ~/.m2/settings.xml; retry
"release version 21 not supported"JDK older than 21 β€” java -version
See dependency tree./mvnw dependency:tree

Shell & Git Essentials

The subset used to build, verify, and package the submission.

Shell β€” dissecting start.sh

#!/usr/bin/env sh        # shebang: which interpreter runs this
set -e                   # abort on the first failing command
cd "$(dirname "$0")"     # cd to the script's own folder
./mvnw -q -DskipTests clean package
exec java -jar target/atm.jar   # exec REPLACES the shell with java

A script needs the executable bit: chmod +x start.sh. Quoting rule: always double-quote "$(...)" and variables so paths with spaces don't split.

Pipes β€” testing a CLI non-interactively

printf 'login Alice\ndeposit 100\nlogout\n' | ./start.sh

printf emits the lines; | feeds them to stdin as if typed. When input ends, the app sees EOF (same as Ctrl-D) and exits. This is exactly how the full sample session was verified.

Git β€” the loop you'll use 99% of the time

git init                 # once
git status --short       # what changed?
git add src pom.xml      # stage specific paths
git commit -m "transfer nets against target debt"
git log --oneline

Mental model: working tree β†’ add β†’ staging area β†’ commit β†’ history. Staging makes a commit a deliberate unit β€” and committing after every green TDD cycle gives you free undo points.

.gitignore

target/
.idea/
*.iml
.vscode/

Check nothing slipped through: git ls-files | grep -E '\.(jar|class)$' should print nothing.

Packaging for submission (no public push!)

git bundle create ../atm.bundle --all   # the REPO: full history, one file
git archive -o ../atm.zip HEAD          # the FILES: snapshot, no history

A bundle receiver runs git clone atm.bundle atm and gets the real repo. The bundle preserves commit history (which shows your TDD process).

Occasionally useful

git diff                  # unstaged;  git diff --staged  for staged
git show <hash>           # inspect one commit
git restore <file>        # throw away uncommitted changes (destructive!)
git commit --amend        # fix the last commit message (before sharing)

Pairing Session Prep

The 1.5h tech-chat / pair-programming session runs on YOUR submission β€” know it cold. Ordered by probability.

1. "Walk me through your solution" (near-certain opener)

Practice a 5-minute tour: start.sh β†’ Main wiring β†’ one command's full journey (transfer Alice 50 β†’ parser β†’ handler β†’ Bank.transfer β†’ renderer). Expect interruptions:

QuestionYour answer
Why does Bank return result records instead of printing?Testability + transport-agnostic domain; rendering is an adapter concern
Why an interface for CustomerRepository with one implementation?Deliberate persistence seam. If pushed on YAGNI: it's the one boundary a bank certainly needs; everything else stayed concrete
Why is the logged-in customer in the CLI layer, not Bank?Identity is a transport concern β€” REST/ISO 8583 carry it per request
Why long, not BigDecimal/double?Spec is whole units; NEVER float for money (binary can't represent 0.1)
What happens if I type <weird input>?Know the error table: every invalid input gets a friendly message; loop never dies
Why no Spring/DI framework?Nothing to inject that constructors don't already do; Main is the composition root

2. Live extension with TDD (the core β€” DRILL THIS)

Most likely asks, easiest β†’ hardest:

  1. New simple command (balance, debts) β€” failing CommandLoopTest case β†’ new handler class β†’ registry line in Main. Practice once for real.
  2. Transaction history β€” history command; a TransactionLog appended from Bank methods.
  3. Change a business rule β€” "withdraw may overdraw up to $100" / "repay newest-first". Rules are localized (one method each) β€” say so, change exactly that method test-first.
  4. Cents support β€” parsing + formatting; do NOT reach for double; store minor units in long.
  5. Persistence β€” FileCustomerRepository behind the existing interface; the seam pays off visibly.
  6. Concurrency β€” "two ATMs, what breaks?" All mutable state; lost-update is the failure; fixes from synchronized Bank methods to per-account locks. (Full answer in doc 13.)

The motion for ANY extension: clarifying question β†’ failing test β†’ minimal code β†’ green β†’ refactor β†’ commit.

3. Testing philosophy

4. Design-principles chat

5. The AI question

They allow AI but reject candidates who can't reason about their submission. Be straightforwardly honest about the process; let fluent live-extension be the proof. Overclaiming is the only losing move.

Pairing etiquette that scores

Pre-session checklist

From Take-Home to Production Grade

Can the ATM CLI grow into a real banking service? Yes β€” that was the point of the layering.

The core idea

The domain layer survives; everything else is an adapter around it.

       TODAY                          PRODUCTION
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ cli (REPL)       β”‚        β”‚ REST API Β· gRPC Β· ISO 8583   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€        β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Bank, DebtLedger β”‚  ===>  β”‚ SAME Bank, same rules        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€        β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ InMemory repo    β”‚        β”‚ Postgres repo, ledger tables β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Bank never touching I/O, returning result records, and depending on a repository interface is exactly what makes the swap possible. Hexagonal architecture in miniature.

Domain (DDD)

Mostly in shape: Bank = application service, Customer/Debt = entities, results = value objects, repository pattern present. Production changes:

Scalability & LB

Cache β€” with care

WAF & security (outside β†’ inside)

Observability

Deployment & async

What survives vs what changes

LayerVerdict
Domain rules (net β†’ pay β†’ owe; oldest-first)Survive as-is
Result records / exception designSurvive β€” map 1:1 to API responses/error codes
CustomerRepository seamSurvives β€” implementation becomes Postgres
Mutable balance fieldReplaced by double-entry ledger + projections
String name as identityReplaced by account IDs
CLI SessionReplaced by per-request tokens
CommandLoop/handlersReplaced by controllers β€” same shape: thin adapter β†’ domain call β†’ render result
The 61 testsDomain tests survive; CLI tests become API tests of the same scenarios

"The CLI is one adapter over a transport-agnostic domain. Production means adding adapters and infrastructure around the same core β€” the two things I'd change inside the domain are account IDs instead of names and a double-entry ledger instead of mutable balances, because auditability and concurrency-safety are structural in banking, not add-ons."

Testing Strategy

From 61 tests to production-grade quality.

Where the ATM stands: the pyramid

            β–²  fewer, slower, costlier
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   E2E / UI tests      β”‚  ← ATM: piped ./start.sh run
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚   Integration tests   β”‚  ← ATM: SampleSessionIntegrationTest (1)
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚   Unit tests          β”‚  ← ATM: 60 (rules, parser, REPL)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
            β–Ό  many, fast, cheap

Lots of fast unit tests, fewer integration, very few E2E. Production keeps the shape and adds new kinds.

1. Functional testing

2. Performance testing β€” a family

Tools: Gatling, k6, JMeter. Each type answers a different question:

TypeQuestionShape
LoadMeets SLOs at expected traffic?Ramp to peak, hold, measure p95/p99 + errors
StressWhere does it break, and how?Ramp until failure; want graceful degradation, not corruption
SoakDegrades over hours/days?Moderate load 8–72h; catches leaks, pool exhaustion
SpikeSurvives sudden 10Γ—?Instant jump; tests autoscaling + backpressure
VolumeFast with 100M rows?Big data, normal load; catches missing indexes

Discipline: define SLOs first ("p99 transfer < 500ms at 1000 RPS"), test in a prod-like env, keep a small load test in CI. For money: after the load test, run reconciliation β€” does every debit have a credit?

3. Security testing

4. Resilience & operational testing

5. In CI/CD

commit β†’ unit + mutation (minutes)
       β†’ integration w/ Testcontainers
       β†’ SAST + dependency scan
       β†’ staging β†’ API/E2E + smoke load test
       β†’ prod (canary) β†’ smoke β†’ full rollout
       nightly: soak, DAST, full load test

Principle: shift left β€” the cheaper and earlier a failure surfaces, the better.

"Today the project has a proper pyramid β€” 60 unit tests on the rules, an integration test as the acceptance gate. Production adds kinds, not just counts: Testcontainers for real-DB integration, property-based tests for money invariants, mutation testing to keep the suite honest, Gatling load/soak against explicit SLOs, SAST/DAST in CI, and chaos drills to prove retries plus idempotency protect the money."

CI/CD & DevOps

From ./mvnw test to continuous delivery.

The core ideas

A real pipeline (GitHub Actions flavor)

on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: 21, cache: maven }
      - run: ./mvnw verify            # compile + unit + integration
      - run: ./mvnw org.pitest:pitest-maven:mutationCoverage
      # SAST + dependency scan steps
      - run: docker build -t registry/atm:${{ github.sha }} .
      - run: docker push registry/atm:${{ github.sha }}
push β†’ compile β†’ unit β†’ integration (Testcontainers)
     β†’ static analysis + CVE scan
     β†’ build artifact ONCE (jar β†’ image, tagged by SHA)
     β†’ STAGING β†’ E2E + smoke load test
     β†’ [approval] β†’ PROD canary β†’ smoke β†’ full rollout

Principles: build once, promote the same image (config changes per env, binary doesn't); immutable tags (SHA, not latest); pipeline under ~10 minutes or people stop trusting it.

Git workflow

Containers & orchestration

FROM eclipse-temurin:21-jdk AS build
COPY . .
RUN ./mvnw -q -DskipTests package
FROM eclipse-temurin:21-jre        # runtime: JRE only
COPY --from=build target/atm.jar /app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

Deployment strategies

StrategyHowTrade-off
RollingReplace pods a few at a timeSimple, default; both versions run briefly
Blue-greenTwo full envs; flip the LBInstant rollback; double cost
Canary1% β†’ 10% β†’ 100% traffic, watching metricsSafest for money; needs observability
Feature flagsDeploy dark, enable at runtimeDecouples deploy from release; flag debt

Banking reality: canary + flags, with automatic rollback wired to error-rate alerts.

IaC & operating it

"CI runs the same ./mvnw verify I run locally, plus scans, on every push; the jar becomes a Docker image built once and promoted through staging to prod. I'd deploy canary-style with automatic rollback on error-rate, manage infra with Terraform and deploys with GitOps, and keep DB migrations expand-contract so rollback is always possible β€” in banking, reversibility is the requirement."

Backend Fundamentals

Idempotency, locking, indexing & friends β€” each anchored to transfer, where they all bite.

1. Transactions & ACID

A transfer debits Bob and credits Alice β€” both or neither:

BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 'bob';
UPDATE accounts SET balance = balance + 50 WHERE id = 'alice';
INSERT INTO ledger_entries ...;
COMMIT;   -- or ROLLBACK, and nothing happened

ACID = Atomicity, Consistency (constraints hold), Isolation, Durability. Isolation levels weakest→strongest: read uncommitted → read committed (Postgres default) → repeatable read → serializable. Money runs read committed plus explicit locking, because the default alone permits lost updates.

2. Race conditions & locking

The lost update: two ATMs read Bob's balance (100), both approve a 100 withdrawal, both write 0 β€” the bank lost 100. Two cures:

Pessimistic β€” lock before reading:

SELECT balance FROM accounts WHERE id = 'bob' FOR UPDATE;
-- check funds, update, COMMIT releases the lock

Right when conflicts are likely. Cost: waiting + deadlock risk (A→B locks A then B; B→A locks B then A). Prevention: always lock rows in deterministic order (lowest account ID first).

Optimistic β€” detect at write time via a version column:

UPDATE accounts SET balance = 50, version = 8
WHERE id = 'bob' AND version = 7;   -- 0 rows = someone beat you: retry

Right when conflicts are rare. JPA's @Version does this. (In-JVM: synchronized = pessimistic; AtomicLong.compareAndSet = optimistic.)

3. Idempotency (the payments non-negotiable)

Client transfers, network times out, client retries β†’ without protection, double transfer. Fix: unique idempotency key per logical operation:

POST /transfers
Idempotency-Key: 7f3a-...-91c2

server: INSERT INTO processed_requests(key, response) ...
        -- unique constraint: second insert fails
        -- β†’ return the STORED response, don't re-execute

Rules: store the key in the same transaction as the money movement; replay the first response; TTL the keys. Same cure for LB retries, Kafka redelivery, double-clicks. "Exactly-once delivery" doesn't exist; at-least-once + idempotent processing is how the industry spells it.

4. Indexing

An index is a sorted structure (a B-tree) beside the table: find-by-value becomes O(log n).

CREATE INDEX idx_ledger_account_time ON ledger_entries(account_id, created_at);

5. Pools & backpressure

Connections are expensive; a pool (HikariCP) lends out ~10–30 warm ones. Pool exhausted = latency spike β€” the classic soak-test failure. Related: timeouts on everything, retries with exponential backoff + jitter, circuit breakers (Resilience4j β€” fail fast after N failures, probe periodically).

6. Scaling the data layer

  1. Read replicas β€” writes to primary, reads fan out. Catch: replication lag β€” route read-your-own-writes to the primary.
  2. Partitioning β€” split a huge table inside one DB (ledger by month).
  3. Sharding β€” customers A–M on shard 1, N–Z on shard 2. Cross-shard transfers now need sagas. Postpone as long as possible.
  4. CAP intuition β€” during a partition, choose Consistency or Availability. Money chooses C. This is why "just cache the balance" is the wrong instinct.

7. Patterns that tie it together

"For a transfer I'd run both legs in one DB transaction with row locks taken in deterministic account-ID order to avoid deadlock, require an idempotency key stored in that same transaction so retries can't double-move money, back it with an append-only double-entry ledger indexed on (account_id, created_at), and publish events via the outbox pattern β€” then let nightly reconciliation prove the invariants held."

API Protocols

HTTP/REST, gRPC, WebSocket & friends β€” what transfer Alice 50 looks like over each.

1. HTTP β€” the substrate

Request = method + path + headers + body; response = status + headers + body.

MethodMeaningIdempotent?Safe?
GETreadyesyes
PUTreplace whole resourceyesno
POSTcreate / do somethingno β€” twice = twice the effectno
PATCHpartial updatenot guaranteedno
DELETEremoveyesno

POST's non-idempotency is exactly why transfers need an Idempotency-Key header.

Status codes cold: 200 OK Β· 201 Created Β· 204 No Content Β· 400 malformed Β· 401 who are you Β· 403 you can't Β· 404 not found Β· 409 conflict Β· 422 semantically invalid ("insufficient funds") Β· 429 rate limited Β· 500 server bug Β· 502/504 the LB talking Β· 503 overloaded.

HTTP/1.1 = one request at a time per connection; HTTP/2 = multiplexed binary streams (what gRPC rides); HTTP/3 = same over QUIC/UDP.

2. REST β€” conventions for HTTP APIs

POST /api/v1/customers/bob/transfers
Idempotency-Key: 7f3a-91c2
{ "target": "alice", "amount": 50 }

201 Created
{ "id": "tx-123", "moved": 30, "balance": 0,
  "debts": [ { "to": "alice", "amount": 70 } ] }

The response is literally your TransferResult record as JSON β€” the domain already speaks API.

3. gRPC β€” services talking to services

service Bank {
  rpc Transfer (TransferRequest) returns (TransferResult);
  rpc WatchBalance (WatchRequest) returns (stream BalanceUpdate);
}
message TransferRequest { string from = 1; string target = 2; int64 amount = 3; }

4. WebSocket β€” full-duplex, long-lived

One HTTP request upgrades into a persistent two-way channel; either side pushes anytime. Use when the server must push AND the client talks back: chat, trading dashboards, live collaboration. Costs: stateful connections (sticky LB routing or a Redis backplane), reconnect/heartbeat logic.

5. The rest of the "etc."

6. Choosing

NeedReach for
Public API, broad clients, curl-ableREST/JSON
Internal service-to-service, strict contractsgRPC
Server pushes, client also sendsWebSocket
Server pushes onlySSE
Notify external partnersWebhooks (signed, retried)
Flexible read aggregation for UIsGraphQL
Async decoupling, event fan-outKafka + outbox

"I'd expose the bank as REST at the edge β€” POST /v1/transfers with an Idempotency-Key, 422 for insufficient funds β€” use gRPC between internal services, push balance updates over SSE or WebSocket depending on whether the client talks back, and fan events out through Kafka via the outbox. Same domain core under all of them β€” that's why it never prints or parses anything itself."

SQL & NoSQL

SQL you can write by hand; NoSQL you can choose correctly. Anchored to the ATM schema.

1. The ATM as a relational schema

CREATE TABLE accounts (
  id         UUID PRIMARY KEY,
  name       TEXT NOT NULL UNIQUE,   -- findByName + no duplicates
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entries (         -- double-entry: balance is derived
  id         BIGSERIAL PRIMARY KEY,
  account_id UUID NOT NULL REFERENCES accounts(id),
  amount     BIGINT NOT NULL,        -- + credit, βˆ’ debit
  tx_id      UUID NOT NULL,          -- groups the two legs of one transfer
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ledger_account_time ON ledger_entries(account_id, created_at);

CREATE TABLE debts (
  debtor_id   UUID NOT NULL REFERENCES accounts(id),
  creditor_id UUID NOT NULL REFERENCES accounts(id),
  amount      BIGINT NOT NULL CHECK (amount > 0),
  created_at  TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (debtor_id, creditor_id)  -- one debt per direction, as a constraint
);

Business rules you wrote in Java become constraints the database enforces β€” the last line of defense no application bug can bypass.

2. Queries to write without thinking

-- Bob's balance (derived)
SELECT COALESCE(SUM(amount), 0) AS balance
FROM ledger_entries WHERE account_id = :bob_id;

-- 20 most recent transactions
SELECT amount, created_at FROM ledger_entries
WHERE account_id = :bob_id
ORDER BY created_at DESC LIMIT 20;

-- JOIN: debts with names
SELECT d.amount, deb.name AS debtor, cred.name AS creditor
FROM debts d
JOIN accounts deb  ON deb.id  = d.debtor_id
JOIN accounts cred ON cred.id = d.creditor_id;

-- GROUP BY + HAVING: owing more than 100 total
SELECT deb.name, SUM(d.amount) AS total_owed
FROM debts d JOIN accounts deb ON deb.id = d.debtor_id
GROUP BY deb.name
HAVING SUM(d.amount) > 100
ORDER BY total_owed DESC;

The distinctions interviewers test:

3. NoSQL β€” four families

FamilyExampleShapeSweet spot
Key-valueRedis, DynamoDBvalue by key, O(1)sessions, cache, rate limits, idempotency keys
DocumentMongoDBnested JSON docsprofiles, catalogs β€” read-together, flexible fields
Wide-columnCassandra, Bigtablehuge sparse rows, partitionedtime-series, write-heavy telemetry at scale
GraphNeo4jnodes + edgesfraud rings, social graphs, recommendations

Traded away vs relational: cross-document ACID, JOINs (you denormalize), schema enforcement. Gained: native horizontal scale, schema flexibility, huge throughput with the right access pattern.

4. The decision, honestly

"The ledger goes in Postgres β€” ACID across both legs of a transfer, unique and check constraints enforcing invariants below the application, a composite index on (account_id, created_at) for statements. Around it, Redis for sessions and idempotency keys β€” money picks relational because correctness constraints are cheap there and hand-built everywhere else."

Java Concurrency β€” Translated from Go

Every Go concept mapped to Java, then the parts Go hides from you.

0. The translation table (start here)

GoJavaNotes
go f()Thread.ofVirtual().start(f)Virtual threads ARE Java's goroutines (21+) β€” cheap, millions OK
go f() (pre-21)executor.submit(f)Platform threads β‰ˆ 1MB each; pool them
channel ch <- x / <-chBlockingQueue.put/takeSame semantics, blocks the same way
sync.Mutexsynchronized / ReentrantLocksynchronized is built into every object
sync.RWMutexReadWriteLockMany readers or one writer
sync.WaitGroupCountDownLatchAwait N completions
select { }no direct equivalentClosest: invokeAny, structured concurrency (21 preview)
atomic.AddInt64AtomicLong.addAndGetSame lock-free CAS
context cancellationThread.interrupt() + Future.cancel()Cooperative, like Go
-race detectornone built-inDiscipline + jcstress; minimize shared mutability

Cultural difference: Go says "share memory by communicating" (channels first). Java historically communicates by sharing memory (locks first) β€” though BlockingQueue pipelines are idiomatic Java too, and usually cleaner.

1. Threads β€” platform vs virtual

// Platform: wraps an OS thread. Expensive. Pool them.
ExecutorService pool = Executors.newFixedThreadPool(16);
pool.submit(() -> bank.transfer("Bob", "Alice", 50));

// Virtual (Java 21): JVM-scheduled, like a goroutine. Spawn freely.
ExecutorService vpool = Executors.newVirtualThreadPerTaskExecutor();
vpool.submit(() -> bank.transfer("Bob", "Alice", 50));

Virtual threads park on blocking I/O without holding an OS thread β€” the goroutine trick. Rule: virtual threads for I/O-bound work, fixed ~CPU-core pools for CPU-bound.

2. The race in OUR code

Customer.add is three operations pretending to be one:

balance = Math.addExact(balance, amount);  // READ, ADD, WRITE

Two threads deposit 100: both read 0, both write 100 β†’ one deposit vanished. The lost update. Three fix families:

Lock it (sync.Mutex style):

public TransferResult transfer(String from, String target, long amount) {
    synchronized (lock) {     // one thread in the money code at a time
        // existing logic unchanged
    }
}

Coarse but correct β€” the honest first answer. Refinement: per-account ReentrantLocks acquired in deterministic order (sort by name) β€” the same ordered-locking discipline as SQL FOR UPDATE.

CAS it (atomic style): AtomicLong for a lone counter. Not enough for transfer β€” two accounts must change together; atomics are per-variable. That limitation is why real money uses transactions/locks.

Confine it (channel style): one worker thread owns the Bank; requests arrive via a BlockingQueue. No sharing, no locks β€” the Go instinct, valid in Java, and effectively what the single-threaded CLI already does.

3. Visibility β€” what Go mostly hides

Without synchronization, a write by thread A may never become visible to thread B (caches, reordering). Java's rulebook = the Java Memory Model; the phrase to know is happens-before.

4. Thread-safe collections

5. Coordination (the WaitGroup aisle)

CountDownLatch done = new CountDownLatch(2);   // WaitGroup
pool.submit(() -> { work(); done.countDown(); });
pool.submit(() -> { work(); done.countDown(); });
done.await();                                   // wg.Wait()

Future<TransferResult> f = pool.submit(() -> bank.transfer(...));
TransferResult r = f.get(2, TimeUnit.SECONDS);  // with timeout

CompletableFuture.supplyAsync(this::fetchRate)  // promise pipeline
    .thenApply(rate -> convert(amount, rate))
    .orTimeout(2, TimeUnit.SECONDS);

Also exists: Semaphore (bounded concurrency β€” a buffered-channel token pool), CyclicBarrier, StructuredTaskScope (Java 21 preview β€” roughly Go's errgroup).

6. Deadlock in five lines

// T1: synchronized(alice) { synchronized(bob)   { ... } }
// T2: synchronized(bob)   { synchronized(alice) { ... } }  β†’ both wait forever

Cure: acquire locks in deterministic global order. Detection: jstack <pid> prints "Found one Java-level deadlock".

"Every mutation β€” Customer.add/subtract and the ledger's ArrayList β€” is a read-modify-write with no synchronization, so concurrent transfers lose updates and can corrupt the list. Cheapest correct fix: synchronize the Bank's public methods. Better: per-account ReentrantLocks acquired in name order to avoid deadlock, ConcurrentHashMap with computeIfAbsent in the repository. In real deployment this moves into the database β€” row locks and transactions β€” and the service tier stays stateless on virtual threads, which are Java 21's goroutines."