MVC Framework
A web framework from scratch. Front controller dispatches requests to route-matched handlers, middleware processes the pipeline, and adapters normalize different handler signatures. How Flask and Spring MVC work under the hood.
Key Abstractions
Single entry point for all incoming requests. Receives the raw request, runs middleware, delegates to the router, and returns the final response.
Maps URL patterns to handler functions. Supports path parameters like /users/:id by converting route patterns into regex and extracting named groups on match.
Normalizes different handler signatures to a common interface. A handler taking (request) and one taking (request, path_params) both work without the caller knowing the difference.
Chain of responsibility for cross-cutting concerns like auth, logging, and CORS. Each middleware runs before and after the handler, with the option to short-circuit.
Data classes carrying method, path, headers, query parameters, and body through the framework. Response holds status code, body, and headers.
Template Method for rendering output. Subclasses decide the format: JSON serialization, HTML templating, or plain text. The handler returns data, the resolver turns it into a response body.
Class Diagram
How It Works
Every HTTP request enters through one object: the front controller. Not a different handler per URL. Not a servlet per endpoint. One entry point that coordinates everything.
Here is the lifecycle. A request arrives at the front controller. It passes through the middleware chain first. Logging middleware records the start time. Auth middleware checks the token and either lets the request continue or short-circuits with a 401. If the chain completes, the front controller hands the request to the router.
The router holds a list of route patterns. Each pattern like /users/:id gets compiled into a regex on registration. When a request for /users/42 comes in, the router walks its routes, tries each regex, and pulls out id=42 as a path parameter. First match wins.
Once the router finds a match, the handler adapter figures out how to call the handler. Your health check function takes zero arguments. Your user listing takes a Request. Your user detail handler takes Request plus path_params. The adapter inspects the function signature and calls it with the right arguments. You never write boilerplate to conform every handler to one rigid interface.
The handler returns raw data, not a formatted string. A dict like {"users": ["alice", "bob"]}. The view resolver picks up from there. JsonViewResolver turns it into JSON. PlainTextViewResolver produces key-value lines. Swap the resolver and every handler's output format changes. No handler code touched.
Requirements
Functional
- Single entry point (front controller) that receives all requests and coordinates routing, middleware, and rendering
- Route registration with URL pattern matching and path parameter extraction (/users/:id)
- Handler adapter that normalizes different handler signatures (zero-arg, one-arg, two-arg)
- Middleware chain with short-circuit capability (auth rejects before reaching the handler)
- Pluggable view resolvers for JSON, plain text, or other formats
Non-Functional
- Routes compiled to regex once at registration, not on every request
- Middleware order is explicit and deterministic
- Adding a new route is a one-line decorator call, not a class hierarchy
- View resolver is swappable without changing any handler code
Design Decisions
Why a Front Controller instead of one handler per URL?
One handler per URL works when you have three endpoints. But cross-cutting concerns like auth, logging, and error handling get duplicated in every handler. A front controller centralizes all of that. Middleware runs once in one place. Routing lives in one table. You can see your entire URL space by looking at one object. Spring MVC's DispatcherServlet and Django's URL dispatcher both follow this pattern for exactly these reasons.
Why use an Adapter for handler normalization?
Without an adapter, every handler must match a single signature like handle(Request) -> Response. Your health check that returns {"status": "ok"} with no request data still needs to accept a Request parameter it ignores. The adapter inspects the handler's signature and calls it appropriately. Zero args? Fine. Two args with path params? Also fine. This is how Flask works: your route handler can take keyword arguments matching path parameters, or take nothing at all.
Why middleware as a chain instead of per-handler decorators?
Per-handler decorators (@auth @log def my_handler) mean you decorate every single handler. Miss one and you have an unprotected endpoint. A middleware chain applies to all requests by default. You opt out of specific middleware for specific routes, not opt in. The chain also gives you a clear before-and-after lifecycle. Logging middleware starts a timer before the handler and reads it after. A decorator can do this too, but the chain makes ordering between multiple concerns explicit.
Why separate the ViewResolver from the handler?
Handlers should return data, not formatted strings. If your handler builds a JSON string manually, what happens when a client wants XML? You rewrite the handler. With a view resolver, you swap JsonViewResolver for XmlViewResolver and every handler works with the new format. Content negotiation becomes a resolver selection problem, not a handler rewrite.
Interview Follow-ups
- "How would you add async request handling?" Replace the synchronous
handle(Request) -> Responsewithasync handle(Request) -> Responseor aCompletableFuture<Response>in Java. The front controller runs the middleware chain on an event loop. Each middleware awaits the next handler. The structure stays identical; the execution model shifts from blocking threads to non-blocking callbacks or coroutines. This is the jump from Flask to FastAPI or from Spring MVC to Spring WebFlux. - "How would you support route groups or namespaces?" Add a
group(prefix)method to the App that returns a sub-router. Routes registered on the sub-router get the prefix prepended.app.group("/api/v1")means.route("/users")becomes/api/v1/users. You can also attach middleware to a group so only those routes get auth, for example. Express.js Router and Django'sinclude()both work this way. - "How would you handle content negotiation?" Check the
Acceptheader on the incoming request. If the client sendsAccept: application/xml, the front controller picksXmlViewResolver. IfAccept: text/html, it picks the HTML resolver. The Strategy pattern: the same handler, different rendering strategy chosen at runtime based on the request. - "How would you add error-handling middleware?" Insert a middleware early in the chain that wraps the
next.handle()call in a try/catch. If any downstream middleware or handler throws, the error handler catches it, logs the stack trace, and returns a 500 response with a safe error message. You can also map specific exception types to specific status codes:NotFoundExceptionto 404,ValidationErrorto 400. Express.js does this with four-argument middleware(err, req, res, next).
Code Implementation
from __future__ import annotations
from dataclasses import dataclass, field
from abc import ABC, abstractmethod
from typing import Any, Callable, Optional
import re
import json
import time
# ─── Data Classes ───────────────────────────────────────────────
@dataclass
class Request:
method: str
path: str
headers: dict = field(default_factory=dict)
body: str = ""
path_params: dict = field(default_factory=dict)
@dataclass
class Response:
status_code: int
body: str
headers: dict = field(default_factory=dict)
# ─── Handler Type ──────────────────────────────────────────────
Handler = Callable[[Request], Response]
# ─── View Resolver (Template Method) ───────────────────────────
class ViewResolver(ABC):
"""Template Method: subclasses decide how to serialize data."""
def render(self, data: Any) -> Response:
body = self.serialize(data)
content_type = self.content_type()
return Response(200, body, {"Content-Type": content_type})
@abstractmethod
def serialize(self, data: Any) -> str:
...
@abstractmethod
def content_type(self) -> str:
...
class JsonViewResolver(ViewResolver):
def serialize(self, data: Any) -> str:
return json.dumps(data, indent=2)
def content_type(self) -> str:
return "application/json"
class PlainTextViewResolver(ViewResolver):
def serialize(self, data: Any) -> str:
if isinstance(data, dict):
return "\n".join(f"{k}: {v}" for k, v in data.items())
return str(data)
def content_type(self) -> str:
return "text/plain"
# ─── Route and Router ─────────────────────────────────────────
@dataclass
class Route:
method: str
pattern: str
handler: Any # raw handler, any signature
regex: re.Pattern = field(init=False)
param_names: list[str] = field(init=False)
def __post_init__(self):
# Convert /users/:id into a regex with named groups
self.param_names = re.findall(r":(\w+)", self.pattern)
regex_pattern = self.pattern
for name in self.param_names:
regex_pattern = regex_pattern.replace(f":{name}", f"(?P<{name}>[^/]+)")
self.regex = re.compile(f"^{regex_pattern}$")
@dataclass
class RouteMatch:
handler: Any
params: dict
class Router:
"""Maps URL patterns to handlers. Supports path parameters."""
def __init__(self):
self._routes: list[Route] = []
def add_route(self, method: str, pattern: str, handler: Any) -> None:
self._routes.append(Route(method.upper(), pattern, handler))
def match(self, method: str, path: str) -> Optional[RouteMatch]:
for route in self._routes:
if route.method != method.upper():
continue
m = route.regex.match(path)
if m:
return RouteMatch(handler=route.handler, params=m.groupdict())
return None
# ─── Handler Adapter (Adapter Pattern) ─────────────────────────
class HandlerAdapter:
"""
Normalizes different handler signatures into a unified call.
Some handlers take just (request). Others take (request, path_params).
A health check might take nothing at all. The adapter figures out
what the handler expects and calls it correctly.
"""
@staticmethod
def invoke(handler: Any, request: Request) -> Any:
import inspect
sig = inspect.signature(handler)
param_count = len(sig.parameters)
if param_count == 0:
return handler()
elif param_count == 1:
return handler(request)
else:
return handler(request, request.path_params)
# ─── Middleware (Chain of Responsibility) ───────────────────────
class Middleware(ABC):
"""Each middleware processes the request and decides whether to continue."""
@abstractmethod
def process(self, request: Request, next_handler: Handler) -> Response:
...
class AuthMiddleware(Middleware):
def __init__(self, valid_tokens: set[str]):
self.valid_tokens = valid_tokens
def process(self, request: Request, next_handler: Handler) -> Response:
auth = request.headers.get("Authorization", "")
token = auth.replace("Bearer ", "") if auth.startswith("Bearer ") else ""
if token not in self.valid_tokens:
print(f" [AUTH] REJECTED: invalid token '{token}'")
return Response(401, "Unauthorized")
print(f" [AUTH] OK: token '{token}'")
return next_handler(request)
class LoggingMiddleware(Middleware):
def process(self, request: Request, next_handler: Handler) -> Response:
print(f" [LOG] >>> {request.method} {request.path}")
start = time.time()
response = next_handler(request)
elapsed_ms = (time.time() - start) * 1000
print(f" [LOG] <<< {response.status_code} ({elapsed_ms:.1f}ms)")
return response
# ─── App: Front Controller + Route Decorator ───────────────────
class App:
"""
Mini Flask. The App is the front controller: single entry point
that coordinates routing, middleware, handler adaptation, and
view resolution.
"""
def __init__(self, view_resolver: ViewResolver | None = None):
self._router = Router()
self._middlewares: list[Middleware] = []
self._adapter = HandlerAdapter()
self._view_resolver = view_resolver or JsonViewResolver()
def route(self, pattern: str, method: str = "GET"):
"""Decorator to register a handler for a URL pattern."""
def decorator(fn):
self._router.add_route(method, pattern, fn)
return fn
return decorator
def use(self, middleware: Middleware) -> App:
self._middlewares.append(middleware)
return self
def handle_request(self, request: Request) -> Response:
"""Front Controller: the single entry point."""
# Build the innermost handler: routing + adaptation + view resolution
def core_handler(req: Request) -> Response:
match = self._router.match(req.method, req.path)
if match is None:
return Response(404, "Not Found")
req.path_params = match.params
raw_result = self._adapter.invoke(match.handler, req)
# If handler returned a Response directly, use it as-is
if isinstance(raw_result, Response):
return raw_result
# Otherwise, run through the view resolver
return self._view_resolver.render(raw_result)
# Wrap core_handler with middleware chain (innermost last)
handler = core_handler
for mw in reversed(self._middlewares):
prev = handler
handler = lambda req, _mw=mw, _prev=prev: _mw.process(req, _prev)
return handler(request)
# ─── Demo ──────────────────────────────────────────────────────
if __name__ == "__main__":
app = App(view_resolver=JsonViewResolver())
# Register middleware
app.use(LoggingMiddleware())
app.use(AuthMiddleware(valid_tokens={"secret-123", "admin-456"}))
# Register routes using the decorator
@app.route("/users", method="GET")
def list_users(request: Request):
return {"users": ["alice", "bob", "charlie"], "count": 3}
@app.route("/users/:id", method="GET")
def get_user(request: Request, path_params: dict):
user_id = path_params["id"]
return {"id": user_id, "name": f"User {user_id}", "active": True}
@app.route("/health", method="GET")
def health_check():
return {"status": "ok"}
# ── Test 1: List users (valid token) ────────────────────────
print("=" * 60)
print("TEST 1: GET /users with valid token")
print("=" * 60)
resp = app.handle_request(Request(
method="GET", path="/users",
headers={"Authorization": "Bearer secret-123"},
))
print(f" Status : {resp.status_code}")
print(f" Body : {resp.body}")
print(f" Headers: {resp.headers}\n")
# ── Test 2: Get user by ID (path parameter extraction) ─────
print("=" * 60)
print("TEST 2: GET /users/42 with path parameter")
print("=" * 60)
resp = app.handle_request(Request(
method="GET", path="/users/42",
headers={"Authorization": "Bearer admin-456"},
))
print(f" Status : {resp.status_code}")
print(f" Body : {resp.body}")
print(f" Headers: {resp.headers}\n")
# ── Test 3: Auth rejection (bad token) ─────────────────────
print("=" * 60)
print("TEST 3: GET /users with bad token (auth rejects)")
print("=" * 60)
resp = app.handle_request(Request(
method="GET", path="/users",
headers={"Authorization": "Bearer wrong"},
))
print(f" Status : {resp.status_code}")
print(f" Body : {resp.body}")
print(f" Headers: {resp.headers}\n")
# ── Test 4: Health check (zero-arg handler via adapter) ────
print("=" * 60)
print("TEST 4: GET /health (zero-arg handler, adapter figures it out)")
print("=" * 60)
resp = app.handle_request(Request(
method="GET", path="/health",
headers={"Authorization": "Bearer secret-123"},
))
print(f" Status : {resp.status_code}")
print(f" Body : {resp.body}")
print(f" Headers: {resp.headers}\n")
# ── Test 5: Unknown route (404) ────────────────────────────
print("=" * 60)
print("TEST 5: GET /unknown (no matching route)")
print("=" * 60)
resp = app.handle_request(Request(
method="GET", path="/unknown",
headers={"Authorization": "Bearer secret-123"},
))
print(f" Status : {resp.status_code}")
print(f" Body : {resp.body}")
print(f" Headers: {resp.headers}\n")
# ── Test 6: Switch view resolver to plain text ─────────────
print("=" * 60)
print("TEST 6: Same routes, but with PlainTextViewResolver")
print("=" * 60)
app._view_resolver = PlainTextViewResolver()
resp = app.handle_request(Request(
method="GET", path="/users/7",
headers={"Authorization": "Bearer admin-456"},
))
print(f" Status : {resp.status_code}")
print(f" Body : {resp.body}")
print(f" Headers: {resp.headers}\n")
print("Front controller dispatched all requests through one entry point.")
print("Router matched patterns, adapter normalized signatures, resolver formatted output.")Common Mistakes
- ✗One endpoint class per URL instead of a central router. This works for five routes. At fifty, you have fifty files with duplicated setup logic and no way to see the full URL space at a glance.
- ✗Hardcoding the response format so every handler builds its own JSON string. When you need XML or HTML, you rewrite every handler instead of swapping in a different ViewResolver.
- ✗Tangling routing logic with business logic. The function that matches /users/:id should not also query the database. Separation means you can test routing and business logic independently.
- ✗Applying middleware globally when it should be per-route. Auth on the health check endpoint means your load balancer needs credentials. Route-scoped middleware fixes this.
Key Points
- ✓Front Controller is the single entry point for every request. One object coordinates routing, middleware, and response rendering instead of scattering that logic across many servlets or handler files.
- ✓Router uses pattern matching with path parameter extraction. The pattern /users/:id becomes a regex that matches /users/42 and pulls out id=42 as a named parameter.
- ✓HandlerAdapter lets you register any function shape without forcing everything to implement one rigid interface. A simple health check taking zero args and a full handler taking request plus params both work through the same dispatch path.
- ✓Middleware runs before and after the handler in a chain. Auth middleware can reject a request before it ever reaches the router. Logging middleware wraps the entire lifecycle to capture timing.