HV
home / chargebee / system-design

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. 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. 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. 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. 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. 5. Emit webhooks (subscription.created, invoice.paid, subscription.canceled) so downstream services (email, analytics) stay decoupled from the billing core.
Trade-offWhich way & why
Immediate vs period-end cancellationImmediate 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 upgradeProration is fairer to the customer but adds complexity; full-cycle-on-next-invoice is simpler but delays revenue recognition.
SQL vs NoSQL for invoicesSQL — 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. 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. 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.
Full Rate Limiter Deep Dive →
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. 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. 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. 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. 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-offWhich way & why
Pessimistic row lock vs atomic conditional UPDATEConditional 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 availabilityRedis 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. 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. 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. 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. 4. Format the human-facing ID lazily: INV-{year}-{counter:06d} — the counter itself stays a plain integer.
Trade-offWhich way & why
DB atomic UPDATE...RETURNING vs Redis INCRDB 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 rowsPer-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. 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. 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. 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-offWhich way & why
Normalized dose table vs fixed dose columnsNormalized 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. 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. 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. 3. Rate-limit per IP/roll-number at the edge (WAF/API gateway) to blunt retry storms from impatient refreshing.
  4. 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. 5. Horizontally scale stateless app servers behind a load balancer with autoscaling triggered pre-emptively at the announced release time, not reactively.
Trade-offWhich way & why
Pre-generate + CDN vs on-demand DB read + cachePre-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 filesCDN 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. 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. 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. 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. 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-offWhich way & why
Idempotency key vs relying only on client-side dedupServer-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 reconciliationWebhooks scale better (no polling) but require handling out-of-order/duplicate delivery; synchronous polling is simpler but adds latency and load on the provider.