🎯 NexusAPI — Interview Preparation Guide
Part 1: Step-by-Step Presentation Flow
Opening (2 min)
"NexusAPI is a multi-tenant, credit-gated backend API built with FastAPI, PostgreSQL, Redis, and ARQ. It
serves as the backend infrastructure for an AI platform where multiple organisations share the same
system, but their data is strictly isolated. Every API call costs credits, and the system enforces this
atomically — even under concurrent load."
Flow: Problem → Architecture → Implementation → Results
Step 1: Problem Statement (1 min)
Businesses use an AI platform for search engine analysis
Need a multi-tenant system: multiple orgs, strict data isolation
Need a credit-gated system: API calls cost credits, must be enforced correctly
Must handle failure gracefully: partial deductions, crashed workers, race conditions
Step 2: High-Level Architecture (3 min)
Client → FastAPI App → PostgreSQL (data, ledger)
→ Redis (rate limiting, job queue)
→ ARQ Worker (background jobs)
Layer Technology Purpose
API Framework FastAPI (async) Request handling, validation, routing
Database PostgreSQL + async SQLAlchemy Multi-tenant data, credit ledger, jobs
Cache/Queue Redis Rate limiting (sorted sets), ARQ job
queue
Background ARQ Async job processing for /api/summarise
Worker
Auth Google OAuth + JWT (python- Authentication, role-based authorization
jose)
Migrations Alembic Schema versioning
Logging structlog (JSON) Structured request/error logging
Step 3: Database Schema (3 min)
erDiagram
organisations ||--o{ users : "has many"
organisations ||--o{ credit_transactions : "has many"
organisations ||--o{ jobs : "has many"
organisations ||--o{ idempotency_records : "has many"
users ||--o{ credit_transactions : "performed by"
users ||--o{ jobs : "created by"
organisations {
UUID id PK
string name
string slug UK
datetime created_at
}
users {
UUID id PK
string email UK
string name
string google_id UK
UUID organisation_id FK
enum role "admin | member"
datetime created_at
}
credit_transactions {
UUID id PK
UUID organisation_id FK
UUID user_id FK
int amount "pos or neg"
text reason
string idempotency_key UK
datetime created_at
}
jobs {
UUID id PK
UUID organisation_id FK
UUID user_id FK
string type
json input_data
enum status "pending|running|completed|failed"
json result
text error
int credits_deducted
datetime created_at
datetime completed_at
}
idempotency_records {
UUID id PK
string key
UUID organisation_id FK
string endpoint
int status_code
json response_body
datetime created_at
}
Step 4: Key Implementation Details (5 min)
Walk through each module logically (see Part 2 below).
Step 5: Results / Failure Handling (3 min)
Demonstrate how every failure mode returns correct HTTP codes, structured error JSON with
request_id , and no stack traces leak.
Part 2: Codebase Breakdown (Module by Module)
📁 Project Structure
backend/app/
├── [Link] # App factory, lifespan, global exception handlers
├── [Link] # pydantic-settings, env vars
├── [Link] # Async SQLAlchemy engine + session factory
├── [Link] # Custom exception hierarchy
├── [Link] # ARQ background job processor
├── models/ # SQLAlchemy ORM models (5 tables)
├── services/ # Business logic layer
├── routers/ # API endpoint definitions
├── middleware/ # Auth, rate limiting, logging, request ID
└── schemas/ # Pydantic request/response models
Layer-by-Layer Walkthrough
1. Configuration ([Link])
Uses pydantic-settings BaseSettings to load all config from .env
Auto-converts postgres:// → postgresql+asyncpg:// for compatibility
Strips incompatible query params ( sslmode , channel_binding ) from connection string
Cached via @lru_cache for singleton behaviour
2. Database ([Link])
create_async_engine with connection pooling (pool_size=20, max_overflow=10,
pool_pre_ping=True)
async_session_factory with expire_on_commit=False so objects remain usable after
commit
get_db() dependency: yields session, auto-commits on success, auto-rollbacks on exception
3. Models (models/)
Model Key Fields Design Notes
Organisation id (UUID), name, slug (unique) Top-level tenant, cascade delete
email (unique), google_id (unique), role FK to organisation, selectinload
User
(admin/member)
amount (pos/neg), idempotency_key Append-only ledger, two
CreditTransaction
(unique) composite indexes
status (enum), result (JSON), Tracks background job lifecycle
Job
credits_deducted
key + org + endpoint (composite unique Caches responses for 24hr
IdempotencyRecord
index)
4. Services (services/)
credit_service.py — The most critical service:
get_balance() : SELECT COALESCE(SUM(amount), 0) — derived, never stored
deduct_credits() : Acquires pg_advisory_xact_lock(hash(org_id)) → checks balance →
INSERTs negative row → returns remaining
grant_credits() : INSERTs positive row
refund_credits() : INSERTs positive row with refund reason, user_id=None (system-initiated)
auth_service.py :
Google OAuth via authlib
find_or_create_user() : looks up by google_id , creates org from email domain if first user
(becomes admin), otherwise adds as member
create_jwt_token() : Encodes user_id , org_id , role , exp , iat , jti with HS256
analysis_service.py : Word count + unique words + sentiment via TextBlob
job_service.py : CRUD for background jobs with cross-org access check (returns 404, not 403, to
prevent org_id enumeration)
5. Middleware (middleware/)
auth_dependency.py :
Decodes JWT, verifies signature + expiry
Fetches user from DB to confirm they still exist (handles deleted user edge case)
require_admin() dependency chains on get_current_user()
rate_limiter.py :
Redis sorted set sliding window: 60 req/min per org
Fail-open strategy: if Redis is down, requests are allowed through (availability > rate limiting)
Pipeline: ZREMRANGEBYSCORE → ZCARD → ZADD → EXPIRE
logging_middleware.py : Structured JSON via structlog , logs method, path, org_id, user_id,
status_code, duration_ms
request_id.py : UUID4 per request via ContextVar , added to response header X-Request-ID
6. Routers (routers/)
[Link] (Product endpoints):
/api/analyse (POST, 25 credits): Rate limit → idempotency check → deduct credits → process →
refund on failure → save idempotency record
/api/summarise (POST, 10 credits): Same flow but returns job_id immediately, enqueues to
ARQ
/api/jobs/{job_id} (GET): Fetches job with org-level access control
[Link] : /auth/google (redirect to Google) → /auth/callback (exchange code, issue JWT, redirect
to frontend)
[Link] : /credits/grant (admin-only POST) → /credits/balance (GET, last 10 transactions)
[Link] : SELECT 1 → 200 or 503
7. Background Worker ([Link])
process_summarise_job() : PENDING → RUNNING → COMPLETED/FAILED
On failure: marks FAILED + automatic credit refund
On refund failure: logs error, rolls back (safety net)
Config: max_jobs=10 , job_timeout=300s (5 min)
8. App Factory ([Link])
Lifespan: initialises Redis, ARQ pool, auto-creates SQLite tables in dev
Middleware order: Session → Logging → RequestID → CORS
Global handlers: NexusAPIError (custom) → RequestValidationError (422) → catch-all
Exception (500, no stack trace)
Part 3: Cross-Questions & Sample Answers
🔸 Architecture & Design Decisions
Q1: Why did you choose FastAPI over Flask or Django?
FastAPI is built on ASGI and natively supports async/await, which aligns with our use of async
SQLAlchemy and Redis. It provides automatic OpenAPI documentation, Pydantic validation, and
dependency injection out of the box. For an I/O-heavy backend with database calls, Redis, and
background jobs, async is critical for throughput — a single FastAPI worker can handle multiple
concurrent requests without blocking, unlike Flask's default synchronous model.
Q2: Why async SQLAlchemy instead of raw SQL or a sync ORM?
Async SQLAlchemy gives us the best of both worlds: we get the safety of parameterised queries
(preventing SQL injection), relationship management, and migration support via Alembic, while staying
fully async to avoid blocking the event loop. Raw SQL would sacrifice maintainability; a sync ORM would
block on every DB call, defeating FastAPI's async architecture.
Q3: Why use a transaction ledger instead of a balance column?
Three reasons — auditability (you can reconstruct the exact sequence of every credit change),
correctness under concurrency (INSERT is safer than UPDATE on a shared row), and reconciliation (if a
customer disputes a charge, the ledger is the evidence). The tradeoff is read performance: SUM across
rows is slower than reading one column. At scale, I'd add a materialised balance cache updated
transactionally alongside each INSERT.
Q4: Why advisory locks ( pg_advisory_xact_lock ) over SELECT FOR UPDATE ?
The credit balance is derived from SUM across multiple rows — there's no single row to lock. SELECT
FOR UPDATE would require locking every transaction row for the org, which is awkward and slow.
Advisory locks provide a clean, explicit serialisation point keyed to the org UUID, and they release
automatically at transaction commit — no deadlock risk from forgotten unlocks.
Q5: Why fail-open for rate limiting when Redis is down?
API availability is more valuable than rate limiting in a production system. If Redis goes down, it's
temporary — you don't want your entire API to return 503 because of a rate limiter dependency. I log the
warning so ops can investigate, but the API keeps serving. The alternative (fail-closed) would make
Redis a single point of failure for the entire system.
Q6: Why return 404 instead of 403 when a job belongs to another org?
Returning 403 would confirm that the job_id exists, just not for this org. That leaks information — an
attacker could enumerate valid job IDs. Returning 404 treats the job as "not found" from this org's
perspective, revealing nothing about other orgs' data.
Q7: Why automatic refund instead of retry for failed background jobs?
Retries introduce complexity: you need idempotency for the work itself, risk consuming resources on
permanently failing inputs, and delay feedback to the caller. A refund is simple, auditable, and explicit —
the user sees "job failed, credits returned" in their transaction history and can retry manually.
🔸 Concurrency & Edge Cases
Q8: Walk me through what happens when two requests arrive simultaneously with only enough
credits for one.
Both requests call deduct_credits() . The first acquires the pg_advisory_xact_lock on the org's
hash. The second blocks at the lock. Request A reads balance=25, confirms 25≥25, INSERTs -25,
commits. Lock releases. Request B acquires the lock, reads balance=0, sees 0<25, raises
InsufficientCreditsError → returns HTTP 402.
Q9: What if the deduction succeeds but the analysis processing fails?
In /api/analyse , if analyse_text() throws after deduction, the except block calls
refund_credits() — inserting a positive +25 transaction with reason "Refund: /api/analyse
processing failed" — then returns HTTP 500 with a message saying credits were refunded.
Q10: What if the ARQ worker process is killed by the OS mid-job?
The job stays in PENDING/RUNNING status indefinitely since the error handler never executes. This
requires a stale job detector — a periodic task that scans for jobs stuck in non-terminal states beyond
5 minutes (matching job_timeout=300s ) and auto-refunds using the credits_deducted field
stored on the job record. The data model already supports this; it would be a cron-like periodic ARQ
task.
Q11: What happens if two identical idempotency keys arrive simultaneously?
Both pass the application-level check (neither finds an existing record). Both proceed to
deduct_credits() . When both try to INSERT a credit transaction with the same idempotency_key ,
the UNIQUE constraint on credit_transactions.idempotency_key causes one to fail with
IntegrityError . The application catches this and returns an error. This is the dual-layer pattern:
app-level check for the fast path, DB constraint for the race condition.
Q12: Can a user from Organisation A access Organisation B's data?
No. The JWT contains organisation_id . Every data query filters by organisation_id from the
JWT. The get_job() service explicitly checks job.organisation_id != user_org_id and returns
404. The credit service only operates on the authenticated user's org. There is no endpoint that accepts
an org_id as a parameter — it always comes from the JWT.
🔸 Failure Modes & Error Handling
Q13: What does the /health endpoint return when the DB is unreachable?
HTTP 503 with {"status": "unhealthy", "database": "unreachable"} . It runs SELECT 1 and
catches exceptions. It never returns 200 with a false "healthy" status.
Q14: How do you prevent raw stack traces from leaking to the client?
Three-layer exception handling: (1) Custom NexusAPIError subclasses caught by a dedicated handler
that returns structured JSON. (2) RequestValidationError handler for Pydantic errors. (3) Catch-all
Exception handler that logs the full traceback internally but returns only {"error":
"internal_error", "message": "An unexpected error occurred"} .
Q15: What happens if someone calls /api/analyse with an empty text or text longer than 2000 chars?
The Pydantic schema validates text with min_length=10 and max_length=2000 . FastAPI raises
RequestValidationError before the endpoint code runs, which the global handler converts to HTTP
422 with a structured error.
Q16: How does the error response structure look?
Every error follows: {"error": "<code>", "message": "<human-readable>", "request_id": "
<uuid>"} . InsufficientCreditsError adds balance and required . RateLimitExceeded adds Retry-
After header.
🔸 Multi-Tenancy & Security
Q17: How is tenant isolation enforced?
At two levels: (1) Application level — every query filters by organisation_id from the JWT. (2) Data
model level — foreign keys on every table link to [Link] . There's no API parameter for
org_id; it's always derived from the authenticated user's token.
Q18: What's in the JWT payload?
user_id , organisation_id , role (admin/member), exp (24-hour expiry), iat (issued at),
jti (unique token ID for potential revocation). Signed with HS256 using JWT_SECRET_KEY from env
vars.
Q19: How does Google OAuth work in your system?
/auth/google → redirects to Google consent screen via authlib . Google redirects back to
/auth/callback with an authorization code. We exchange it for an access token, extract email ,
name , sub (Google ID). If no org exists for the email domain, we create one and the user becomes
admin. Otherwise, user joins as member. Then we issue a signed JWT.
Q20: What if a valid JWT user is deleted from the database?
The get_current_user() dependency always queries the DB to confirm the user exists. If deleted, it
returns HTTP 401: "User no longer exists. Token is no longer valid."
Part 4: Technical Stack Deep-Dive Questions
FastAPI / Python
Q21: What is ASGI and how does FastAPI use it?
ASGI (Asynchronous Server Gateway Interface) is the async successor to WSGI. FastAPI is ASGI-native
through Starlette. It allows handling multiple concurrent I/O operations (DB queries, Redis calls, HTTP
requests) without blocking threads. Uvicorn is the ASGI server that runs the app.
Q22: How does FastAPI's dependency injection work?
Depends() creates a dependency chain. When a route declares db: AsyncSession =
Depends(get_db) , FastAPI calls get_db() before the route, injects the session, and handles cleanup.
Dependencies can chain: require_admin() depends on get_current_user() , which depends on
both security (HTTP Bearer) and get_db() .
Q23: What is expire_on_commit=False and why use it?
By default, SQLAlchemy expires (invalidates) object attributes after commit() . With async code,
accessing attributes after commit would trigger a lazy load, which is not supported in async mode.
expire_on_commit=False keeps attributes accessible without re-querying.
PostgreSQL
Q24: What are advisory locks and how do they differ from row locks?
Advisory locks are application-defined locks in PostgreSQL. Unlike row locks ( SELECT FOR UPDATE ),
they don't lock any table row — they lock an arbitrary integer key. pg_advisory_xact_lock() is
transaction-scoped and auto-releases at commit/rollback. They're ideal when you need serialisation but
have no single row to lock (like our SUM-based balance).
Q25: What indexes does your schema have and why?
ix_credit_transactions_org_id : fast SUM(amount) per org for balance calculation
ix_credit_transactions_org_created : fast ORDER BY for recent transactions
ix_users_organisation_id : fast user lookup by org
ix_jobs_organisation_id + ix_jobs_status : fast job queries
ix_idempotency_records_key_org_endpoint : unique composite for idempotency lookup
Unique constraints on [Link] , users.google_id , [Link] ,
credit_transactions.idempotency_key
Q26: Why use UUIDs instead of auto-increment integers for primary keys?
UUIDs are globally unique, won't collide in distributed systems, don't leak information about record
count or creation order, and can be generated client-side. The tradeoff is slightly larger index size, but
it's negligible for our scale.
Redis
Q27: How does the sliding window rate limiter work?
Uses a Redis sorted set per org, scored by timestamp. Each request: (1) ZREMRANGEBYSCORE removes
entries older than 60s. (2) ZCARD counts remaining. (3) ZADD adds current timestamp. (4) EXPIRE
auto-cleans the key. All in a MULTI/EXEC pipeline for atomicity. If count ≥ 60, raise error with Retry-
After header.
Q28: Why a sorted set instead of a simple counter with TTL?
A simple counter with TTL creates a fixed window problem: if you send 60 requests in the last second of
window N, and 60 in the first second of window N+1, you've sent 120 in 2 seconds. A sorted set sliding
window accurately counts within any 60-second window, providing true rate limiting.
ARQ (Background Jobs)
Q29: Why ARQ over Celery?
ARQ is lightweight, async-native (built on asyncio), and uses Redis as its broker. Celery is a much
heavier framework with more features we don't need. Since our entire stack is async, ARQ integrates
naturally without introducing a sync/async boundary.
Q30: What is the job lifecycle?
PENDING (created, credits deducted, enqueued) → RUNNING (worker picks it up) → COMPLETED (result
stored) or FAILED (error stored, credits refunded). The job record stores credits_deducted to enable
refunds even if the worker context is lost.
Scalability
Q31: What breaks first at 10x load?
Credit balance computation. Every deduct_credits() runs SUM(amount) across all transactions for
the org. As the table grows, this degrades linearly. Solution: add a credit_balances table with a
cached balance per org, updated transactionally alongside each INSERT. This replaces the advisory lock
with a row lock on the balance record, simplifying concurrency too.
Q32: How would you scale the background workers?
Run multiple ARQ worker instances ( arq [Link] in N containers). ARQ's
Redis-based queue handles distribution automatically. Add Redis Sentinel or a Redis cluster for HA.
Scale workers independently of the API.
Part 5: Implementation-Level Questions
Q33: Walk me through a complete request to /api/analyse .
1. Request hits RequestIdMiddleware → generates UUID, stores in ContextVar
2. LoggingMiddleware starts timer
3. FastAPI resolves dependencies: get_current_user() validates JWT, fetches user from DB
4. RateLimiter.check_rate_limit() checks Redis sorted set
5. check_idempotency() queries idempotency_records table
6. deduct_credits() : advisory lock → SUM balance → INSERT -25 → return remaining
7. analyse_text() : word count + unique words + sentiment
8. If processing fails: refund_credits() inserts +25, returns 500
9. If success: saves idempotency record (if key provided), returns result with remaining credits
10. LoggingMiddleware logs the request with all metadata
Q34: How does the get_db() dependency handle transactions?
It uses async with async_session_factory() as a context manager. On success, it calls await
[Link]() . On any exception, it calls await [Link]() . In finally , it closes
the session. This means each request gets a full transaction boundary.
Q35: Why do you flush instead of commit in services?
flush() sends the SQL to the database and assigns IDs (e.g., UUID) but doesn't commit the
transaction. The get_db() dependency commits at the end. This keeps the entire request in one
transaction — if anything fails later, the whole thing rolls back. commit() mid-request would prevent
rollback of earlier operations.
Q36: How does the config handle Render/Railway database URLs?
The format_database_url field validator converts postgres:// to postgresql+asyncpg:// ,
strips sslmode (asyncpg uses ssl instead), and removes other incompatible params like
channel_binding and options . This makes deployment to any PaaS seamless.
Q37: Why is there a /dev/seed endpoint?
For development convenience — seeds test data (org, user, credits) without needing Google OAuth. It's
gated behind APP_ENV == "development" and should be disabled in production.
Q38: How do you prevent middleware ordering issues?
FastAPI/Starlette middleware is applied in reverse order. The outermost middleware
(SessionMiddleware) executes first, then LoggingMiddleware, then RequestIdMiddleware. This ensures
the request ID is available in logging middleware, and session state is available in auth callbacks.
Part 6: Structured Presentation Plan
Time Budget (15-20 min total)
Phase Duration Content
Opening 1 min What NexusAPI is, one-sentence summary
Problem / Context 2 min Multi-tenancy, credit gating, failure handling
Architecture 3 min Tech stack, component diagram, data flow
Database Design 3 min ER diagram, ledger pattern, indexes
Key Implementation 5 min Advisory locks, idempotency, ARQ worker, rate limiter
Failure Handling 3 min Auth failures, credit edge cases, infrastructure failures
Scalability 2 min 10x bottleneck, solutions
Q&A — Prepared from Part 3-5 above
Confidence Tips
1. Lead with "why" — don't just describe code, explain decisions
2. Use specific examples: "When two requests arrive at the same time with 25 credits..."
3. Be honest about tradeoffs: "The tradeoff of the ledger is read performance, but at this scale..."
4. Acknowledge what you didn't do: "In production, I'd add a stale job detector..."
5. Reference [Link] — it shows you thought deeply about the hard problems