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
- Run
./start.sh and play a full session by hand.
- Read
Bank.java until you can narrate transfer (net β pay β owe) and deposit (repay oldest-first) without looking.
- Skim every test name β they are the requirements checklist.
- 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
- Class = state + behavior.
Customer holds a name and balance and the rules for changing them.
- Interface = a contract with no state.
CustomerRepository says what (find, save) without how; InMemoryCustomerRepository provides the how. Code depending on the interface doesn't change when the implementation does β the whole trick behind "swap in a database later."
final on a class = cannot be subclassed. Default to it.
final on a field = assigned once in the constructor, never reassigned. Use it everywhere you can.
- Visibility:
public, private, and package-private (no keyword β same package only). The ATM uses package-private for Debt's constructor and Bank's test constructor: visible to same-package tests, invisible to everyone else.
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
- Checked (
IOException): compiler forces throws or try/catch. For expected external failures.
- Unchecked (
RuntimeException subclasses): propagate freely. Business-rule violations are modeled this way: InsufficientFundsException, UnknownCustomerException.
- Pattern to internalize: throw typed exceptions deep, catch them at one boundary. The domain throws; only
CommandLoop.execute() catches, mapping each type to its user message.
- Exceptions can carry data:
InsufficientFundsException carries the balance so the message can say Your balance is $50.
IllegalArgumentException / ArithmeticException (from Math.addExact on overflow) mark programming errors β callers should have checked first.
Where to look: CommandLoop.java (the catch ladder), InsufficientFundsException.java
4. Collections
List<Debt> β ordered, allows duplicates; ArrayList is the default. The ledger's insertion order is the FIFO repayment order β the data structure encodes a business rule.
Map<String, Customer> β keyβvalue lookup. LinkedHashMap keeps insertion order; HashMap doesn't.
List.of(...) / Map.of(...) β immutable literals; mutation throws.
List.copyOf(list) β immutable defensive copy; the domain returns these so callers can't mutate internal state.
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
long over int for money-like quantities; Math.addExact throws on overflow instead of silently wrapping.
var β local type inference; use when the type is obvious from the right-hand side.
String.split("\\s+") splits on runs of whitespace (regex).
- A
private constructor (private Messages() {}) prevents instantiating utility classes.
- Static methods (
Messages.hello(...)) belong to the class, not an instance β right for pure functions.
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());
}
}
- A new test-class instance is created per test method β fields reset automatically.
- No
public needed on test classes or methods.
- Tests mirror the main package β same-package tests can use package-private members (how
BankDepositTest reaches the package-private Bank(repo, ledger) constructor to seed debts).
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
- Test names are sentences about rules:
depositRepaysCreditorBeforeBalance. Read the test list = read the spec.
- Body shape is arrange / act / assert.
- One behavior per test β a failing name alone tells you which rule broke.
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:
| Beat | What you do | Why |
| π΄ Red | Write ONE small test for behavior that doesn't exist. Run it. Watch it fail. | A test you've never seen fail proves nothing. |
| π’ Green | Write the minimum code to pass. Dumb is fine. | Elegance comes later, under test protection. |
| π΅ Refactor | Clean 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
- 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.
- One failing test at a time.
- Name tests as sentences about rules.
transferToCreditorNetsDebtWithoutMovingMoney.
- Watch it fail β always. The #1 way tests silently pass on broken code is skipping this.
- Size steps by confidence. Lost in a tricky rule? Shrink: trivial case, then edges, then the weird ones.
- Never bend a failing test to match the code. The test is the spec; the code moves.
- Commit on green, every cycle. Free undo points; history tells the design story.
What TDD buys you (say this in the interview)
- Design pressure. Hard-to-test code is bad design caught early. The ATM's seams (
Console, CustomerRepository, a Bank that never prints) exist because tests demanded them. Testability and good architecture are the same force.
- Regression safety. When asked to "add feature X," you change code with dozens of tests guarding every rule. You refactor fearlessly.
- YAGNI, enforced mechanically. Code exists only if a test demanded it.
Common beginner mistakes
- Writing all tests up front, then all code β waterfall in a TDD costume. Alternate.
- Skipping Red.
- Chasing coverage β test rules, not getters.
- Elegant Green. Make it pass stupidly, then refactor under protection.
- Asserting too much per test β one behavior each.
For the pairing session
- Start every requirement out loud: "First I'll capture this as a test so we agree on the behavior."
- When something breaks, let the failing test name diagnose it before reading code.
- Stuck on implementation? Shrink the test β a smaller step is always available.
- After any refactor: run the tests, and say why β "green means we didn't break the rules we've captured."
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:
./mvnw test β compile + run tests.
./mvnw package β all of that plus build the jar.
./mvnw -DskipTests package β jar without tests (what start.sh does for fast startup).
./mvnw clean package β delete target/ first; no stale classes.
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:
- Nobody who clones needs Maven β just a JDK.
- Everyone builds with the same version.
- Generated with
-Dtype=only-script so no jar is committed β satisfies the challenge's "no binary files" rule.
- Bootstrapping the wrapper is the only step needing a local Maven:
mvn -N wrapper:wrapper -Dmaven=3.9.11 -Dtype=only-script
Troubleshooting one-liners
| Symptom | Move |
| Weird stale behavior | ./mvnw clean test |
| One test class only | ./mvnw test -Dtest=BankTransferTest |
| Flaky downloads | check ~/.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.
stdout vs stderr: two streams. 2>/dev/null discards stderr.
command | grep -E "pattern" filters; head/tail take first/last lines.
command -v mvn β "is this installed?"
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:
| Question | Your 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:
- New simple command (
balance, debts) β failing CommandLoopTest case β new handler class β registry line in Main. Practice once for real.
- Transaction history β
history command; a TransactionLog appended from Bank methods.
- 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.
- Cents support β parsing + formatting; do NOT reach for
double; store minor units in long.
- Persistence β
FileCustomerRepository behind the existing interface; the seam pays off visibly.
- 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
- Fakes over Mockito:
FakeConsole exists because production code depends on the Console interface β testability came from design.
- The sample-session integration test is the acceptance gate; units pin rules.
- "What would you add?" β property-style invariant: total money is conserved across any operation sequence.
- They may describe a bug and ask you to write the failing test first. Pure TDD red.
4. Design-principles chat
- SOLID here: one handler per command (SRP),
Console/CustomerRepository (DIP), new command = new class not a modified switch (OCP).
- Production-real changes to name-drop with understanding: double-entry ledger, idempotency keys, audit log, DB transactions.
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
- Think aloud constantly; silence reads as lost.
- Open every task with one clarifying question ("should
balance also show debts?").
- Small steps, run tests after each change, commit on green.
- Stuck? Say what you're weighing, don't freeze.
- If they suggest something questionable, engage technically β pushback is sometimes probed deliberately.
Pre-session checklist
- β Run
./start.sh, play a session by hand
- β Narrate
Bank.transfer (net β pay β owe) and Bank.deposit (oldest-first) without looking
- β Skim all test names β they are the requirements checklist
- β Do drill #1 (add
balance test-first), then git reset --hard it away
- β IDE ready: single-test runs fast; font size up for screen share
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:
- Identity:
String name β AccountId (UUID). Names are not identity in a real bank.
- Money as a type:
long β a Money value object once multi-currency exists.
- Double-entry ledger: replace mutable
balance with append-only debits/credits; balance = a projection. Audit, reproducibility, no lost updates by design. The single biggest domain change.
Scalability & LB
- Make the service stateless: CLI
Session disappears; identity arrives per request (JWT). Then N instances behind a load balancer.
- The hard part is consistent money movement under concurrency: DB transactions with row locks taken in deterministic order, or optimistic locking with retry.
- Idempotency keys on every transfer: retries never double-move money. Non-negotiable in payments.
Cache β with care
- β
Cache: reference data, sessions/tokens, idempotency keys (Redis + TTL), rate-limit counters.
- β οΈ Balances: only as explicitly-stale read models β never to authorize a withdrawal. Authorization reads go to the DB inside the transaction.
WAF & security (outside β inside)
- WAF in front of the LB: OWASP filtering, bot detection, DDoS absorption.
- Rate limiting at the gateway per token/IP.
- TLS everywhere; mTLS between internal services.
- OAuth2/OIDC for users; a real ATM adds PIN verification against an HSM.
- Secrets in a vault; encryption at rest; field-level encryption for sensitive columns.
- Audit trail: every movement immutably logged β regulators require it; double-entry gives it almost free.
Observability
- Structured logs (JSON) with a trace ID per request; no
System.out.
- Metrics: Micrometer β Prometheus β Grafana. Business metrics too: transfers/sec, failed-auth rate, p99 per endpoint.
- Tracing: OpenTelemetry spans gateway β service β DB.
- SLOs + alerting: "99.9% of transfers < 500ms"; page on error-budget burn, not every blip.
- Health endpoints (
/health/live, /health/ready) for LB and orchestrator.
Deployment & async
- Docker + Kubernetes: rolling deploys, autoscaling, self-healing. CI/CD with the existing tests as gate. Terraform for reproducible infra. Backups + PITR for the ledger DB.
- Domain events (
TransferCompleted) to Kafka via the outbox pattern; cross-bank movement becomes a saga.
- Compliance shapes the data model: KYC/AML status, transaction monitoring hooks, PCI DSS if cards.
What survives vs what changes
| Layer | Verdict |
| Domain rules (net β pay β owe; oldest-first) | Survive as-is |
| Result records / exception design | Survive β map 1:1 to API responses/error codes |
CustomerRepository seam | Survives β implementation becomes Postgres |
Mutable balance field | Replaced by double-entry ledger + projections |
String name as identity | Replaced by account IDs |
CLI Session | Replaced by per-request tokens |
CommandLoop/handlers | Replaced by controllers β same shape: thin adapter β domain call β render result |
| The 61 tests | Domain 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
- Integration with real infra: repository tests against real Postgres via Testcontainers (throwaway DB in Docker). The interface means one contract-test suite runs against in-memory AND Postgres.
- API/E2E: the sample session becomes an HTTP scenario (REST Assured).
- Contract testing (Pact): consumers define expected request/response pairs; your CI verifies you honor them. Prevents "renamed a field, broke mobile."
- Property-based (jqwik): assert invariants over thousands of generated inputs β "for ANY operation sequence, total money is conserved". Finds edge cases humans don't imagine.
- Mutation testing (PIT): mutates production code (flips
< to <=), checks a test fails. Coverage % lies; mutation score doesn't.
2. Performance testing β a family
Tools: Gatling, k6, JMeter. Each type answers a different question:
| Type | Question | Shape |
| Load | Meets SLOs at expected traffic? | Ramp to peak, hold, measure p95/p99 + errors |
| Stress | Where does it break, and how? | Ramp until failure; want graceful degradation, not corruption |
| Soak | Degrades over hours/days? | Moderate load 8β72h; catches leaks, pool exhaustion |
| Spike | Survives sudden 10Γ? | Instant jump; tests autoscaling + backpressure |
| Volume | Fast 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
- SAST (SonarQube, Semgrep) β static scan in CI.
- Dependency scanning (Snyk, Dependabot) β known CVEs; the Log4Shell catcher.
- DAST (OWASP ZAP) β attacks the running app: injection, auth bypass, IDOR.
- Pen testing β humans, periodically; regulator-required.
- Secrets scanning (gitleaks) in CI.
4. Resilience & operational testing
- Chaos engineering: kill instances, inject latency, drop the DB in a controlled experiment; verify LB reroutes and retries+idempotency hold. Start in staging.
- DR drills: actually restore from backup on a schedule. An untested backup is a hope.
- Post-deploy smoke tests: 1β2 min critical path gating each rollout stage; auto-rollback on failure.
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
- CI: every push is built and tested automatically; breakage caught in minutes. Your
./mvnw test is already the heart β CI is a robot running it on every commit.
- CD: Continuous Delivery = every green build can deploy at a button-press; Continuous Deployment = it just does. Banks usually stop at Delivery with approval gates.
- DevOps: the team that builds it runs it; infrastructure is code; feedback loops are short.
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
- Trunk-based (modern default): short-lived branches, PR review, merge to
main often; main always releasable. Pairs with feature flags.
- GitFlow: long-lived develop/release branches; still common in regulated release trains.
- Either way: protected
main, CI green + review required before merge.
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"]
- Docker: jar + JRE in an image β "works on my machine" becomes "works everywhere." Multi-stage keeps it small.
- Kubernetes: N replicas, rolling deploys, self-healing, autoscaling, service discovery. Requires statelessness + health endpoints β same requirements as the LB story.
- GitOps (ArgoCD/Flux): desired state lives in git; deploy = merge a PR bumping the image tag; rollback = revert. Audit trail free β regulators like it.
Deployment strategies
| Strategy | How | Trade-off |
| Rolling | Replace pods a few at a time | Simple, default; both versions run briefly |
| Blue-green | Two full envs; flip the LB | Instant rollback; double cost |
| Canary | 1% β 10% β 100% traffic, watching metrics | Safest for money; needs observability |
| Feature flags | Deploy dark, enable at runtime | Decouples deploy from release; flag debt |
Banking reality: canary + flags, with automatic rollback wired to error-rate alerts.
IaC & operating it
- Terraform: infra declared in versioned code;
plan shows the diff before apply. DR = re-apply.
- Same image everywhere; per-env config via env vars; secrets from a vault, never git.
- Environments: dev β staging (prod-like; E2E/load run here) β prod. Non-prod-like staging is theater.
- Rollback discipline: previous image ready; DB migrations backward-compatible (expand-migrate-contract: add column β dual-write β remove old).
- On-call: alerts β runbooks β blameless postmortems feeding the backlog.
- DORA metrics: deployment frequency, lead time, change failure rate, MTTR. Elite = daily+ deploys, <1h restore.
"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);
- PKs and unique constraints are indexes already.
findByName β unique index on accounts.name.
- Composite order matters:
(account_id, created_at) serves "Bob's transactions newest-first"; the reverse doesn't. Leftmost-prefix rule.
- Every index taxes writes β index what you query.
EXPLAIN ANALYZE shows index use vs table scan.
- Unique indexes are correctness tools: the idempotency table works because a constraint can't be raced; an application check can.
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
- Read replicas β writes to primary, reads fan out. Catch: replication lag β route read-your-own-writes to the primary.
- Partitioning β split a huge table inside one DB (ledger by month).
- Sharding β customers AβM on shard 1, NβZ on shard 2. Cross-shard transfers now need sagas. Postpone as long as possible.
- 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
- Double-entry ledger: append-only debits/credits; balance = SUM or a projection. Immutable rows sidestep update races; audit for free.
- Outbox: write domain change + event in one transaction; a relay publishes to Kafka. Solves the dual-write problem.
- Saga: multi-service operation = local transactions + compensations. The distributed replacement for a transaction you can't have.
- Reconciliation: nightly job proving invariants (every debit has a credit). Last line of defense; first thing auditors ask.
"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.
| Method | Meaning | Idempotent? | Safe? |
| GET | read | yes | yes |
| PUT | replace whole resource | yes | no |
| POST | create / do something | no β twice = twice the effect | no |
| PATCH | partial update | not guaranteed | no |
| DELETE | remove | yes | no |
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.
- Nouns, not verbs:
POST /transfers, not /doTransfer.
- Errors as structured bodies: your exceptions map 1:1 (
InvalidAmount β 400/422, UnknownCustomer β 404, InsufficientFunds β 422).
- Versioning:
/v1/ in the path.
- Pagination:
?cursor=...&limit=50 β cursor beats offset at scale.
- Statelessness: every request carries auth (JWT) β the property that makes LB scaling work.
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; }
- Binary + HTTP/2 = much faster/smaller than JSON/HTTP1.
- Contract-first with codegen β a field rename breaks at compile time, not in production.
- Four shapes: unary, server streaming, client streaming, bidirectional.
- Not for public APIs: browsers can't speak it natively; not curl-able. Typical layout: REST at the edge, gRPC inside.
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."
- SSE β one-way serverβbrowser push over plain HTTP, auto-reconnect built in. Push-only (notifications, balance ticker)? SSE is simpler than WebSocket. Also what LLM chat streaming uses.
- Webhooks β reverse API: you call their URL on events ("transfer settled β POST to merchant"). Hard parts: retries + HMAC signature verification + receiver idempotency β your idempotency story again.
- GraphQL β client picks exactly the fields it wants; great for flexible UI reads, wrong for money commands.
- Queues (Kafka) β not request/response: fire an event, consumers handle later. Contrast: REST/gRPC = "do this now, tell me the result"; queue = "this happened, whoever cares will handle it."
6. Choosing
| Need | Reach for |
| Public API, broad clients, curl-able | REST/JSON |
| Internal service-to-service, strict contracts | gRPC |
| Server pushes, client also sends | WebSocket |
| Server pushes only | SSE |
| Notify external partners | Webhooks (signed, retried) |
| Flexible read aggregation for UIs | GraphQL |
| Async decoupling, event fan-out | Kafka + 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:
- WHERE vs HAVING: WHERE filters rows before grouping; HAVING filters groups after aggregation.
- JOIN types: INNER (matches only), LEFT (all left rows, NULLs where no match), RIGHT, FULL. 90% of real queries are INNER or LEFT.
- NULL is not a value:
= NULL never matches; use IS NULL. COALESCE(x, 0) supplies defaults.
- Aggregates: COUNT/SUM/AVG/MIN/MAX;
COUNT(*) counts rows, COUNT(col) skips NULLs.
UPDATE ... WHERE β never forget the WHERE. INSERT ... ON CONFLICT DO NOTHING β the idempotency-key insert.
3. NoSQL β four families
| Family | Example | Shape | Sweet spot |
| Key-value | Redis, DynamoDB | value by key, O(1) | sessions, cache, rate limits, idempotency keys |
| Document | MongoDB | nested JSON docs | profiles, catalogs β read-together, flexible fields |
| Wide-column | Cassandra, Bigtable | huge sparse rows, partitioned | time-series, write-heavy telemetry at scale |
| Graph | Neo4j | nodes + edges | fraud 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
- Money movement β relational, non-negotiable. ACID across two accounts, constraints as guardrails, auditability.
- Around the core, polyglot is normal: Redis for sessions/idempotency, documents for profile blobs, Cassandra-class if history hits billions of rows, graph for fraud rings.
- The trap question β "would MongoDB work for the ATM?": you can, but you'd hand-build transactions, constraints, and reconciliation that Postgres gives free. Choose the DB that makes your invariants cheap.
- Access pattern first, database second. NoSQL punishes choosing before knowing queries; relational forgives exploration.
"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)
| Go | Java | Notes |
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 / <-ch | BlockingQueue.put/take | Same semantics, blocks the same way |
sync.Mutex | synchronized / ReentrantLock | synchronized is built into every object |
sync.RWMutex | ReadWriteLock | Many readers or one writer |
sync.WaitGroup | CountDownLatch | Await N completions |
select { } | no direct equivalent | Closest: invokeAny, structured concurrency (21 preview) |
atomic.AddInt64 | AtomicLong.addAndGet | Same lock-free CAS |
context cancellation | Thread.interrupt() + Future.cancel() | Cooperative, like Go |
-race detector | none built-in | Discipline + 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.
volatile = visibility only β volatile long balance does NOT fix read-add-write.
- Locks give visibility AND atomicity β leaving a lock publishes your writes to the next acquirer.
final fields are safely visible after construction β your records and final classes are thread-safe by construction. Immutability is the cheapest concurrency strategy, and the ATM domain is mostly there already.
4. Thread-safe collections
HashMap/ArrayList are NOT thread-safe β concurrent writes can corrupt them (worse than Go maps, which at least panic).
ConcurrentHashMap β the workhorse. map.computeIfAbsent(name, Customer::new) = create-once safely; that's the repository made concurrent in one line.
CopyOnWriteArrayList β reads free, writes copy; for listener lists, not ledgers.
BlockingQueue β your channels.
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."