Chargebee · Design Rounds
System Design Questions Asked at Chargebee
Chargebee is a subscription billing platform, so billing lifecycle, idempotency, and payment retry semantics show up repeatedly across rounds — even in questions that look generic (parking lot, ID generator) the interviewer is really probing concurrency-safety and clean OOP design.
// STAR-D Reminder
Use Scope → Traffic → API → Rough Architecture → Deep Dive for every question below. See the System Design overview for the full framework.
Billing Core · Very Likely Design a Subscription Management System
ASKED AS: Onsite technical deep dive — "handle adding a subscription, canceling a subscription, and amount calculation", judged on logic, clean code, OOP, and SOLID.
REQUIREMENTS
- ·Create, upgrade/downgrade, and cancel a subscription
- ·Prorated billing when a plan changes mid-cycle
- ·Recurring invoice generation must be idempotent (never double-charge)
- ·Cancellation can be immediate or 'at period end'
CORE ENTITIES
Customer — id, payment method, billing address
Plan — id, price, billing interval (monthly/yearly), features
Subscription — customer_id, plan_id, status, current_period_start/end
Invoice — subscription_id, amount, status, idempotency_key
STATE MACHINE
trialing → active → past_due → canceled
APPROACH
- 1. Model subscription lifecycle as an explicit state machine — trialing → active → past_due → canceled. Every transition is an event, not a free-form field update.
- 2. Proration formula: prorated_amount = (days_remaining_in_cycle / days_in_cycle) × (new_plan_price − old_plan_price), charged/credited immediately on plan change.
- 3. Billing runs as an idempotent scheduled job: generate one invoice per (subscription_id, period) — enforce a unique constraint on that pair so retries never double-bill.
- 4. Cancellation writes a cancel_at timestamp instead of deleting the row immediately if 'cancel at period end' is requested — a background job flips status to canceled at that time.
- 5. Emit webhooks (subscription.created, invoice.paid, subscription.canceled) so downstream services (email, analytics) stay decoupled from the billing core.
| Trade-off | Which way & why |
|---|---|
| Immediate vs period-end cancellation | Immediate loses remaining paid time (needs a refund/credit); period-end is simpler and is what most SaaS billing tools default to. |
| Prorated vs full-cycle billing on upgrade | Proration is fairer to the customer but adds complexity; full-cycle-on-next-invoice is simpler but delays revenue recognition. |
| SQL vs NoSQL for invoices | SQL — invoices need ACID guarantees and relational joins (customer → subscription → invoice → payment); this is not a place for eventual consistency. |
Asked in HR/VP round Design a Rate Limiter
ASKED AS: Hiring Manager round — asked to design a rate limiter at a high level (implementation not required due to time).
REQUIREMENTS
- ·Limit requests per user/API key over a time window
- ·Must work correctly across multiple app servers (not just in-process)
- ·Should allow short bursts without being overly strict
APPROACH
- 1. For the full breakdown of all 5 algorithms (token bucket, leaky bucket, fixed/sliding window) with Redis Lua implementation, see the dedicated deep-dive.
- 2. For a Chargebee-style answer: default to Token Bucket in Redis — atomic INCR/EXPIRE or a Lua script keyed by user_id, since it allows controlled bursts (e.g. webhook retries) while capping sustained rate.
OOD · Concurrency Focus Design a Parking Lot (Cars, Bikes, Trucks)
ASKED AS: Onsite round — lot starts with Cars and Bikes only; Trucks added later. 1 car slot can hold 2 bikes. Focus was on DB design and handling two users requesting a slot simultaneously.
REQUIREMENTS
- ·Support multiple vehicle types, and let new types (Truck) be added without rewriting core logic
- ·1 car-sized spot can be split to hold 2 bikes
- ·No two vehicles can ever be assigned the same physical spot — even under concurrent requests
CORE ENTITIES
Vehicle (abstract) → Car, Bike, Truck — each declares the spot size it needs
ParkingSpot — id, size (COMPACT/BIKE/LARGE), status (FREE/OCCUPIED)
ParkingLot — owns pools of spots by size, exposes allocate()/release()
Ticket — vehicle_id, spot_id, entry_time
APPROACH
- 1. Use polymorphism, not a switch statement: each Vehicle subclass reports the spot type(s) it can use. Adding Truck later means adding one class, not touching ParkingLot.
- 2. Model a car spot as splittable into 2 bike sub-spots via composition (a Spot can have child slots), rather than hardcoding a '2 bikes = 1 car' rule in the allocator.
- 3. Concurrency: wrap spot selection + status flip in a single DB transaction using SELECT ... FOR UPDATE (pessimistic row lock) on the candidate spot row, or an atomic conditional UPDATE (UPDATE spots SET status='OCCUPIED' WHERE id=? AND status='FREE') and check rows_affected == 1.
- 4. At higher scale, shard free-spot lookup through Redis (a set of free spot IDs per size) and use an atomic SPOP so two requests can never pop the same spot.
| Trade-off | Which way & why |
|---|---|
| Pessimistic row lock vs atomic conditional UPDATE | Conditional UPDATE (optimistic) scales better under contention — no lock held, just retry on 0 rows affected. Row locks are simpler to reason about but serialize contention on hot spots. |
| DB-only vs Redis for spot availability | Redis SPOP gives O(1) atomic allocation for a busy lot; DB alone is simpler to keep consistent but can bottleneck at high concurrent entry rates (e.g. stadium parking). |
Distributed Systems Design an Auto-Increment ID Generator (Multiple Sequences, With Reset)
ASKED AS: Onsite round (Chennai) — "design auto increment feature (multiple sequences at a time, with reset)", e.g. invoice numbers like INV-2026-000123 that reset every year, per tenant.
REQUIREMENTS
- ·Many independent counters (per tenant, per document type) generating IDs concurrently
- ·A counter can reset on a schedule (yearly/monthly) without ID collisions across periods
- ·No two concurrent requests may ever receive the same number for the same sequence
CORE ENTITIES
SequenceCounter — sequence_key (e.g. tenant123:invoice:2026), current_value, updated_at
APPROACH
- 1. Store one row per (sequence_name, period) in a counters table. Getting the next ID = UPDATE counters SET value = value + 1 WHERE key = ? RETURNING value — a single atomic statement, no read-then-write race.
- 2. Reset is 'free': the period is part of the row key, so Jan 2027 simply starts a brand-new row at 0 instead of needing a reset job to run at midnight on Dec 31.
- 3. For very high throughput, front the DB with a Redis INCR per key (sub-millisecond, atomic) and periodically checkpoint the value to the DB for durability/audit.
- 4. Format the human-facing ID lazily: INV-{year}-{counter:06d} — the counter itself stays a plain integer.
| Trade-off | Which way & why |
|---|---|
| DB atomic UPDATE...RETURNING vs Redis INCR | DB alone is durable and simple but each ID costs a round trip + row lock; Redis INCR is much faster under load but needs a reconciliation/backup strategy in case Redis loses data. |
| Global sequence vs per-tenant sequence rows | Per-tenant rows avoid a single hot row becoming a bottleneck across all tenants, at the cost of more rows to manage. |
SQL vs NoSQL Choose a Database & Design Schema for a Vaccination Tracking System
ASKED AS: Onsite round — pick an appropriate database, design the schema, and write a query to find everyone who has received exactly n doses (n ≥ 0).
REQUIREMENTS
- ·Track people, vaccine doses administered, and dates
- ·Answer aggregate/reporting queries (dose counts, coverage %) efficiently
- ·Data must be consistent — a person's dose count can't be ambiguous
CORE ENTITIES
person — id, name, dob, region_id
vaccination_record — id, person_id, dose_number, vaccine_type, administered_on
APPROACH
- 1. Pick MySQL/relational, not NoSQL — the workload is join-heavy reporting (per-region coverage, dose-count histograms) and strong consistency matters for a health record; a document store buys nothing here and loses easy aggregation.
- 2. Normalize: one row per dose in vaccination_record rather than 'dose1_date, dose2_date' columns on person — this scales to any number of doses (boosters) without a schema migration.
- 3. Index on (person_id) for the per-person lookup and (vaccine_type, administered_on) for reporting queries.
QUERY
SELECT person_id, COUNT(*) AS doses_taken FROM vaccination_record GROUP BY person_id HAVING COUNT(*) = :n; -- everyone with exactly n doses, n >= 0 handled via LEFT JOIN + COALESCE if n = 0
| Trade-off | Which way & why |
|---|---|
| Normalized dose table vs fixed dose columns | Normalized handles boosters/any dose count for free; fixed columns are simpler to query but break the moment a 3rd/4th dose is introduced. |
Scalability Design a Result-Publishing Website — Handle a Traffic Spike
ASKED AS: CTO round — "design a system for a [national exam] result publishing website; how do you handle the quick traffic spike?"
REQUIREMENTS
- ·Millions of users hit the same endpoint within minutes of results going live
- ·Read-heavy, near-zero writes at the moment of the spike
- ·Each user only cares about their own roll number's result
APPROACH
- 1. Pre-generate the result payload for every roll number ahead of the announcement and push it into a CDN/edge cache (or a static object store like S3 behind CloudFront) — turn a dynamic DB read into a static file fetch.
- 2. Cache-aside in Redis keyed by roll number as a fallback layer for anything not pre-warmed, with a long TTL since results don't change after publishing.
- 3. Rate-limit per IP/roll-number at the edge (WAF/API gateway) to blunt retry storms from impatient refreshing.
- 4. Queue-based load leveling for any write-side work (e.g. logging, analytics) so it never competes with the read path during the spike.
- 5. Horizontally scale stateless app servers behind a load balancer with autoscaling triggered pre-emptively at the announced release time, not reactively.
| Trade-off | Which way & why |
|---|---|
| Pre-generate + CDN vs on-demand DB read + cache | Pre-generation removes the DB from the hot path entirely — the only real defense against a synchronized spike at a known time; on-demand caching still risks a cold-cache stampede at t=0. |
| Redis cache-aside vs static CDN files | CDN is cheaper and scales near-infinitely for read-only, rarely-changing data; Redis is better when the result data can still change after publishing (corrections). |
Idempotency · Very Common Payment Gateway Integration — Retry Semantics
ASKED AS: CTO round — "in which scenario will you retry a payment?", "how do you ensure clients don't make unnecessary retries?", "how do you stop a client retrying a successful payment?"
REQUIREMENTS
- ·Never double-charge a customer due to a retried request
- ·Distinguish retryable failures (network/timeout) from non-retryable ones (card declined)
- ·Client and server must agree on the outcome of a request even after a dropped connection
APPROACH
- 1. Only retry on ambiguous/transient failures — network timeout, 5xx, connection reset. Never retry on a definitive business decline (insufficient funds, card expired) — retrying won't change the outcome.
- 2. Require every payment request to carry a client-generated idempotency key. The server persists (idempotency_key → result) and returns the cached result for any duplicate key instead of re-running the charge — this is what actually 'stops' a client retrying a successful payment from double-charging.
- 3. Use exponential backoff with jitter on the client for retryable errors, capped at a small number of attempts, so a flaky network doesn't turn into a retry storm on the payment provider.
- 4. Reconcile asynchronously: even with idempotency keys, run a periodic job that checks payment-provider webhooks/status against your local records to catch cases where the client gave up before ever seeing the (successful) response.
| Trade-off | Which way & why |
|---|---|
| Idempotency key vs relying only on client-side dedup | Server-side idempotency is authoritative — client-side logic alone (e.g. 'disable button after click') cannot protect against retries from a different client/session or an actual network-layer resend. |
| Synchronous status check vs async webhook reconciliation | Webhooks scale better (no polling) but require handling out-of-order/duplicate delivery; synchronous polling is simpler but adds latency and load on the provider. |