43 min read
Engineering9,360 words

STAR Interview Prep

First-round dossier. STAR answers for every achievement, plus the tech-follow-up drills, a behavioral-question → story map, an opening pitch, and questions to ask them. Built to match the three things they told you they care about: Structure (STAR), The Tech (concurrency, thread-safety, DI, architecture, testing, PostgreSQL, DDD/EDD), and The Mindset ("Product Ownership" — the why behind every choice).


0. How to use this#

Read this once, out loud, twice. Don't memorize it word-for-word — the MIT page you were sent explicitly warns against scripting. Memorize the shape of each story (the four beats and the one number at the end) and let the words come out fresh.

The STAR ratios (from the MIT page they linked):

BeatShare of your airtimeWhat it isTrap
S — Situation~20%Just enough context to understand the example. 2 sentences.Over-scening. They don't need the org chart.
T — Task~10%Your responsibility or the goal. 1 sentence.Blurring into Situation.
A — Action~60%What you personally did, and why. This is the answer.Listing tech instead of decisions. Saying "we."
R — Result~10%The outcome, quantified, + one thing you learned.No number. No learning.

Three rules that win a first round:

  1. Say "I," not "we." They're screening you. "We built an outbox" tells them nothing about what you did. "I introduced the transactional outbox because we were losing events on dual-write" tells them everything. Use "we" only to set the scene, then switch to "I" for the actions.
  2. Lead every Action with the why, not the what. Their stated mindset is Product Ownership: "explain the why behind your architectural choices, not just the code." So the pattern is always "I chose X because Y, over the alternative Z." That single word — because — is what makes you sound senior.
  3. Land a number. End every story on a result you can defend under questioning.

⚠️ The placeholder rule. Anywhere you see ‹angle brackets›, that's a number or fact only you know — drop in the real one before your interview. Never say a figure you can't defend; a made-up metric collapses the moment they ask "how did you measure that?" If you don't have a hard number, use an honest qualitative result ("eliminated the class of lost-event incidents entirely") — that's stronger than a fake percentage. Everything below is a draft in your voice: adjust any detail that doesn't match what you actually did.

Senior vs Lead framing. They're hiring at both levels. If they're feeling you out for senior, lead with deep individual technical ownership — stories #1 (Session module), #5 (Contract mgmt), #8 (ONS). If it tilts lead, lead with #4 (Roaming hub / eMSP-as-a-Service) and #7 (strangler fig), and emphasize the pattern-setting and cross-team parts of #5. Same stories, different emphasis.


1. Your opening pitch — "Tell me about yourself"#

This is almost always the first question. It sets the frame for the whole call. ~60 seconds. Present → past → why-you're-here.

"I'm a backend engineer specializing in distributed systems — event-driven architecture, data consistency, and resilience — mostly in the JVM world with Kotlin, Java and Spring Boot on PostgreSQL and Kafka.

For the last ‹X years› I've been at DCS, an EV e-mobility platform, working on the roaming side — the systems that let a driver charge at any operator's hardware over the OCPI protocol. I owned the charging-session module end to end: an event-driven pipeline that folds a firehose of session updates into a consistent view, with a transactional outbox and inbox so we never lose or double-count an event, and CQRS read models that serve multiple OCPI protocol versions at once. From there I moved into a ‹staff-plus / lead› role across the whole roaming hub, where I drove our shift to a white-label 'eMSP-as-a-Service' product and set the domain-driven design boundaries the teams build on. I also redesigned our contract and account management around the Party data model and Temporal.io for durable sagas.

Before the platform work I did analytics engineering with dbt, which is where I learned to bring real engineering discipline — tests, modularity, CI — to the data side.

What draws me to this role is the emphasis on product ownership and getting concurrency and data-modeling right at both the application and database level — that's exactly the work I care about and the level I want to keep operating at."

20-second version (if they want it short, or for a recruiter screen):

"Backend engineer, distributed systems — Kotlin/Java/Spring on Postgres and Kafka. I spent the last few years at an EV-roaming platform owning the charging-session module and then the roaming-hub architecture: event-driven pipelines, sagas, CQRS, DDD. Strong on data consistency, concurrency, and resilience — and I like owning the why, not just the code."

Tips: Don't recite your CV chronologically — they have it. Give them the headline and the hook (product ownership + concurrency/DB) so they steer toward your strengths. Have both lengths ready; read the room.


2. The STAR answers#

Nine stories, one per achievement. Each has: the four STAR beats in your voice → a one-breath "why" for the product-ownership follow-up → the technical follow-ups they're likely to drill → a recall cue to memorize.


Story 1 — The charging-session module (event-driven pipeline)#

Demonstrates: flagship architecture · data consistency at scale · concurrency · CQRS · the dual-write problem. This is your strongest "most challenging technical project" story — lead with it.

Situation. At DCS, the charging-session module is the heart of roaming: whenever a driver charges at any operator's hardware, we receive a high-volume stream of session-update events over OCPI from many operators and hubs, and we have to present one consistent session and drive start/stop commands back. The delivery is at-least-once and out of order, and different roaming partners speak different OCPI protocol versions.

Task. I owned the design and build of that module: make session state correct and reliable under an at-least-once firehose, serve reads in multiple OCPI versions simultaneously, and feed analytics — without ever losing or duplicating an event.

Action. I modeled the session as an event-driven aggregate: incoming updates feed an aggregate pipeline that folds the stream into an accumulative session object, so the current state is always the fold of everything we've seen — the natural model for an entity built from many partial updates. The hard part was consistency across the database and Kafka, so I introduced the transactional outbox: every state change and its outgoing event are written in the same Postgres transaction, and a relay polls the outbox and publishes to Kafka — because you can't atomically write to a database and a broker, and a naive dual-write loses or duplicates events on any crash between the two. On the consumer side I added a transactional inbox so every consumer is idempotent — the dedup mark commits inside the business transaction, so an at-least-once redelivery is absorbed instead of double-applied. Then I split reads from writes with CQRS: the write side ingests the granular firehose, but the read side had to be materialized into multiple OCPI-version projections (2.1.1 vs 2.2.1 have different wire shapes) for different partners — so I built each version as an independent projection off the same event stream. That decoupling mattered because our write volume dwarfs our reads, and it let me add a new OCPI-version read model without ever touching the write path. For concurrency I used optimistic locking on the aggregate — an append at a stale expected version hits a unique (aggregate_id, version) constraint and surfaces as a 409 — and I keyed Kafka partitions by session id so all updates for one session stay ordered. Finally I streamed the raw event log to a data lake — on the order of ‹~2.5M› session-update events ‹/ per day — confirm your real figure and window› — so analytics got full-fidelity history and replay without hammering the operational database.

Result. Session state became consistent and reliable under the at-least-once stream — we eliminated the class of lost-and-duplicated-update incidents entirely — while serving ‹N› OCPI versions concurrently and giving analytics the complete event history. ‹Add one hard number you can defend: throughput, p99 read latency, or incident reduction.› The lesson that stuck: modeling the session as a folded event stream and making the boundaries idempotent (inbox) and atomic (outbox) was the whole unlock — everything else followed from that.

The "why" in one breath. "A session is a stream of partial truths from the operator. The only reliable way to hold that is: make ingestion idempotent, make state-plus-publish atomic with an outbox, fold the stream into the aggregate, and split the version-specific reads out with CQRS — so the write firehose and the multi-version read contracts can evolve independently."

Likely follow-ups:

  • Kafka is at-least-once — how did you get exactly-once? → You don't get true end-to-end exactly-once; you get at-least-once delivery + idempotent processing = effectively-once. The inbox dedups on message id inside the business transaction, and a rolled-back transaction removes the mark so a genuine redelivery still reprocesses. The outbox relay is deliberately at-least-once (FOR UPDATE SKIP LOCKED, mark published on success, retry on failure).
  • Why outbox instead of just publishing after the DB commit? → Because there's no atomicity across two systems: crash after the commit but before the publish and the event is lost; publish first and the DB rollback orphans it. The outbox collapses both writes into one transaction.
  • Optimistic or pessimistic locking on the aggregate — and why? → Optimistic. Conflicting concurrent writes to the same session are rare, and a 409-and-retry is far cheaper than holding a row lock across the whole pipeline. Postgres enforces it with the UNIQUE (aggregate_id, version) constraint — no extra lock manager.
  • How do you stop the multiple OCPI-version read models from diverging? → There's a single source of truth (the event stream); every version is a pure projection, never a second write path. Contract tests per version guard the wire shape, and a new version is additive — a new projector — never a mutation of the write side.
  • How did you keep ordering with a partitioned topic? → Partition by session id, so all of one session's updates are ordered within its partition; cross-session order is irrelevant. Where retries could reorder, I made the transitions idempotent so ordering was never load-bearing for correctness.

Recall cue: S: OCPI firehose, at-least-once, multi-version partners. T: own a consistent session module. A: event-folded aggregate + outbox + inbox + CQRS multi-version reads + optimistic lock + data lake. R: killed lost/dup events, N versions served, ‹number›.


Story 2 — The live session map#

Demonstrates: real-time systems · CQRS as a design tool · concurrency / fan-out · knowing when eventual consistency is fine.

Situation. On top of the session module, product and operations wanted a live map of every active charging session — where sessions are running, their status, updating in real time — for an operational view and for the product.

Task. I built a real-time, horizontally-scalable read model and delivery path for live session state to a map UI, without loading the operational write database.

Action. I treated the map as just another CQRS read model — a different question asked of the same event stream — rather than a query bolted onto the write side. I built a dedicated projection that consumes the session events and maintains only the live (active) sessions, shaped and indexed for map bounding-box queries. For delivery I used a push transport ‹WebSocket / SSE — say which you used› so clients get updates as state changes, and the real engineering was the fan-out concurrency: many concurrent subscribers, a high update rate, and slow clients that can't be allowed to back up the whole stream. I kept the push layer stateless apart from a thread-safe connection registry, applied backpressure (for a map you only care about the latest state, so I could drop-to-latest for a slow consumer instead of buffering unboundedly), and handled session-end cleanup with tombstones/TTL so ended sessions fall off the map instead of leaking.

Result. Operations and product got a sub-‹second› live view that scaled to ‹N› concurrent sessions and viewers with zero additional load on the write database, because it was a separate projection with its own store. The takeaway: a purpose-built read model per query shape beats overloading one model — the map's needs (geo, real-time fan-out) are nothing like the session module's needs (consistency, multi-version), and CQRS let each be optimal.

The "why" in one breath. "The map is a different read of the same events, so it's a separate CQRS projection — real-time viewers never contend with the write firehose, and I can tune its store for geo-queries and its transport for fan-out independently."

Likely follow-ups:

  • Real-time delivery — WebSocket vs SSE vs polling? → SSE is simpler and fine for one-directional server→client push (which a map is); WebSocket if you need bidirectional; polling as a floor. The tricky shared state is the connection registry — that has to be thread-safe, or you offload fan-out to a broker.
  • How do you handle a slow or dead subscriber (backpressure)? → Bounded per-connection buffers with drop-to-latest — for a live map you only care about current state, so coalescing stale updates is correct — and disconnect clients that fall too far behind rather than let them consume memory.
  • Is the map consistent with the authoritative session? → It's eventually consistent, and that's the right call: the map is an operational hint, the session module is the source of truth. Sub-second staleness is invisible on a map and buys you the decoupling.

Recall cue: S: wanted a live ops map of active sessions. T: real-time read model, no load on writes. A: dedicated CQRS projection + push transport + fan-out backpressure (drop-to-latest) + TTL cleanup. R: sub-second view, N concurrent, zero write-DB load.


Story 3 — Security & API management (APIM)#

Demonstrates: ownership of a cross-cutting concern · security posture · declarative configuration over bespoke code · DI/config discipline.

Situation. The platform exposed APIs to three very different audiences — internal consumers, roaming partners, and hubs — and each service was handling authentication, rate limiting and security in its own way, which is inconsistent and easy to get wrong.

Task. I took ownership of the API management gateway and security configuration: one consistent, auditable authN/authZ, token handling, and rate-limiting/quota model across services, secure by default.

Action. I centralized ingress through the APIM gateway and made security declarative configuration rather than per-endpoint code: centralized OAuth2/JWT validation, subscription keys and quotas, rate limiting, IP restrictions, and request/response policies at the edge. For the hub-facing OCPI routes I standardized Token-scheme auth with a constant-time comparison (MessageDigest.isEqual, so token checks don't leak timing) and a hard rule that secrets are never logged — not in access logs, not in the dead-letter sink. Inside each service I wired security as a cross-cutting Spring Security filter chain via dependency injection, so every endpoint inherits the same posture instead of each team re-implementing it, and I kept the auth logic in interceptors that throw typed auth exceptions rather than writing responses inline — which keeps it testable with slice tests. Secrets moved out of config into a ‹vault / key store›.

Result. One consistent, reviewable security posture across all services; new partners onboarded by gateway policy rather than by code changes; and ‹reduced auth-related incidents / passed the security review with no criticals — use your real outcome›. The lesson: security and rate-limiting are most reliable when they're one declarative layer at the edge plus a standard, injected filter chain — because then no team can forget them.

The "why" in one breath. "Cross-cutting concerns belong in one declarative place, not scattered across services. APIM at the edge for coarse checks and a standard injected filter chain per service for fine-grained authz means it's consistent, auditable, and impossible to forget."

Likely follow-ups:

  • Do you terminate auth at the gateway or in the service?Defense in depth. The gateway does coarse checks (valid token, quota, IP allow-list); the service still does fine-grained authorization (scopes/roles) because you never fully trust the edge. A gateway breach or a misrouted internal call shouldn't mean unauthenticated access to business logic.
  • How do you keep secrets out of code and logs? → Vault/key store injected at runtime, never in source or config files; constant-time comparison for token equality; and header-scrubbing so Authorization never reaches logs or the dead-letter table.
  • Where do you enforce rate limits? → Coarse per-subscription limits at the gateway (token bucket), plus in-service bulkheads to protect downstream dependencies from overload — the two solve different problems (fair usage vs resource isolation).

Recall cue: S: three audiences, inconsistent per-service security. T: own one secure-by-default APIM + security model. A: centralize at gateway (OAuth2/JWT, quotas, rate limits) + OCPI Token constant-time + injected filter chain + secrets in vault. R: consistent posture, policy-based onboarding, ‹outcome›.


Story 4 — The roaming hub & "eMSP-as-a-Service" (product ownership at scale)#

Demonstrates: leadership · product ownership · influence without authority · strategic DDD. This is your headline lead-level story — it's the one that proves you own the "why," not just the code.

Situation. DCS ran a roaming hub connecting e-mobility providers (eMSPs) and charge-point operators (CPOs). Leadership wanted to grow the business, and I was moved into a ‹staff-plus / lead› role to help set the technical direction. The open question was strategic, not technical: where's the biggest customer pain we could turn into a product?

Task. I owned finding the biggest pain for our eMSP customers and shaping a productized answer to it — and then driving the architecture, with domain-driven design, to make that product buildable across teams.

Action. I started with discovery, not code: I worked with customers and business development, mapped the eMSP journey, and pinned down the biggest pain — every eMSP has to individually integrate with dozens of CPOs and hubs, each a months-long project of handshakes, protocol quirks and fragile operations, repeated for every new operator. That reframed the opportunity: the leverage isn't a better integration, it's removing integration — a white-label "eMSP-as-a-Service" where a customer consumes one modern API and one contract instead of N bespoke integrations, and we replace their in-house eMSP work with our platform. Then I drove the architecture with strategic DDD: I mapped the bounded contexts — sessions, billing/wallet, contracts & accounts, roaming — decided what was core domain (the roaming/session core, where we compete) versus supporting, and set hard context boundaries so teams could own a context and integrate through events rather than by reaching into each other's code. Because I had no direct authority over most of those teams, my real job was influence: I wrote the design docs and ADRs, aligned PM, BD and multiple engineering teams on the direction, and set the integration patterns — event-driven architecture, transactional outbox/inbox, orchestrated sagas — as the house standard so contexts composed consistently instead of each team inventing its own.

Result. We had a clear, productized white-label direction that replaced bespoke per-customer integration with a single platform offering, and DDD context boundaries that let ‹N› teams build in parallel without stepping on each other. ‹Add a business signal you can defend — customers/pipeline in the new model, or time-to-onboard a new eMSP dropping from months to ‹X›.› What I took from it: the highest-leverage senior work was finding the right problem before touching architecture — the DDD only paid off because it was pointed at a real customer pain.

The "why" in one breath. "The win wasn't a faster integration, it was deleting integration — hub-only connectivity plus one product API. And DDD gave us the context boundaries so sessions, billing and contracts could each be owned and scaled independently, instead of a monolith nobody could safely change."

Likely follow-ups:

  • How did you choose the bounded contexts? → By true invariants and ownership, not by data. Sessions, billing and contracts each have their own invariants, language and lifecycle, so each is a context; the boundary is where a consistency rule stops needing to be atomic. They talk via events, never shared tables.
  • How do you enforce context isolation in one codebase? → No cross-context code dependencies (I've enforced this structurally with ArchUnit rules — sessions can't import billing, the shared kernel stays context-agnostic), Kafka-only cross-context communication, and a shared kernel limited to value objects.
  • Give me a product-ownership call that wasn't the obvious engineering choice. → Making the northbound API product REST with 202-async semantics instead of exposing OCPI directly — OCPI would've forced customers to host callback endpoints and run a credentials handshake, re-imposing the very pain we were removing. And hub-only connectivity as a strategic constraint: one protocol family, one class of counterparty, hundreds of CPOs.

Recall cue: S: roaming hub, told to grow it, staff-plus role. T: find the biggest eMSP pain, productize it, drive the architecture. A: discovery → "delete integration" → white-label eMSP-as-a-Service → strategic DDD contexts → influence via ADRs + house patterns. R: productized direction, N teams parallel, ‹business signal›.


Story 5 — Contract & account management (Party Model + Temporal sagas)#

Demonstrates: database architecture · data modeling · DDD · durable orchestration · picking the right tool. Your strongest PostgreSQL / data-architecture story — they explicitly asked about this.

Situation. Contract and account management is core to the platform — who's under contract, on what tariffs, with which accounts — but the legacy master-data model was rigid and duplicated, and the multi-step processes that touch it (onboarding a contract, changing an account) weren't reliable: a partial failure could leave state half-updated across systems.

Task. I owned redesigning the master data and standardizing contract/account management so the model was flexible and the processes were reliably consistent across contexts.

Action. For the data I redesigned the master data around the Party Model pattern: a Party supertype with Person and Organization subtypes, and roles and relationships modeled explicitly as their own entities — because in this domain the same real-world entity plays many roles (a customer, a billing party, a roaming partner), and one-table-per-entity had forced us to duplicate that. The Party Model gave us a PostgreSQL schema that mirrors reality instead of duplicating it, with proper referential integrity and optimistic locking for concurrent edits. For the processes I standardized them on Temporal.io durable workflows to run the sagas — a contract onboarding is a long-running orchestration that touches accounts, billing and roaming, exactly the kind of thing that's error-prone to hand-roll — because Temporal gives you durable execution, automatic retries, timeouts and compensation as a platform capability, so I didn't have to build and operate a bespoke state machine plus crash-recovery for every process. The integration between contexts was event-driven over Kafka / Event Hub, with the transactional inbox/outbox for atomic state-and-publish and idempotent consumption, and Temporal owning the saga orchestration and compensation on top.

Result. One flexible master-data model replaced the duplicated legacy structures, and the contract/account processes stopped getting stuck — Temporal retries and compensates, so a partial failure resolves to a clean terminal state instead of half-applied data. ‹Quantify: onboarding time down from ‹X› to ‹Y›, or partial-failure/stuck-process incidents to ~zero.› The lesson: reach for the right durable-orchestration tool instead of rebuilding saga plumbing per process — and model your master data on how the domain actually works, not on convenient tables.

The "why" in one breath. "Two calls. Model parties by the Party Model so the schema mirrors reality — people and orgs playing roles — instead of duplicating entities. And run the multi-step processes on Temporal so durability, retries, timeouts and compensation are the platform's job, not fragile code I'd have to write and operate for every saga."

Likely follow-ups:

  • Temporal vs a saga you code yourself vs choreography — when each?Temporal when you want durable execution, long timeouts, visibility and compensation without building the machinery — great for business processes. A hand-rolled orchestrated saga (I've built these too — a plain persisted state machine with compare-and-swap transitions and a timeout sweeper for crash recovery) when you want zero extra infra and total control. Choreography when steps are loosely coupled and you don't want a central brain. The trade is control-and-visibility vs infra-and-coupling.
  • How does Temporal keep things correct under retries? → Activities are retried, so they must be idempotent — I key side effects with idempotency/command ids. The workflow itself is durable and effectively single-threaded per execution, and it must be deterministic (no wall-clock/random in the workflow body; those go through the SDK) so it can be replayed from history.
  • A flexible Party-Model schema can mean join explosions — how do you keep reads fast? → Index for the hot access paths, and where a query is hot, serve it from a read model / CQRS projection or a materialized view rather than joining the normalized master data every time. The write model stays correct and flexible; the read model stays fast.
  • Two people edit the same account at once — what happens?Optimistic locking with a version column: the second write hits a stale version, gets a 409, and retries against fresh state. Postgres unique constraints back the hard invariants so they can't be violated even under a race.

Recall cue: S: core contracts/accounts, rigid duplicated model, unreliable multi-step processes. T: flexible master data + reliable processes. A: Party Model on Postgres + Temporal durable sagas + Kafka EDA + outbox/inbox. R: one flexible model, no stuck processes, ‹number›.


Story 6 — Data migration: legacy (OpenInformer) → new system#

Demonstrates: risk management · high-stakes execution · correctness you can prove · handling revenue-critical data. Your best "high-stakes / how do you de-risk" story.

Situation. The new Party-Model-based contract & account system had to take over from a legacy system (‹OpenInformer›) that held years of live, revenue-critical master data. There was no acceptable version of this where we lost a contract or took a long outage.

Task. I owned migrating the master data — parties, contracts, accounts — from the legacy system into the new one, correctly and safely, and proving it was correct.

Action. I rejected a big-bang cutover and ran an incremental, parallel-run migration. I built idempotent, re-runnable ETL that mapped the legacy structures onto the Party Model, and — this was the part I insisted on — a reconciliation step that proved parity: row counts, checksums/hash totals per entity, and invariant checks (no orphaned accounts, balances tie out), plus spot audits. During the cutover window I kept the two systems in sync with ‹CDC / dual-write› so writes to the old system still landed in the new one, then flipped reads first, watched, and only then flipped writes — every step reversible, with a rollback plan ready. I sequenced the migration in phases by risk and communicated the cutover plan and its risks to stakeholders up front.

Result. We migrated ‹volume› records with ‹zero / near-zero› data loss and ‹minimal / no› downtime, and — because of reconciliation — we could demonstrate parity rather than hope for it, which is what let leadership sign off on retiring the legacy system. The lesson: for revenue-critical data, incremental + reconciled + reversible beats big-bang every time, and the proof of correctness (reconciliation) mattered as much as the migration code itself.

The "why" in one breath. "You don't get to lose a contract. So: map to the new model, run both in parallel, prove parity with reconciliation before flipping anything, and keep every step idempotent and reversible — correctness becomes provable, not hoped for."

Likely follow-ups:

  • Big-bang vs incremental — when would big-bang ever be right? → Only for small, low-risk datasets, or when a parallel run is genuinely impossible. For large, live, revenue-critical data the parallel-run-and-reconcile approach wins because you can verify and roll back.
  • How did you keep old and new consistent during cutover?‹CDC from the legacy DB / dual-write through an outbox›, with idempotent upserts keyed by natural key, so re-running or overlapping syncs converge instead of duplicating.
  • How did you actually prove it was correct? → Reconciliation: row counts, per-entity checksums/hash totals, invariant assertions, and shadow reads comparing old vs new for the same query. A migration you can't reconcile is a migration you can't trust.

Recall cue: S: years of live revenue-critical data in legacy OpenInformer. T: migrate to new Party-Model system, no loss, no outage. A: incremental parallel-run + idempotent re-runnable ETL + reconciliation proof + sync-then-flip-reads-then-writes, all reversible. R: ‹volume› migrated, ~zero loss, provable parity, legacy retired.


Story 7 — Retiring AMS with the strangler fig pattern#

Demonstrates: legacy modernization · incremental delivery · de-risking a scary project · architectural judgment.

Situation. ‹AMS — your account/asset-management system› was a legacy ‹monolith / service› that was risky to change but couldn't be thrown away — too much depended on it, and a big-bang rewrite of something that critical is exactly where projects go to die.

Task. I owned replacing AMS incrementally — no big-bang rewrite, no disruption to the consumers that depended on it.

Action. I applied the strangler fig pattern: I put a facade/routing layer in front of AMS, then carved out one capability at a time into a new service, routed that slice's traffic to the new service while everything else still hit AMS, and repeated — gradually "strangling" the legacy system until it had nothing left to do. Each new service sat behind an anti-corruption layer so it didn't inherit the legacy models, and I kept old and new consistent for each slice with ‹events / CDC› during the transition. I prioritized the slices by risk and value so every step shipped something useful and was independently reversible, rather than accumulating a giant unreleased rewrite.

Result. AMS was replaced incrementally across ‹N› extracted capabilities with no big-bang cutover and no major outage, and each slice delivered value as it landed instead of at the end. The takeaway: the strangler fig turns a terrifying all-or-nothing rewrite into a sequence of small, reversible, value-delivering steps — the risk profile is completely different.

The "why" in one breath. "A big-bang rewrite of a critical legacy system bets the company on one cutover. Strangler fig replaces it a slice at a time behind a facade — every step ships value, is reversible, and de-risks the next — so you're never one deploy away from disaster."

Likely follow-ups:

  • How do you route between old and new mid-migration? → A facade/gateway routing per capability, feature-flagged and ramped by percentage, with an anti-corruption layer translating between the legacy and new models so the new services stay clean.
  • How do you avoid data inconsistency across the old/new boundary? → Move one owner at a time — never let both systems own the same data — and sync with events/CDC during the hand-off; dual-ownership is where these migrations rot.
  • How did you pick what to extract first? → Highest risk-reduction or value first, or the lowest-coupling slice first to build momentum and prove the pattern before tackling the hard cores.

Recall cue: S: critical legacy AMS, too risky to rewrite, too important to keep. T: replace it incrementally, no disruption. A: strangler fig — facade + carve one capability at a time + anti-corruption layer + sync during hand-off + slice by risk/value. R: ‹N› capabilities migrated, no big-bang, no outage, value early.


Story 8 — ONS: resilient webhook notification service#

Demonstrates: resilience patterns · concurrency & thread-safety · isolating failure · at-least-once delivery. Your strongest concurrency / thread-safety / resilience story — anchor the resilience follow-ups here.

Situation. Clients needed to be notified of system updates, so we needed a reliable outbound webhook service — but the failure mode of any webhook fan-out is that one slow or dead client endpoint backs everything up and takes delivery down for everyone.

Task. I built ONS, ‹our outbound notification service›: event-driven, delivering webhooks to many clients, resilient to slow and failing client endpoints without ever affecting the rest of the platform.

Action. I built it event-driven on Kafka — internal domain events flow in, ONS consumes them and delivers webhooks out — so producers are fully decoupled from delivery. The core of it was the resilience design. I put a circuit breaker per client endpoint so that when a client is down we fail fast and stop hammering it, and recover automatically when it comes back — instead of piling up threads against a dead endpoint. And critically I used a bulkhead to isolate each client's concurrency into its own bounded pool, so one slow client can only ever exhaust its own budget and can never starve the shared thread pool and take down delivery for healthy clients — that isolation is the whole point. On top of that, retries with backoff and a dead-letter topic: undeliverable notifications are retried on non-blocking retry topics and, if still failing, quarantined in a DLT sink for later replay — never silently dropped, never blocking the stream behind a poison message. Delivery is at-least-once with idempotent, signed webhooks (dedup keys so a client can safely receive a retry), and I kept the consumers stateless with no shared mutable state so the whole thing is thread-safe by construction.

Result. Notifications became reliable, and — the thing I most wanted — one flaky client stopped being everyone's problem: the bulkhead isolates it and the circuit breaker stops us wasting resources on it, while the DLT means nothing is ever lost. ‹Add: delivery success rate, or "eliminated the one-slow-client-stalls-all-notifications incident class."› The lesson: any outbound integration has to assume every client will fail — you isolate (bulkhead) and fail fast (circuit breaker) by default, not as an afterthought.

The "why" in one breath. "The failure mode of a webhook fan-out is one slow client starving delivery for all the others. Bulkhead isolates each client's concurrency so it can't exhaust the shared pool; the circuit breaker stops us hammering a dead endpoint and recovers on its own; and retries plus a dead-letter sink mean nothing is lost and nothing poisons the stream."

Likely follow-ups:

  • Circuit breaker vs bulkhead — what does each actually protect against? → Different failures. The circuit breaker stops you calling a failing dependency (fail fast, then probe for recovery); the bulkhead isolates resource pools so one dependency's saturation can't starve others. You want both — a breaker without a bulkhead still lets a slow-but-not-failing client tie up your shared pool.
  • Thread-pool vs semaphore bulkhead — which and why? → A semaphore bulkhead just caps concurrency in the caller's thread — cheap, but it can't offload a blocking call. A thread-pool bulkhead runs the call on a bounded pool, which is what you need if you also want a TimeLimiter to bound a blocking HTTP call — a TimeLimiter can't interrupt a blocking socket read in place, so you compose thread-pool bulkhead → time limiter → circuit breaker, and back it with an HTTP read-timeout so a black-holed endpoint can't permanently shrink the pool.
  • How do you guarantee you never lose a notification? → At-least-once delivery + idempotent consumer + non-blocking retry topics + a DLT sink that records every poison message with enough context to replay after a fix. The dead-letter recorder itself never throws — a bad sink can't be allowed to poison anything.
  • Ordering of a client's notifications? → Partition by client id where order matters; elsewhere I make handling idempotent so out-of-order retries are safe, because non-blocking retries reorder by design.

Recall cue: S: need outbound webhooks; one slow client can stall everyone. T: resilient event-driven notification service. A: Kafka EDA + per-client circuit breaker + per-client bulkhead isolation + retry/backoff + DLT + idempotent signed delivery + stateless consumers. R: reliable delivery, one bad client can't hurt others, nothing lost.


Story 9 — Analytics engineering with dbt (Lilt)#

Demonstrates: breadth · engineering discipline applied to data · adaptability · designing with the consumer in mind.

Situation. At Lilt, the business needed trustworthy analytics and metrics, but the data was scattered and the transformations were ad-hoc SQL — a metric was often a mystery query in someone's notebook, and you couldn't fully trust the numbers.

Task. I owned building reliable, tested data transformations — analytics engineering — with dbt, so key metrics were reproducible and trustworthy.

Action. I built a layered dbt project — staging → intermediate → marts — that turned raw warehouse data into modular, version-controlled models, and I brought real software-engineering discipline to it: tests (schema tests for not-null, unique, and referential relationships, plus custom data tests and freshness checks), documentation and lineage so every model was self-describing, DRY models via refs and macros, incremental models for the large fact tables, and CI on the dbt project so a change that breaks a test can't merge. I worked with analysts and PM to define the canonical version of each key metric, so there was a single source of truth instead of five slightly-different definitions.

Result. Analytics became trustworthy: tested, documented, reproducible, with a single source of truth for the key metrics and ‹faster / less-broken reporting›. What I took from it: analytics engineering is software engineering — tests, modularity, CI and versioning are what make data trustworthy — and it made me a better backend designer, because now I design events and schemas with the downstream data consumer in mind, not just the service.

The "why" in one breath. "The data team had been writing one-off SQL, so no metric was reproducible. dbt lets you treat transformations like software — modular, tested, documented, version-controlled — so a number becomes something you can trust and trace instead of a query nobody can vouch for."

Likely follow-ups:

  • When and why incremental models? → For large fact tables where a full rebuild is wasteful — you only transform new/changed rows. The care points are late-arriving data and making sure a full-refresh still produces the same result, so correctness doesn't depend on incremental state.
  • How do you test data? → dbt's built-in schema tests (unique, not-null, relationships, accepted-values) plus custom tests and source freshness, all gating CI — so a violated assumption fails the build instead of silently corrupting a dashboard.
  • This is off your main backend track — why does it matter to you? → Same engineering principles, different surface, and it's exactly the breadth a lead needs: understanding the analytics/data side means I design my services' events and schemas to be good sources for it, which is where a lot of downstream pain otherwise comes from.

Recall cue: S: scattered data, ad-hoc SQL, untrustworthy metrics. T: build tested, reliable transformations with dbt. A: layered dbt models + tests + docs/lineage + incremental + CI + canonical metric definitions. R: trustworthy single-source-of-truth analytics; made me design backend with the data consumer in mind.


3. Behavioral-question → story map#

They won't ask "tell me about achievement #5." They'll ask behavioral prompts, and your job is to route each one to the right story. Learn this table — it's the muscle memory that makes you look composed.

If they ask about…Lead withBackupWhy it fits
Most challenging / complex project#1 Session module#5 Contract mgmtDeepest architecture + consistency at scale
Biggest technical problem you solved#1 Session module#8 ONSDual-write, at-least-once, concurrency
A system you're proud of#1 or #4#8Your best build / your best bet
Leadership / setting direction#4 Roaming hub#5Strategy + DDD + house standards
Influence without authority#4 Roaming hub#7Aligned PM/BD/teams via ADRs, no direct authority
Product ownership / owning the "why"#4 Roaming hub#5, #6Found the pain before the architecture
High-stakes / risk management#6 Migration#7Reversible, reconciled, parallel-run
Working with legacy / modernization#7 Strangler fig#6Incremental replacement, no big-bang
Improving reliability / resilience#8 ONS#1Circuit breaker + bulkhead + DLT
Handling ambiguity#4 (discovery)#6Undefined problem → structured approach
Simplifying / reducing complexity#5 Party Model#7Schema mirrors reality; strangle the monolith
Cross-team collaboration#4#5Multiple contexts, multiple teams
Learning something new fast#5 Temporal#9 dbtAdopted a new durable-workflow / data tool
Breadth / stepping outside your lane#9 dbt#3 SecurityAnalytics engineering, security config
Disagreement / conflict (technical)see belowA real disagreement resolved with reasoning
A failure / mistake / what you'd redosee belowShow the learning, not the wound

The two hard ones — prepare a real example for each. These trip people up because they need a specific true story, and you can't route around them.

  • Disagreement / conflict. Pick a real technical disagreement where you were right for a reason and won the argument with evidence, not volume — or, even better, one where you changed your mind when shown data (that reads as senior maturity). Good seeds from your own work, if they're true for you: pushing outbox over a dual-write someone wanted to keep; async-202 semantics over a fake-synchronous API partners asked for; Temporal over a hand-rolled saga (or the reverse). Structure it as STAR too: the disagreement is the Situation, your reasoning + how you brought the other person along is the Action, the aligned decision + shipped outcome is the Result. Name the other side's view fairly — that's the tell of someone who actually collaborates.
  • Failure / mistake. Interviewers want learning, not self-flagellation. Pick something real, take clean ownership ("I decided X, it caused Y"), and spend most of the answer on what you changed. Honest seeds from this domain, if they match your experience: an early design that assumed message ordering and broke when retries reordered it — fixed by making transitions idempotent + compare-and-swap so ordering stopped being load-bearing; or shipping an unlimited-retry error handler that let one poison message block a partition — fixed by moving to non-blocking retry topics + a dead-letter sink; or a semaphore bulkhead that couldn't bound a blocking hub call until you switched to a thread-pool bulkhead + time limiter + HTTP read-timeout. Any of these is a genuinely senior "here's a subtle thing I got wrong and what it taught me" — use whichever actually happened to you.

⚠️ Never use #4 (your best leadership story) as your "failure." And never answer "what's your weakness" with a humblebrag ("I care too much"). Pick a real, non-fatal-for-the-role weakness and show the system you built to manage it.


4. Tech follow-up drills#

They told you: "Be ready for questions on Concurrency, Thread Safety, DI, Architecture, and Testing," plus PostgreSQL / database architecture, system design, and DDD/EDD, and specifically "concurrency & multithreading on both database and application level." Below is the rapid-fire: the question, a senior answer, and where to anchor it. You have deep guides on every one of these in this folder — this is the interview-compression of them.

4.1 Concurrency & thread-safety — application level#

Mental model to state up front: "The enemy is shared mutable state. You beat it by removing sharing (confinement), removing mutability (immutability), or coordinating access (synchronization) — and there's no thread-safety without a happens-before edge." Saying that sentence signals you actually understand the JMM, not just synchronized.

  • "Are Spring singletons thread-safe?" → A stateless singleton is thread-safe because it's stateless — many threads execute its methods with no shared mutable state. The trap is adding a mutable field to a singleton (a counter, a non-thread-safe cache, a SimpleDateFormat): now you've created shared mutable state across every request thread. Keep singletons stateless; put per-request state on the stack (local variables) or in request-scoped beans.
  • "How did you handle concurrency in your consumers?" → Anchor to #1/#8. Stateless consumers, no shared mutable state, per-message transactions, and idempotency via the inbox so concurrent/retried deliveries can't double-apply. Idempotency is the concurrency tool that survives across process and machine boundaries, where a local lock is worthless.
  • "@Transactional and threads?"@Transactional is thread-bound: the transaction and its connection live in a ThreadLocal. So it does not propagate across an @Async call, a new thread, or a coroutine dispatcher hop — the async work runs in no transaction and silently. Same reason @Async self-invocation and @Transactional self-invocation don't work: they're proxy-based.
  • "Virtual threads?" → Loom (GA in Java 21) makes blocking cheap rather than making compute faster — one virtual thread per task, so blocking I/O like hub calls scales without a thread pool. spring.threads.virtual.enabled=true. And as of JDK 24 (JEP 491) synchronized no longer pins carrier threads, so the old "replace synchronized with ReentrantLock" advice is largely dead.
kotlin
// Kotlin — idempotent consumer: dedup mark inside the business transaction @KafkaListener(topics = ["sessions.events"], groupId = "session-view") @Transactional fun onEvent(record: ConsumerRecord<String, ByteArray>) { val messageId = record.headers().messageId() if (!inbox.markProcessed("session-view:$messageId")) return // already handled → skip projector.apply(deserialize(record.value())) // commits or rolls back WITH the mark }
java
// Java — same pattern; the inbox mark and the projection commit atomically @KafkaListener(topics = "sessions.events", groupId = "session-view") @Transactional public void onEvent(ConsumerRecord<String, byte[]> record) { String messageId = messageId(record.headers()); if (!inbox.markProcessed("session-view:" + messageId)) return; // dedup: first caller wins projector.apply(deserialize(record.value())); }

4.2 Concurrency — database level (PostgreSQL)#

This is the one they flagged twice ("on both database and application level" + "PostgreSQL and database architecture"), so have real depth here.

  • Optimistic vs pessimistic — when each?Optimistic (version column; write fails on stale version → retry) when conflicts are rare — the common case, e.g. concurrent edits to one account (#5) or appending to the event store (#1). It doesn't hold locks, so it scales. Pessimistic (SELECT … FOR UPDATE) when contention is high or you must serialize a critical section — you take the row lock and make others wait.
  • The queue/relay trick: SELECT … FOR UPDATE SKIP LOCKED — how the outbox relay (#1) lets multiple pollers each grab a different unpublished row without blocking each other. SKIP LOCKED gives you throughput/competing-consumers; it does not give ordering (call that out honestly).
  • Isolation levels & anomalies: Postgres default is READ COMMITTED. Know the anomalies and their fixes: lost update → optimistic version or SELECT FOR UPDATE; write skew (two txns each read a valid state, both write, jointly violating an invariant — e.g., two concurrent reservations) → SERIALIZABLE (SSI) or an explicit lock/constraint. This is the classic senior gotcha: "REPEATABLE READ in Postgres does not stop write skew; SERIALIZABLE does."
  • Enforce invariants in the schema, not just code: a UNIQUE constraint is your last line of defense under a race — e.g., UNIQUE (aggregate_id, version) is the optimistic-lock mechanism in the event store; a duplicate-key violation becomes your 409.
  • Advisory locks for coordinating app-level critical sections across instances (e.g. "only one node runs this sweep") when you don't want a row lock.
kotlin
// Kotlin — optimistic locking with JPA @Version: the DB rejects a stale write @Entity class Account( @Id val id: UUID, var availableBalance: BigDecimal, @Version var version: Long = 0 // UPDATE … WHERE id=? AND version=? → 0 rows → OptimisticLockException )
java
// Java — same; second concurrent writer gets a stale version and retries against fresh state @Entity public class Account { @Id private UUID id; private BigDecimal availableBalance; @Version private long version; // Hibernate bumps + checks it on every UPDATE }

4.3 Dependency Injection#

  • "Constructor vs field injection?"Constructor injection, always. It makes dependencies explicit and lets you mark fields final/val (immutable → thread-safe reference), makes the class trivially unit-testable with plain constructors (no Spring, no reflection), and fails fast on a missing bean. Field injection hides dependencies and needs reflection to test.
  • "Why DI at all — what does it buy you architecturally?" → It's inversion of control: your domain/application code depends on interfaces (ports), and Spring wires the adapters at the edge. That's what lets my domain layer stay framework-free (I enforce it with ArchUnit — ..domain.. can't import Spring), and it's what makes the hexagonal/ports-and-adapters structure real instead of aspirational. Swapping a real hub client for a mock in tests is just wiring a different adapter.

4.4 Architecture, DDD & EDD#

Most of this is shown in your stories; keep the rapid-fire crisp.

  • "When event-driven vs request/response?" → EDA when you want temporal decoupling, independent scaling, and multiple consumers of the same fact (#1, #8); sync request/response when you need an immediate answer and a simple consistency story. The cost of EDA is eventual consistency and operational complexity — you pay it deliberately.
  • "How do you keep consistency without distributed transactions?" → You don't do 2PC across services. Saga for the process (orchestrated Temporal in #5, or a persisted CAS state-machine saga), outbox for atomic state+publish, inbox for idempotent consume, compensation for rollback. Consistency is eventual and provable, not atomic.
  • "CQRS — when is it worth it?" → When reads and writes have genuinely different shapes or scale (#1: write firehose vs multi-version reads; #2: geo/real-time reads). Not by default — it costs you a second model and eventual consistency. Don't CQRS a CRUD app.
  • "Bounded contexts — how do you draw them?" → Around true invariants and language, not around data or teams. A boundary is where a consistency rule stops needing to be atomic. Communicate across them with events only.

4.5 Testing#

  • "How do you test event-driven / DB code?"Testcontainers — spin up real Postgres and Kafka per test class, because testcontainers-real beats mocks for messaging/DB semantics (transactions, SKIP LOCKED, offsets). Anchor: this is how I test the whole outbox→Kafka→inbox→projection flow end to end.
  • "How do you test idempotency / at-least-once?" → Deliver the same message twice in the test and assert the state changed once and the second delivery was a no-op. Assert the inbox mark exists.
  • "Contract tests?" → Per OCPI version (#1), so a read-model change can't silently break a partner's wire shape. Consumer-driven where it fits.
  • "Testing a saga / async flow?" → Drive it with a mock hub that injects failure by data (reject / no-callback / slow), then use Awaitility to await the terminal state — asserting the compensation path, not just the happy path. Determinism comes from controlling the failure injection, not from sleeps.
  • "Testing concurrency itself?" → Hard to prove by unit test; you assert the invariant under concurrent load (e.g., fire N concurrent reservations, assert available balance never went negative and the version conflicts were retried), plus architectural guarantees (ArchUnit) and the DB constraints that make the bad state impossible.

5. The "why" bank — product-ownership one-liners#

Their mindset ask is "explain the why behind your architectural choices." Keep these loaded; each is a decision + its reason + the alternative you rejected. Drop them verbatim when they push on "why."

  • Outbox over dual-write: "You can't atomically write to a DB and a broker; the outbox makes the state change and the publish one transaction, and a relay guarantees delivery. Dual-write loses or duplicates on any crash between the two."
  • Inbox / idempotent consumers: "Kafka is at-least-once, so every consumer must be idempotent — dedup inside the business transaction. At-least-once + idempotent = effectively-once; chasing true exactly-once is chasing a myth."
  • CQRS + multi-version reads: "Reads and writes had different shapes and scale — a write firehose vs version-specific partner contracts — so I split them; a new OCPI version becomes a new projection, never a change to the write path."
  • Temporal for sagas: "A long-running multi-step process needs durability, retries, timeouts and compensation. Temporal makes those a platform capability instead of code I hand-roll and operate for every process."
  • Party Model for master data: "The same real-world entity plays many roles, so I modeled parties, roles and relationships explicitly — the schema mirrors the domain instead of duplicating entities across one-table-per-thing."
  • Circuit breaker + bulkhead: "They solve different failures — the breaker stops calling a dead dependency, the bulkhead isolates pools so one slow client can't starve the rest. Outbound integrations get both by default."
  • Async 202 over fake-sync: "Hub semantics are irreducibly asynchronous; a fake-synchronous API over them manufactures phantom failures and duplicate sessions. I made the async honest — acknowledge with 202, advance state on confirmations."
  • Strangler fig over rewrite: "A big-bang rewrite of a critical system bets everything on one cutover. Strangling it a slice at a time ships value continuously and stays reversible."
  • Incremental migration + reconciliation: "For revenue-critical data, correctness has to be provable — parallel-run, reconcile counts and checksums, flip reads before writes, keep every step reversible."
  • Optimistic locking by default: "Conflicts on one aggregate are rare, so a version-check-and-retry beats holding row locks across a pipeline — and the unique constraint is the real enforcement."
  • Idempotency keys on every mutating command: "A client retry must never create a second session or a second charge; a deterministic id derived from the command makes retries free."

6. Delivery — red flags to avoid#

  • "We" creep. The #1 first-round killer. You'll drift into "we" under pressure; consciously pull back to "I" for every action.
  • Rat-holing into tech before landing the story. Give the STAR arc first; let them pull you into the deep tech with follow-ups. Don't open with the class diagram.
  • No number at the end. Every story lands on a Result. If you have no metric, land a hard qualitative outcome ("eliminated that incident class"), never nothing.
  • Trashing people or past employers. Even the legacy system and the dual-write advocate get described fairly. Seniority reads as generosity.
  • Fabricated metrics. If you're not sure, don't cite it. "I'd have to check the exact figure, but the order of magnitude was…" is completely fine and more credible than a suspiciously round number.
  • Rambling. Situation in two sentences. If you're 90 seconds in and still scene-setting, you've lost them. Practice the recall cues out loud with a timer.

7. Questions to ask them#

Always have 4–5 ready; asking good questions is itself a signal, and it's where you probe whether you want them. Pick to taste.

On the architecture & tech:

  • "You emphasized product ownership — can you give an example of an engineer changing the product direction, not just the implementation?"
  • "Where are you on the monolith↔microservices spectrum today, and is that deliberate or inherited?"
  • "How do you handle data consistency across services — outbox/sagas, 2PC, something else?"
  • "What does your testing culture look like — Testcontainers-style integration, contract tests, and what's CI runtime like?"

On the role & level:

  • "This is open at senior and lead — what would distinguish someone you'd bring in as a lead versus a senior?"
  • "What would the first 90 days look like, and what would 'this hire is going great' look like at six months?"
  • "What's the split between greenfield and evolving existing systems right now?"

On concurrency/scale (plays to your strengths):

  • "Where does concurrency actually bite you today — hot rows in Postgres, message ordering, something else?" (Then you can relate it to your own work.)
  • "What's the scale and shape of the data — read-heavy, write-heavy, and where's the current bottleneck?"

On ways of working:

  • "How do architectural decisions get made and recorded — do you use ADRs or design docs?"
  • "What does on-call and production ownership look like for the team?"

8. One asset worth mentioning#

You have a reference implementation of exactly this domain — the roaming-aggregator (Kotlin 2 / Spring Boot 4 / Postgres 17 / Kafka, with event sourcing, CQRS, transactional outbox+inbox, two orchestrated CAS-based sagas, Resilience4j thread-pool-bulkhead composition, OCPI 2.2.1, ArchUnit-enforced context isolation, Testcontainers, and 14 ADRs documenting the why of every decision). If they ask for a code sample, want to go deeper on any pattern above, or you want to prove the product-ownership mindset, offer to walk them through it or share the repo. A working artifact that embodies every buzzword on their JD is a rare, strong signal — use it.


Good luck. You know this material cold — the only job now is to say "I," lead with the "why," and land a number.