Take-home Discussion — 40 min

Q: "Walk me through your submission" — the opener (~75-90s)

This is the most likely opening question. Practice this until it flows. It front-loads the two best things, names a weak spot before they spot it, and hands the floor back.

"Sure. It's a 5-screen POS payment flow — main, pick method, enter amount, show QR, result. Four layers: data, which is network plus a local session store for restore; one PaymentRepository as the use case layer; ViewModels only on screens with state; and Compose for UI.

Two parts are worth your time.

First, the polling in PaymentRepository is a small state machine. There's a delay before the first call — on purpose, the customer needs time to scan and a poll at t=0 always returns PENDING. PENDING loops at the base interval. 429 honors Retry-After. Other errors do exponential backoff and surface a connectivity warning without aborting the flow. Cancellation propagates cleanly.

Second, the network layer is one interface with two implementations — DebugPaymentApi and ProductionPaymentApi — selected by build type, not a runtime flag. Debug forwards to production when nothing's mocked and simulates locally when I trigger it via adb. Lets me build the client against fault injection without waiting on a backend, and production code never sees the debug class.

A few things I cut for the 3-hour budget, documented in NOTES: the auth token is hardcoded — it's the sandbox token Komoju gave me, real flow would be sign-in with a refresh token in encrypted storage. Polling values are hardcoded but isolated in one config object, ready for remote config. And non-429 4xx errors are still retried — a 404 should stop the flow.

AI scope: UI scaffolding and unit test setup. The architecture, the polling state machine, the debug split, and session restore are mine. For QR scaling: the idea was mine (I saw the blurry result from the test API), the algorithm is a standard one, and AI did the implementation. I verified by asking AI to walk me through the algorithm step by step.

Where do you want to dig in?"

Delivery notes:

  • Speak the polling paragraph slowly — it's the technical anchor.
  • Pause after "two parts are worth your time" — tells them what to ask about next.
  • The closing question hands the floor back. Don't trail off.
  • Auth token + AI scope are volunteered here. Don't repeat them later unless asked.

Anchors (in order): 4 layers → polling → debug split → 3 cuts → AI → handback.


Decisions to defend (one short answer each)

Decision Short answer
One PaymentRepository, no use-case classes "5 screens, 4 endpoints. Use-cases would be extra ceremony. I would split when the file gets too big or domains diverge."
Polling lives in repo, not ViewModel "Backoff and retry are business logic. The ViewModel only maps state to UI. It should not know about Retry-After."
Flow over LiveData or callbacks "Cold flow cancels when the ViewModel is cleared. No leaks. Cancellation works through CancellationException."
NonCancellable for clearSession "If the ViewModel is cleared mid-write, we keep stale session data. Next launch is broken. NonCancellable makes sure the write finishes." See QrCodeViewModel.kt:70,84.
Debug API via build type + ServiceLoader priority "Debug builds use DebugPaymentApi, production builds use ProductionPaymentApi — selected by source set, not a runtime flag. DebugPaymentApi forwards to the real one when no simulation is set, simulates locally when triggered. Production code never sees the debug class. Bonus: client and backend can be built in parallel — I can ship the client against the debug impl while the backend is still being built. I usually add the debug impl first on real projects."
ADB-driven debug, not in-app menu "Debug menus add code to the binary. They also can not test background events well. dumpsys lets me send a command from the terminal and get a result back." (See iamtuna.org article.)
Session restore via local storage "The most likely failure is the merchant's device crashing. Local storage handles that. I also wrote down the alternative: GET /sessions/active on the server. That is more robust — it works after a device change. I would use that in production."
Lich, not Hilt "What we use at LY across 100+ engineers in 5 offices. Just the factory pattern plus a central component registry — no annotation magic, low learning curve, fast onboarding. The ServiceLoaderComponent makes the debug swap clean. I would still pick Lich on a team project, not switch to Hilt."
AndroidViewModel + SavedStateHandle for QR screen "sessionId survives process death. With local storage restore, the merchant resumes after the OS kills the app."
MVVM, not MVI "MVI's reducer is too much for these simple screens. Sealed classes give me type safety with less code." Match the resume line: 'reduced abstraction and boilerplate'.
First poll runs after a delay, not at t=0 "The customer needs time to scan the QR. A poll at t=0 always returns PENDING. That is a wasted call. The API already has 429, so saving calls matters. If the cashier needs faster feedback, I can shorten the first interval. I would not poll at t=0."
Polling values isolated in StatusPollingConfig "Hardcoded today, but kept in one config object. There is a TODO (PaymentRepository.kt:191) for server config. The structure is ready: ship a tuning change without a release, A/B per merchant, back off if the backend is hot."
Custom QR upscaling: nearest-neighbor + binarize "Server returns a tiny 30x30 PNG. The default Bitmap.createScaledBitmap uses bilinear filtering. That makes the QR pixels blurry. QR is pure black and white, so I scale with filter=false (nearest-neighbor), then binarize with BT.601 luminance and 128 threshold. See QrCodeScreen.kt:209. I cache the result with remember(session.sessionId) so it does not run again."
OkHttp directly, no Retrofit "Familiar, saved time. For 3 endpoints with kotlinx.serialization, Retrofit's adapter is extra code. If I had ~10 endpoints, I would add Retrofit."
Tracking service (impression / click / error) "Not in the spec. I added it because the README said 'think like a product owner'. For a payment flow, telemetry helps: drop-off per screen, error rates, polling failures. The implementation is a fake LoggingTrackingService. The interface is real and can be swapped."
MVVM only for screens with state "Main and Result are simple — one button. Adding a ViewModel would be extra code. The rule: if the screen has nothing to observe, the screen is the state holder. This avoids the Compose trap I wrote about in NOTES."

Things I documented in NOTES.md as "temporary solutions"

I wrote these down myself. Say them in the opening as "things I cut for the 3-hour budget". This shows I prioritized well.

  1. Payment method list returns IDs only — no name or icon. UI shows the ID. Production: API should return (id, displayName, iconUrl).
  2. Auth token hardcoded in BuildConfig — Komoju gave me this sandbox token. Production: sign-in flow + Keystore-wrapped refresh token (see weak spot D).
  3. Polling values hardcodedpollingIntervalMillis, maxDelayMillis, errorThreshold should come from a remote config service. We can tune live, A/B per tier.
  4. HTTP errors all handled the same — only 429 has its own path. 4xx (like 404) should stop polling. 5xx should retry but with longer backoff. Marked as a // TODO in code.

If they ask "what would you do with another 8-10 hours?" — start with this list. It maps to the README's deliverables.


Polling algorithm — say this from memory

I wrote this in NOTES. Walk through it if asked to whiteboard.

1. Wait before the 1st attempt (initial delay — saves server, gives customer time to scan)
2. GET /status
3. Handle response:
   3.1 HTTP 200
       - PENDING → loop with initial delay (reset)
       - PAID/EXPIRED/FAILED → emit and stop
   3.2 HTTP 429
       - Loop with delay from `Retry-After` header if present, else initial delay
   3.3 Other HTTP error or network error
       - errors++
       - delay *= backoffFactor (capped at maxDelay)
       - emit PollingState with hasConnectivityIssue if errors >= threshold
       - loop

Key things to call out:

  • CancellationException is rethrown — cooperative cancellation.
  • Errors reset on success — one good response clears the error UI.
  • 429 path is separate from the error pathRetry-After (or initial delay) drives 429; exponential backoff drives other errors. Two different signals, two different policies.
  • No jitter today — I would add that in production.

AI usage — say this before they ask

The README says: AI for "boilerplate, helpers, self-contained code" is OK. AI for "fundamental architecture" is not. I stayed inside the OK line. Be ready to draw it:

"I used AI for: the Compose UI scaffolding (5 screens), the unit tests (I prompted, reviewed, fixed the setup), the tracking service plumbing, and the API response data classes — straight JSON-to-POJO mapping, mechanical work that AI handles fast. I did NOT use AI for: the layer architecture, the polling state machine, the debug-API split, the session-restoration design, or the polling-vs-push trade-off.

QR scaling is the interesting one. The decision was mine — I saw the blurry result from the test API, recognized the bilinear-filter problem, and decided to scale at bitmap-time with nearest-neighbor plus binarize. The algorithm is a standard one. I asked AI to do the implementation, then asked it to walk me through the algorithm step by step so I could verify the loop was correct. So: I framed the problem and the solution shape, AI wrote the code, I checked it.

I review every AI suggestion and rewrite when it does not fit. The decisions are mine. The syntax is shared work."

Surface this during the walkthrough so they do not have to ask.


Q: "How did you produce this much code in 3 hours?" — the volume pushback

Likely follow-up to the AI scope answer. The interviewer's real question is "did you actually build this, or did AI build it?" Don't get defensive — reframe the question. The senior signal is knowing what to delegate, not how fast you can type.

"First, the framing: the README explicitly allowed planning before the 3-hour coding window. I used that. Most of the studying, the low-level design of the layers, and the AI-vs-human partitioning happened up front. When the clock started, I was implementing decisions I'd already made — not figuring them out.

The partition was: AI gets the work where its output is fast and verifiable in seconds — Compose UI for five screens, unit tests against an interface I'd already designed, and the API response data classes (mechanical JSON-to-POJO mapping). I keep the work where the wrong call costs hours later — the layer architecture, the polling state machine, the data-layer split, the API contract.

Two things made the AI parts tractable. First, I designed for testability up front — every dependency is injected through an interface, so runTest with a fake API works and AI can generate tests without rewriting production code. Second, I built the API mock before the real integration — same separation-of-concerns pattern I use at work. The client could be feature-complete against DebugPaymentApi while the contract with the real backend was still settling. Defers integration risk to a single point.

What AI didn't do: the layered architecture, the polling/backoff/error state machine, the debug-vs-production network split, the session-restore design, the QR scaling decision. Those are the calls that shape every other line of code. If they're wrong, typing speed doesn't matter — you just produce wrong code faster.

The way I think about it: AI is a fast junior pair. You hand it the well-scoped pieces with clear contracts. You don't hand it the architecture, and you don't trust the output without reading it. The skill is knowing which is which."

Delivery notes:

  • Lead with the README allowance — that's the legitimate frame. Don't apologize, don't imply you compressed planning into the 3 hours.
  • The "designed for testability up front" line is the senior-IC signal. It shows the architecture enabled the AI delegation — it wasn't an afterthought.
  • "Junior pair" metaphor lands well in interviews. Most engineers know it.
  • Don't list every weak spot here — that's a different question. Stay on the partitioning thesis.

Weak spots — own them before they ask

Walk in knowing these. Pattern: "I noticed X. Here is the impact. Here is the fix."

A. Polling waits before the first request — DEFEND, don't apologize

PaymentRepository.kt:138delay(currentDelay) is at the top of the loop. This is on purpose. The customer needs time to scan. A poll at t=0 always returns PENDING. The API already returns 429, so we should save calls.

"On purpose. The first useful poll comes after the customer had time to scan. Polling at t=0 wastes a call. If they push — 'what if the customer is fast?' — the interval is small (1s) and tunable. The worst case is a one-second delay on a flow that takes seconds anyway."

If they keep pushing, mention the alternative: a short-then-grow interval (500ms, 1s, 2s…). Only mention if asked.

B. 404 / non-429 4xx is treated as retryable

PaymentRepository.kt:156-166 — broad catch (Exception) will retry forever on a deleted session. There is a // TODO already. Be ready with the fix:

"Map ApiException with code in 400..499 (excluding 429) to a terminal error. Emit FAILED and stop the flow. The catch should branch on exception type."

C. Polling does not stop when the app is in background

QrCodeViewModel.kt:77viewModelScope does not pause on background. Cashier presses Home → app keeps calling the API.

"Use flowWithLifecycle(STARTED) on the screen, or expose start/stop from the VM with DefaultLifecycleObserver. I would also pause to save battery."

D. Hardcoded token in build.gradle.kts — defend the scope, then explain production design

build.gradle.kts:31 — this is the sandbox token Komoju gave me. It is not a real merchant token. Do not apologize. Talk about how I would do auth in production.

"That is the sandbox token from Komoju for this take-home. Fine for testing. In a real POS app, the merchant signs in. Backend gives a short-lived access token plus a refresh token. Access token in memory. Refresh token in EncryptedSharedPreferences or Keystore-wrapped. OkHttp Authenticator handles 401 → refresh → replay. Logout clears both. The BuildConfig token would be replaced by a TokenStore injected into AuthInterceptor."

If they push on the build pattern: "For values that are sandbox-secrets (sandbox keys, beta hosts), I would read from local.properties (gitignored) into BuildConfigField. They do not enter git history."

E. QrCodeViewModel.session is a plain var read from Compose

QrCodeViewModel.kt:43, read at QrCodeScreen.kt:108. The checkNotNull works because the UI only reaches that branch after Polling is emitted. But the compiler does not check it.

"I split it from StateFlow because the QR base64 is large. I did not want to recompose on every status update. Better fix: put it in Polling(session: PaymentSessionData). Compose will skip recomposition if the state is equals. The cost is small."

F. SharedPreferences is not encrypted

PaymentSessionDataManager.kt:40-44 — the TODO in code already says this.

"For this take-home, I decided the QR base64 is not credentials. It is a one-time scan target. But amount and sessionId could leak transaction history. EncryptedSharedPreferences is a one-line swap. In a real POS, I would move to server-side resume."

G. DebugPaymentApi has a data race

DebugPaymentApi.kt:29,54,87simulateResultState is written from a binder thread (dumpsys) and read from the polling dispatcher. Plain var, no @Volatile.

"Debug-only, but still wrong. @Volatile minimum, or AtomicReference. If they pull on this, agree fast."

H. Double-tap on Continue can create two sessions

EnterAmountViewModel.kt:43 — guard exists for Loading but not after Success.

"After Success, navigation has not finished yet. Add a consumed flag, or disable the button on any non-Idle state."

I. PaymentSessionStatus.PENDING appears in ResultScreen

ResultScreen.kt:93-94,105 — the type does not stop a Pending result. Should be a separate TerminalStatus enum.


Likely questions — pre-built answers

Q: Why one repository instead of separate use-cases?

"Pragmatism. 5 screens, 4 endpoints — most methods are thin pass-throughs to the data layer, mapping errors and responses to types the presentation layer can use. The only real business logic is the polling state machine. Network error tracking also lives here, since this is the boundary where the network actually happens. Splitting GetPaymentMethodsUseCase, CreateSessionUseCase, etc. would be 4 classes that each call one API. That is noise. I would split when the file gets too big or domains diverge — for example, separate classes per domain."

Q: Why poll instead of webhooks or push?

"First — the API Komoju gave me is poll-based. So for this take-home, that was the constraint. But if I had to design the protocol from scratch, polling is still a fine choice. The trade-off is where the cost is.

Quick comparison:

  • Short polling (what we have): server is stateless. Scales with QPS, not with open connections. Client carries backoff and lifecycle. 429 is the natural backpressure.
  • Long polling: fewer requests, but the server holds one open socket per merchant. Risk of leaked connections.
  • WebSockets: persistent two-way connection per merchant. Best UX, highest cost. Needs sticky sessions or a pub/sub bus to scale.
  • SSE (Server-Sent Events): one-way HTTP stream. Server can push many messages over one connection. Cheaper than WebSockets (one-way, plain HTTP, works well with HTTP/2). Still one open connection per client. Good middle ground.
  • FCM push: cost moves off your server. But you depend on Google services. Latency varies. Delivery is not 100%. iOS would need APNs — two protocols.

For 'customer pays while cashier watches' that takes seconds, polling moves a small amount of complexity to the client (backoff, lifecycle, cancellation). The server stays cheap and stateless. ~1-3s polling is the right UX."

If they push on cost: "At Komoju's scale — many merchants, many short sessions — open connections from long polling, WebSockets, or SSE are expensive. Polling lets the server stay stateless behind a CDN or simple horizontal scaling. The 429 in the API is the natural backpressure — it tells the client to back off, with no server state."

If they push on UX: "A hybrid is fine — SSE for live updates while the QR is on screen, fall back to polling on connection drop. Most of the push benefit, most of the polling simplicity."

Q: How do you choose the polling interval?

"Three things to balance: server rate limit (429 tells you the ceiling), how fast the merchant feels (~2s feels responsive), battery. The values are in StatusPollingConfig with a TODO for server config — see PaymentRepository.kt:191. Hardcoded for the take-home, but the structure is ready: tune without a release, A/B per merchant tier, react to backend load."

Q: What happens on rotation during polling?

"The ViewModel survives because of viewModelScope. The Flow is collected in viewModelScope.launch, so it is not restarted. The UI re-subscribes to the uiState StateFlow, and the last value is replayed. sessionId comes from SavedStateHandle, so even after process death, we resume to the same session ID. Then init reloads from local storage and restarts polling."

Q: What if there are two sessions on the device?

"Today, that cannot happen — PaymentSessionDataManager stores one session. For multi-session, storage would be a list keyed by sessionId. The harder question is: which session is 'active' for the merchant? That is a UX decision."

Q: How would you test the polling Flow?

"runTest with virtual time. Inject a fake PaymentApi with scripted responses. Advance time and check emissions. Tests should cover: PENDING→PAID happy path, 429 with Retry-After honored, exponential backoff on errors, terminal status stops the flow, cancellation propagates. I have some of this. The gap is the 4xx-terminal case."

Q: How do you handle authentication in a real POS app?

"Merchant signs in with their Komoju credentials. Backend gives a short-lived access token plus a refresh token. Access token in memory. Refresh token in EncryptedSharedPreferences (or Keystore-wrapped). OkHttp Authenticator handles 401 → refresh → replay. Logout clears both."

Q: The README says APIs are flaky. Where does your code show you took that seriously?

"Three places: (1) exponential backoff with Retry-After honored in polling, (2) consecutiveErrors and hasConnectivityIssue so the UI can warn the merchant without aborting, (3) typed NetworkException vs ApiException so we can react differently. Gaps: 4xx is not terminal yet, no jitter on backoff, no circuit breaker."

Q: Why store the QR base64 in the session model? Why not fetch it again on resume?

"Trade-off. Re-fetching means creating a new session. That costs server resources, and the new QR might be different. Storing it lets the merchant resume the same session. Cost: storage size and the security concern I called out."

Q: Walk me through the QR code scaling. Why not Bitmap.createScaledBitmap or a Compose Modifier.size?

"Server returns a 30x30 PNG. Display is ~1080dp wide. If the framework upscales with the default bilinear filter, you get gray edges on QR pixels. That is the noise QR scanners hate. Two steps in QrCodeScreen.kt:209: (1) scale with filter=false for nearest-neighbor, edges stay sharp; (2) binarize with BT.601 luminance and 128 threshold, so any leftover gray becomes pure black or white. QR is binary, so binarize is safe — no information lost."

On AI: the decision was mine — I noticed the blurry QR from the test API and recognized this is a standard 'upscale a binary image' problem. I asked AI to write the implementation, then asked it to walk me through the algorithm step by step so I could verify. So: I framed the problem and the solution shape; AI wrote the loop; I checked it. That fits the README's 'self-contained algorithm' rule.

If they push on performance: "~1M iterations of a constant-time loop. Runs well under a second on any modern phone. It runs once per session — remember(session.sessionId) caches it. Normal UX, no blocking the user can feel."

If they push on the right fix: "The right fix is the opposite of 'send a bigger image'. A bigger PNG just costs more bandwidth. A QR code is 1 bit per module: a 30x30 QR is 900 bits ≈ 112 bytes if the server sends raw bits. The client renders at any size. A small base64 PNG is 1-2KB by comparison. So the production protocol I would argue for is { size: 30, bits: <base64-bitstream> }. Client renders. My scaler is the workaround for the PNG-based API we got."

If they ask about Compose Modifier.scale(): "Compose's default rendering is also bilinear. You can use FilterQuality.None on Image. That is the lighter alternative. I went with bitmap-time scaling and binarize because the binarize step is what actually helps the scanner — FilterQuality.None keeps edges sharp, but does not fix any anti-aliasing already baked into the source PNG."

Q: You said in NOTES that you do not really like Jetpack Compose. So why did you use it?

"Honest answer: it is the obvious choice for a new Android app in 2026 — declarative, fast to iterate, and the team I would join is probably already on it. My critique is specific: Compose makes it easy to mix presentation logic with UI logic in the same @Composable. There is also a culture trap — forcing MVVM on simple screens because 'that is the right pattern'. My counter is what I did here. Main and Result have no ViewModel — just a stateless composable with a callback. State sealed classes for screens that need them. No state holder for screens that do not. Use the framework for what it is good at. Do not perform architecture rituals."

Q: Tracking is not in the spec. Why did you add it?

"The README invited me to think like a product owner. For a payment flow, telemetry is cheap insurance. You will spend more time debugging a 0.1% drop-off without it than the hour to add it. The implementation is a fake LoggingTrackingService that prints to Logcat. But the interface and call sites are real — screen=qr_code action=back, error=poll_session_status network. Swap the implementation, ship the dashboards. Komoju is a payments company. Observability of state transitions is the kind of thing I expect to discuss in week one."

Q: Why did you add the debug API before the production one?

"Two reasons. First, it lets client and backend ship in parallel. The client can be feature-complete and tested against fault injection while the API is still being built. Second, it forces you to think about the contract before the transport. You write the interface, write the fake responses, write the tests, then implement the wire format. Same TDD instinct, applied at the integration boundary."

Q: How would you scale this to all of Komoju's methods (~30 across Japan, Korea, China, SEA, Europe)?

"The list endpoint already returns IDs. Next step: server returns (id, displayName, iconUrl, methodType). Client does not need a hardcoded mapping. Image loading via Coil. Lazy column. The bigger problem is method-specific UI — Konbini needs a voucher code screen with a long-running poll (cash payment can take days), card needs 3DS2, bank transfer needs a wait screen, QR wallets need a deep link out and back. I would model the per-method shape as an 'extra fields schema' from the server, and group methods by methodType so the client picks the right screen variant. The state machine stays the same: PENDING → PAID/EXPIRED/FAILED. That is the unifying contract across every method."

results matching ""

    No results matching ""