Volume-2-Backend-Codebase
Volume-2-Backend-Codebase
Volume 2
Backend Codebase
Walkthrough
Distributed URL Shortener · FastAPI · PostgreSQL 16 · Redis 7 · SQLAlchemy 2.0 (async) · Alembic · Docker
Internal engineering onboarding & interview-preparation document
Table of Contents
1. How to Read This Volume 4
1.1 The complete file inventory 4
3. Core Infrastructure 7
3.1 [Link] — typed settings 7
3.2 [Link] — async engine, sessions, get_db 7
3.3 [Link] — pool, key helpers, cache abstraction 8
3.4 [Link] — hashing and JWTs 9
3.5 [Link] — structured logs 10
4. Models Layer 11
4.1 models/__init__.py — the model registry 11
4.2 [Link] 11
4.3 [Link] 11
4.4 [Link] 12
5. Schemas Layer 13
5.1 Validation lives in the schema 13
5.2 Response schemas and the short_url trick 13
6. Repositories Layer 14
6.1 URLRepository — representative methods 14
6.2 UserRepository and AnalyticsRepository 14
toc ii
8. API Endpoints & Dependencies 18
8.1 How a protected endpoint wires up 18
8.2 dependencies/[Link] — authentication as a dependency 19
8.3 The redirect endpoint 20
8.4 The analytics endpoints and an auth quirk 21
9. Cross-Cutting Concerns 22
9.1 middleware/request_id.py 22
9.2 middleware/rate_limit.py 22
9.3 exceptions/ — domain errors and global handlers 24
9.4 utils/request_parser.py 25
toc iii
1. How to Read This Volume
Volume 1 gave you the map. This volume is the guided tour of the territory: every folder, every file, and
the important lines inside each one. We go top-down — from the application entry point, through the
layers, to the tests — because that mirrors how a request actually flows.
For each significant file you get a file card (purpose, what it imports, who imports it, and what breaks if it
disappears), the code that matters, and a plain-English explanation. Bugs and rough edges are flagged, not
hidden.
Area Files
Entry app/[Link]
The repository contains app/models/__init__2.py (empty) and a literal directory named tests/
{unit,integration} — an accidental artefact of a shell mkdir that didn’t expand braces. Neither is imported or
used; both are safe to delete. Spotting dead files like these is good hygiene.
Purpose Builds and configures the FastAPI application (the ‘app factory’), registers middleware, exception
handlers, health routes, and all routers, and manages startup/shutdown.
Imports [Link], logging, redis pool helpers, exception handlers, both middlewares, the API router and
the redirect router.
app/[Link]
app = create_app()
The redirect route is GET /{slug} — a catch-all that matches any single path segment. If it were registered
before /health or /docs , it would swallow them (treating ‘health’ as a slug). FastAPI matches routes in
registration order, so health and /api/v1/* are registered first and the catch-all last. The redirect handler also
keeps an explicit exclusion set as a second line of defence.
app/[Link]
@asynccontextmanager
async def lifespan(app: FastAPI):
configure_logging([Link])
[Link]("Starting URL Shortener", environment=[Link], ...)
try:
redis = await get_redis_pool()
await [Link]()
[Link]("Redis connection established")
except Exception as e:
[Link]("Redis connection failed", error=str(e))
yield # <-- app serves requests here
[Link]("Shutting down URL Shortener")
await close_redis_pool()
If Redis is unreachable at boot, the app logs an error but still starts. That aligns with the fail-open philosophy: the
service can run degraded (no cache, no rate limiting) rather than refusing to boot.
Field Detail
Purpose Parses and validates all environment variables into a typed Settings object; derives the database
URLs and CORS list; exposes a cached singleton.
Imported by Almost everything — database, redis, security, main all read settings .
If deleted The app cannot read its configuration; nothing that needs a URL, key, or limit can initialise.
Using Pydantic Settings means a wrong type (say, a non-numeric port) fails loudly at startup rather than silently
later. The database URLs are computed properties, so credentials live in one place:
app/core/[Link]
@property
def DATABASE_URL(self) -> str: # async, used by FastAPI at runtime
return (f"postgresql+asyncpg://{self.DATABASE_USERNAME}:{self.DATABASE_PASSWORD}"
f"@{self.DATABASE_HOSTNAME}:{self.DATABASE_PORT}/{self.DATABASE_NAME}")
@property
def SYNC_DATABASE_URL(self) -> str: # sync, used only by Alembic
return (f"postgresql+psycopg2://{self.DATABASE_USERNAME}:{self.DATABASE_PASSWORD}"
f"@{self.DATABASE_HOSTNAME}:{self.DATABASE_PORT}/{self.DATABASE_NAME}")
The runtime app uses the async asyncpg driver; Alembic’s schema inspection needs a sync psycopg2
connection. Deriving both from the same credentials avoids drift.
The settings object is wrapped in @lru_cache so parsing happens once, and exposed as a module-level
settings alias for convenient imports.
Field Detail
Purpose Creates the async SQLAlchemy engine (the connection pool) and session factory; defines the
declarative Base ; provides the get_db per-request dependency.
Imported by Every repository, every model (via Base ), every route needing a session, Alembic.
If deleted No database access anywhere; models can’t be defined; the app is inert.
The engine is created once per process and configured with pool parameters straight from settings:
engine = create_async_engine(
settings.DATABASE_URL,
echo=not settings.is_production, # log SQL in dev only
pool_size=settings.DB_POOL_SIZE, # 10 warm connections
max_overflow=settings.DB_MAX_OVERFLOW, # +20 under burst
pool_timeout=settings.DB_POOL_TIMEOUT, # wait max 30s for a free conn
pool_recycle=settings.DB_POOL_RECYCLE, # recycle conns older than 30 min
pool_pre_ping=True, # verify a conn is alive before using it
)
The get_db dependency yields a session and guarantees cleanup. The try/except/finally rolls back on
any exception and always closes the session, so a failed request never leaks a connection back to the pool in
a dirty state:
app/core/[Link]
Q1 INTERMEDIATE What is the difference between the Engine and the Session?
The Engine is the process-level connection pool — expensive to create, created once. The Session is a per-
request unit of work — cheap, tracks ORM object state, and is not thread-safe, so each request gets its own.
Creating an Engine per request would exhaust the database’s connection limit almost immediately.
Field Detail
Purpose Manages the singleton Redis connection pool; defines TTL constants and namespaced key builders;
provides the RedisCache helper class with fail-open operations.
Imported by Services (caching, blocklist), middleware (rate limiting), main (startup ping), dependencies.
If deleted No caching, no rate limiting, no token revocation; redirects always hit the database.
Keys are built by small helper functions so the naming scheme is centralised and consistent (e.g.
url:slug:aB3cD4eF , auth:blocklist:<jti> , ratelimit:ip:[Link] ). The RedisCache class wraps raw
operations and — crucially — fails open:
class RedisCache:
async def get(self, key: str) -> str | None:
try:
return await self._r.get(key)
except Exception:
return None # cache error -> behave like a miss
async def set(self, key: str, value: str, ttl: int) -> None:
try:
await self._r.setex(key, ttl, value)
except Exception:
pass # cache is optional; never break the request
async def increment(self, key: str, ttl: int | None = None) -> int:
count = await self._r.incr(key) # atomic
if count == 1 and ttl:
await self._r.expire(key, ttl)
return count
Redis INCR is atomic and single-threaded, so even with ten app replicas hammering the same key there is no
read-modify-write race. That is the whole reason rate limiting and click counting can be safely centralised in Redis.
Field Detail
Purpose Password hashing/verification with bcrypt; creation, decoding, and JTI extraction of JWT access and
refresh tokens.
Imported by AuthService (login, register, refresh, revocation), the security unit tests.
If deleted No authentication is possible — passwords can’t be hashed or verified, tokens can’t be issued or
validated.
Passwords use bcrypt directly (not passlib) with a work factor of 12 (212 rounds). bcrypt is deliberately slow
and auto-salts each hash, so identical passwords produce different hashes and brute-forcing a stolen
database is expensive.
app/core/[Link]
_BCRYPT_ROUNDS = 12
app/core/[Link]
bcrypt silently truncates input beyond 72 bytes. Passwords are capped at 100 characters by the schema, so a >72-
byte password would have its tail ignored. It is a minor, well-known bcrypt caveat — worth mentioning if asked
about password handling.
Field Detail
Purpose Configures structlog for JSON output in production and colourised console output in
development; silences noisy third-party loggers.
If deleted Logs become unstructured/no correlation; observability degrades but the app still runs.
Structured logs are machine-parseable (searchable by request_id , user_id , slug ) which is essential once
you run many replicas. The request-ID binding that makes cross-instance tracing possible is set by
RequestIDMiddleware (Chapter 9).
app/models/__init__.py
4.2 [Link]
The User model uses a UUID primary key (unguessable, no enumeration, no sequential-write hotspot), a
unique+indexed email and username, a role string for RBAC, and soft-delete via is_active . It declares a
one-to-many relationship to URL .
app/core/../models/[Link]
class User(Base):
__tablename__ = "users"
id: Mapped[[Link]] = mapped_column(UUID(as_uuid=True),
primary_key=True, default=uuid.uuid4, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), default="user", nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
urls: Mapped[list["URL"]] = relationship("URL", back_populates="owner", lazy="select")
4.3 [Link]
The heart of the domain. Key columns: a unique+indexed slug (the redirect lookup key), the long_url
(Text), a url_hash (SHA-256, indexed) for duplicate detection, a denormalized click_count (BigInteger),
and an expires_at that is nullable (NULL = never expire). Composite indexes support the common queries.
class URL(Base):
__tablename__ = "urls"
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
long_url: Mapped[str] = mapped_column(Text, nullable=False)
url_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
click_count: Mapped[int] = mapped_column(BigInteger, default=0, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
owner_id: Mapped[[Link]] = mapped_column(
UUID(as_uuid=True), ForeignKey("[Link]", ondelete="CASCADE"), nullable=False,
index=True)
__table_args__ = (
Index("ix_urls_owner_active", "owner_id", "is_active"),
Index("ix_urls_owner_hash", "owner_id", "url_hash"), # duplicate detection
)
The unique B-tree index on slug is what makes a redirect lookup O(log n) even at a billion rows. On a cache miss
the query is SELECT ... WHERE slug = ? — a single index seek.
4.4 [Link]
Each row is one click event: a hashed visitor fingerprint (privacy-preserving), IP, country, browser/OS/device,
referrer, and a timestamp. Composite indexes support the aggregation queries (by date, by country, by visitor).
Volume 3 explains the denormalization strategy between this event log and the click_count column.
app/schemas/[Link]
class UserCreate(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9_-]+$")
password: str = Field(min_length=8, max_length=100)
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if not any([Link]() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any([Link]() for c in v):
raise ValueError("Password must contain at least one digit")
return v
Because FastAPI validates the body against the schema before the handler runs, a malformed email or weak
password is rejected with a 422 automatically. Your service code can assume its inputs are already well-formed.
app/schemas/[Link]
class URLResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: [Link]
slug: str
long_url: str
short_url: str = "" # populated by URLService._to_response()
click_count: int
expires_at: datetime | None
...
Other schema files: [Link] (login/refresh/logout requests, the token response, and an internal
TokenPayload ), [Link] (the breakdown and leaderboard shapes), and [Link] (a uniform
APIResponse envelope, an ErrorResponse , and pagination helpers).
app/repositories/url_repository.py
Second, paginated listing returns both the page of items and the total count in one method, so the service can
compute page counts:
app/repositories/url_repository.py
OFFSET pagination is simple but degrades on deep pages (the database still scans and discards the skipped
rows). For very large result sets, keyset (‘seek’) pagination is preferred. For a per-user URL list this is a non-issue,
but it’s a good thing to name in an interview.
7.1 URLService.create_short_url
The creation flow: hash the URL, short-circuit if the user already shortened it, validate or generate a slug,
persist, and prime the cache.
app/services/url_service.py
expires_at = data.expires_at
if expires_at is None:
expires_at = [Link](UTC) + timedelta(minutes=30) # <-- see BUG
url = await self._url_repo.create(
slug=slug, long_url=long_url, url_hash=url_hash, owner_id=owner_id,
title=[Link], is_custom_alias=is_custom,
expires_at=data.expires_at) # <-- passes original None
await self._cache.set(url_cache_key(slug), long_url, ttl=URL_CACHE_TTL)
return self._to_response(url)
Look at the last two lines carefully. The code computes a 30-minute default into the local variable expires_at ,
but then passes data.expires_at (the original None ) to create() . So the computed default is never used,
and new URLs are stored with a NULL expiry (never expire). The fix is to pass the local expires_at variable.
This is the root cause of the ‘expiry doesn’t work’ behaviour noted in Volume 1.
Because the duplicate check runs before the custom-alias branch, if you have already shortened a URL and then
submit the same URL again with a custom alias, the service returns the pre-existing short URL and ignores your
alias. Defensible (one URL per user), but surprising — a good design-discussion point.
On a cache hit the function returns immediately without checking expires_at . So even if the expiry bug in 7.1
were fixed, a link that expired while cached would keep redirecting until its 1-hour Redis TTL lapsed. A correct
implementation caches the expiry alongside the URL (or shortens the TTL to the remaining lifetime) and checks it
on hits too. Volume 6 shows the fix.
app/services/url_service.py
On the final fallback the longer slug is returned without re-checking uniqueness. With 6410 combinations the risk is
astronomically small, and the unique index would reject a genuine clash anyway, but strictly the loop could verify
the fallback too.
If the update payload doesn’t include expires_at , the code forces a new expiry 30 minutes out. So changing
only a link’s title makes the link expire in half an hour — almost certainly unintended. The correct behaviour is to
leave expires_at untouched when the client didn’t send it. Note the one thing this method gets right: it
invalidates the Redis cache after updating.
7.7 AuthService
Handles register, login, refresh, logout, and current-user resolution. Login verifies the bcrypt hash and issues
tokens; refresh validates the token type and checks the blocklist; logout writes both JTIs to the Redis blocklist
with a TTL equal to the refresh token’s lifetime so the blocklist self-cleans.
The login code comments claim it hashes a dummy password when the user doesn’t exist (to keep response time
constant), but the implementation actually returns early on an unknown email. That leaves a small user-
enumeration timing side-channel: ‘unknown email’ returns faster than ‘known email, wrong password’. The fix is
to always run a bcrypt verification against a dummy hash. Volume 4 revisits this.
7.8 AnalyticsService
Thin orchestration over AnalyticsRepository : it looks up the URL, then gathers total clicks, unique visitors,
and the five breakdowns into a single URLAnalyticsResponse . Ownership is enforced at the endpoint
(Chapter 8), not here.
app/api/v1/endpoints/[Link]
FastAPI resolves the whole dependency tree per request, in the right order, and cleans it up afterwards:
app/dependencies/[Link]
app/api/v1/endpoints/[Link]
The print(...) line is development scaffolding that shipped by accident. It writes to stdout on every redirect —
noise in production and a tiny performance cost. Delete it (and rely on structured logging if you need the value).
The leaderboard route declares current_user: CurrentUser = None intending ‘optional auth’, but
CurrentUser is a hard dependency that raises 401 when no token is present. The default value doesn’t make it
optional, so the endpoint actually requires authentication despite the README calling it public. To make it truly
optional you’d use a dependency that returns None when unauthenticated.
9.1 middleware/request_id.py
Attaches a UUID to every request, exposes it on [Link].request_id and the X-Request-ID
response header, and binds it into the structlog context so every log line during that request carries the same
ID — the foundation of distributed tracing.
9.2 middleware/rate_limit.py
Per-IP fixed-window limiting with an atomic Redis INCR ; sets the TTL on the first request of a window; returns
429 with Retry-After and X-RateLimit-* headers when the limit is exceeded; and fails open if Redis
errors.
The module docstring is candid: the implementation is really a fixed window with a sliding reset (one counter per IP
with a TTL), not a true sliding window. The docstring even sketches the sorted-set approach a true sliding window
would need. Fine for this scope; know the distinction for interviews.
Each domain exception maps to a specific HTTP status; everything unexpected becomes a logged 500.
app/exceptions/[Link]
@app.exception_handler(URLNotFoundException)
async def url_not_found_handler(request, exc):
return _error_response([Link], status.HTTP_404_NOT_FOUND)
@app.exception_handler(Exception) # catch-all
async def generic_error_handler(request, exc):
[Link]("Unexpected error", exc_info=exc)
return _error_response("An unexpected error occurred", 500,
request_id=getattr([Link], "request_id", None))
The catch-all logs the full exception server-side but returns a generic message to the client, with the request ID so
support can correlate. Never returning stack traces or SQL errors to clients is a basic but important security
property.
RateLimitExceededException exists and has a handler, but the rate-limit middleware returns a 429
JSONResponse directly instead of raising it, so the exception is effectively dead code.
9.4 utils/request_parser.py
Extracts analytics metadata from a request: the client IP (honouring X-Forwarded-For / X-Real-IP ), a salted
one-way hash of the IP for privacy-preserving unique-visitor counting, and browser/OS/device parsed from the
User-Agent. Country is read from a CF-IPCountry header if present (a real deployment would use a GeoIP
database or a CDN header).
Unique visitors are counted by a salted SHA-256 of the IP, not the raw IP, so you can measure reach without
persisting personal data. The salt should be rotated periodically — the code notes this.
• Mock Redis: a plain dict standing in for Redis, with async get / setex / incr / exists so caching, rate
limiting, and the blocklist behave in tests.
• Dependency overrides: the app’s get_db and get_redis are replaced with the test doubles — the exact
mechanism Volume 1 praised layering for.
• User/auth fixtures: registered_user and auth_headers give tests a logged-in user with a bearer token
in one line.
tests/[Link]
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_redis] = override_get_redis
async with AsyncClient(transport=ASGITransport(app=app), base_url="[Link] as ac:
yield ac
integration/test_urls.py Integration create (incl. custom alias, dedup, validation), list+pagination, get,
update, delete, and redirect
If you’re unsure how an endpoint behaves, read its integration test — it shows the exact request shape and the
expected status code. For instance, test_create_url_deduplication confirms that shortening the same URL
twice returns the same slug.
Volume 3 goes deep on the data and speed layers this volume introduced: the PostgreSQL schema and indexing,
SQLAlchemy async internals, Alembic migrations, the Redis caching strategy, and the background-task-vs-Celery
decision.