DEV Community

Dishon Oketch
Dishon Oketch

Posted on

Backend Frameworks Under the Hood: What They Actually Do For You

If you've built a backend in more than one language, you've probably noticed something: despite wildly different syntax, ecosystems, and philosophies, Express, Django, Rails, Laravel, Spring, Gin, and FastAPI all end up solving the same handful of problems. This article is about those problems — what a backend framework is actually doing underneath the routes and controllers you write every day, and why the tradeoffs between frameworks matter more than the tradeoffs between languages.

We'll go from "what happens when a request hits your server" all the way through routing, middleware, the request/response lifecycle, ORMs, and the architectural philosophies that separate frameworks like Rails from frameworks like Express. By the end, the goal is that picking a framework — or explaining why you picked one — becomes a much more informed decision.


1. What Problem Are Backend Frameworks Actually Solving?

At the most fundamental level, a backend framework exists to handle a repeating pattern:

  1. A client sends an HTTP request.
  2. The server needs to figure out what the client wants (routing).
  3. The server needs to do some preprocessing (auth, parsing, logging, rate limiting).
  4. The server needs to run business logic (your actual application code).
  5. The server needs to talk to a database or other services.
  6. The server needs to format and send a response.
  7. The server needs to handle errors gracefully at every step above.

You could write all of this from scratch using just your language's standard library — and many people do, especially in Go, where the standard net/http package is genuinely usable on its own. But every non-trivial application ends up re-implementing routing, middleware chains, request parsing, and error handling in some form. Frameworks exist to standardize these patterns so you're not reinventing them per-project, and so that other developers reading your code recognize the shape of it immediately.

The key insight is that a framework is really a set of opinions about how to structure the six steps above — and different frameworks make different opinionated choices, which is where their real differences (and their real value) lie.


2. The Request Lifecycle: What Actually Happens

Let's trace a single HTTP request through a typical framework, because almost everything else in this article builds on understanding this flow.

Step 1 — The Listener

Every framework, underneath its abstractions, is running some form of a TCP/HTTP server that listens on a socket. In Node.js frameworks like Express, this is literally Node's built-in http module, wrapped. In Go frameworks like Gin, it's net/http's server, wrapped. In Python, WSGI (Django, Flask) or ASGI (FastAPI, Django with async support) servers like Gunicorn or Uvicorn sit between the raw socket and your framework code.

This matters because it tells you where the framework's responsibility actually starts. Express doesn't manage TCP connections — Node does. Gin doesn't manage TCP connections — Go's standard library does. The framework's job begins the moment a raw HTTP request has been parsed into some kind of structured object.

Step 2 — Routing

Once a request comes in, the framework needs to match it to a handler. This is usually done via:

  • Exact string matching for static routes (/users)
  • Pattern matching for dynamic routes (/users/:id or /users/{id})
  • Trie-based or radix-tree matching in performance-focused frameworks (Gin, for example, uses a radix tree for its router, which is part of why it benchmarks so well)

The router's job is purely to answer: given this method and this path, which function should handle it? This is a genuinely interesting data structure problem at scale — imagine an API with 500 routes; you don't want route matching to be O(n) against every registered route on every request.

Step 3 — Middleware

This is where most of a framework's actual personality shows up. Middleware is a function that sits between the raw request and your final handler, and can:

  • Inspect or modify the request before it reaches your handler
  • Inspect or modify the response after your handler runs
  • Short-circuit the request entirely (e.g., reject unauthenticated requests before they reach your logic)

Middleware is almost universally implemented as a chain of responsibility pattern. Each middleware function receives the request, does its work, and then either calls the next function in the chain or terminates the chain early.

Here's roughly what that looks like conceptually, regardless of language:

request → [logging middleware] → [auth middleware] → [rate limit middleware] → [your handler] → response
Enter fullscreen mode Exit fullscreen mode

If auth middleware detects an invalid token, it can immediately return a 401 without ever calling the next function — your handler never runs, and none of the middleware "downstream" of auth ever executes either.

This pattern is powerful because it lets you compose cross-cutting concerns (things that apply to many routes, like logging or auth) without duplicating that logic inside every single handler.

Step 4 — The Handler

This is your code — the part you actually write for each specific route. In most frameworks this receives some representation of the request (parsed body, query params, headers, path params) and is expected to return some representation of the response.

Step 5 — Serialization and Response

The framework takes whatever your handler returned (an object, a dict, a struct) and serializes it — usually to JSON, but potentially to XML, HTML (server-rendered templates), or other formats — then writes the appropriate headers and status code back through the socket.

Step 6 — Error Handling

Almost every framework provides some mechanism for catching unhandled errors thrown anywhere in the chain and converting them into a sensible HTTP response instead of crashing the whole process. This is usually implemented as a special kind of middleware that sits at the very end of the chain and catches anything that bubbled up.


3. Middleware Architecture: A Closer Look

Since middleware is genuinely the architectural heart of most modern backend frameworks, it's worth digging deeper into how it's implemented across ecosystems, because the implementation details reveal real philosophical differences.

Express (Node.js) — Explicit next()

function loggingMiddleware(req, res, next) {
  console.log(`${req.method} ${req.path}`);
  next(); // explicitly hand off control
}

app.use(loggingMiddleware);
Enter fullscreen mode Exit fullscreen mode

Express's middleware model is unapologetically explicit — you must call next() yourself, or the chain simply stops. This is powerful (you have total control over when/whether to proceed) but also a common source of bugs for beginners: forget to call next(), and your request just hangs forever with no response.

Gin (Go) — Explicit c.Next(), but with deferred execution

func Logger() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next() // hand off control, code AFTER this runs on the way back
        log.Printf("%s took %v", c.Request.URL.Path, time.Since(start))
    }
}
Enter fullscreen mode Exit fullscreen mode

Gin's model is similar to Express's but leverages Go's call-stack semantics: code written after c.Next() executes on the way back out of the chain, after the handler and all downstream middleware have finished. This lets you do things like measure request duration cleanly, without needing separate "before" and "after" hooks.

Django — Middleware Classes with __call__

class SimpleMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # code here runs before the view
        response = self.get_response(request)
        # code here runs after the view
        return response
Enter fullscreen mode Exit fullscreen mode

Django's middleware is class-based and structured almost identically to Gin's "before and after" pattern, just expressed through Python's __call__ protocol instead of a deferred function call. This reflects Django's broader philosophy of being explicit and structured — everything has a defined class-based shape.

FastAPI — Dependency Injection Instead of (Just) Middleware

FastAPI is interesting because while it supports traditional middleware, its more idiomatic pattern for request preprocessing is dependency injection:

async def get_current_user(token: str = Depends(oauth2_scheme)):
    user = decode_token(token)
    if not user:
        raise HTTPException(status_code=401)
    return user

@app.get("/profile")
async def read_profile(user: User = Depends(get_current_user)):
    return user
Enter fullscreen mode Exit fullscreen mode

Instead of a global chain that every request passes through, individual routes declare exactly which dependencies (auth, database sessions, validation) they need, and FastAPI resolves and injects them per-route. This is a genuinely different architectural philosophy — middleware is "global by default, opt out per route," while dependency injection is "local by default, opt in per route." Neither is strictly better; they represent different defaults for different scales of application.


4. Routing Philosophies: Convention vs. Configuration

One of the deepest philosophical splits between frameworks is how much structure they impose on you by default.

Convention-Heavy: Ruby on Rails

Rails is famous for "convention over configuration." If you create a file at app/controllers/users_controller.rb with a method called index, Rails will automatically wire up GET /users to call that method — assuming you've followed RESTful resource naming conventions. You barely write routing code at all for standard CRUD operations:

resources :users
Enter fullscreen mode Exit fullscreen mode

That single line generates seven conventional routes (index, show, create, update, destroy, new, edit) automatically. This is extremely productive when your app fits the convention, and can feel like fighting the framework when it doesn't.

Configuration-Heavy: Express, Gin

app.get('/users', getUsersHandler);
app.get('/users/:id', getUserHandler);
app.post('/users', createUserHandler);
Enter fullscreen mode Exit fullscreen mode

Here, nothing is implicit. Every route is manually declared, every handler manually wired. This is more verbose but gives you total flexibility — nothing "magic" is happening, which makes the codebase easier to reason about for newcomers, at the cost of more boilerplate.

Somewhere in Between: Django, Laravel

Django's URL routing sits in the middle — you explicitly declare URL patterns in urls.py, but Django's class-based views (especially generic views like ListView, DetailView) give you Rails-like conventional behavior if you opt into it, while still letting you write fully custom views when you need to.

urlpatterns = [
    path('users/', UserListView.as_view()),
    path('users/<int:pk>/', UserDetailView.as_view()),
]
Enter fullscreen mode Exit fullscreen mode

5. The ORM Question: How Frameworks Talk to Databases

Object-Relational Mappers are one of the most consequential architectural decisions a framework makes, because they shape how you think about data for the entire lifetime of a project.

Active Record Pattern (Rails, Laravel)

In this pattern, a single class represents both the data and the behavior for interacting with a database table — the object itself knows how to save, update, and delete itself.

user = User.find(1)
user.name = "New Name"
user.save
Enter fullscreen mode Exit fullscreen mode

This is intuitive and fast to write, but it tightly couples your domain model to your persistence layer — the User class is simultaneously a business object and a database access object, which can become awkward in large codebases where you want those concerns separated.

Data Mapper Pattern (Django's ORM, SQLAlchemy, Entity Framework)

Here, the object representing your data is kept separate from the logic that persists it. A separate "session" or "manager" object handles the actual database interaction.

user = User.objects.get(id=1)
user.name = "New Name"
user.save()  # Django's ORM is actually a hybrid — Active-Record-flavored API, Data-Mapper internals
Enter fullscreen mode Exit fullscreen mode
# SQLAlchemy — a purer data mapper
user = session.query(User).filter_by(id=1).first()
user.name = "New Name"
session.commit()  # the session, not the object, owns persistence
Enter fullscreen mode Exit fullscreen mode

This separation is more flexible for complex domains but has a steeper learning curve.

Query Builders (Knex.js, Gin + sqlx, Laravel's Query Builder)

Some frameworks — especially in the Go ecosystem, where ORMs are somewhat culturally distrusted for performance and "magic" reasons — favor query builders that sit between raw SQL and a full ORM:

var users []User
db.Where("age > ?", 18).Find(&users) // GORM, a popular Go ORM
Enter fullscreen mode Exit fullscreen mode
rows, err := db.Query("SELECT id, name FROM users WHERE age > ?", 18) // sqlx / raw SQL
Enter fullscreen mode Exit fullscreen mode

Go's backend culture, in particular, leans toward "just write the SQL" more than other ecosystems — a reflection of the language's broader philosophy of explicitness over abstraction.


6. Synchronous vs. Asynchronous Request Handling

This is where the underlying language's concurrency model deeply shapes what the framework can offer.

Thread-Per-Request (Traditional Django, Rails, Spring MVC)

Each incoming request is handled by a dedicated OS thread (or a thread from a pool). While that thread is waiting on I/O — a database query, an external API call — it's blocked, doing nothing, but it's isolated: your code reads top-to-bottom like normal synchronous code, no callbacks or promises needed.

The tradeoff is scalability: threads are relatively expensive (memory, context-switching overhead), so this model caps out at a certain number of concurrent connections before you need to scale horizontally.

Event-Loop Based (Express/Node.js)

Node.js runs on a single-threaded event loop. When a request needs to do I/O, it registers a callback and the event loop moves on to handle other requests, coming back to your callback once the I/O completes. This lets a single Node process handle a very large number of concurrent connections cheaply — but it requires you to write in a callback/promise/async-await style, and a single long-running synchronous computation can block every concurrent request, since there's only one thread.

app.get('/data', async (req, res) => {
  const result = await db.query('SELECT * FROM data'); // non-blocking
  res.json(result);
});
Enter fullscreen mode Exit fullscreen mode

Async/Await with True Concurrency (FastAPI + ASGI, Go's Goroutines)

FastAPI, built on ASGI rather than WSGI, gives you Node-style async I/O within Python — but Python's GIL (Global Interpreter Lock) means you still don't get true CPU-level parallelism from async alone; you get concurrency for I/O-bound work, not CPU-bound work.

Go's goroutines are a different beast entirely — lightweight, runtime-managed coroutines that the Go scheduler multiplexes across OS threads automatically. This is arguably the most elegant solution to this whole problem: you write straightforward, blocking-looking code, and the runtime handles making it non-blocking under the hood.

func handler(c *gin.Context) {
    result := fetchFromDB() // looks blocking, but goroutine scheduling makes it cheap
    c.JSON(200, result)
}
Enter fullscreen mode Exit fullscreen mode

7. Framework Comparison Table

Framework Language Routing Style ORM Pattern Concurrency Model Philosophy
Rails Ruby Convention-heavy Active Record Thread-per-request "Optimize for developer happiness"
Django Python Configuration + optional convention Data Mapper (Active-Record-flavored API) WSGI (sync) or ASGI (async) "Batteries included"
Express JavaScript Fully explicit None built-in (use anything) Event loop "Unopinionated, minimal core"
Laravel PHP Convention-heavy Active Record (Eloquent) Thread-per-request (typically) "Elegant syntax, developer experience"
Spring (Boot) Java Annotation-based configuration Data Mapper (JPA/Hibernate) Thread-per-request (or reactive w/ WebFlux) "Enterprise-grade, dependency injection everywhere"
Gin Go Fully explicit, radix-tree routing None built-in (query builders/GORM common) Goroutines "Minimal, fast, explicit"
FastAPI Python Fully explicit + dependency injection None built-in (SQLAlchemy common) ASGI (async) "Modern, type-hint driven, auto-documented"

8. So Which Framework Should You Actually Choose?

Given everything above, here's how I'd actually think through it:

  • If your team values speed of initial development and your domain fits standard CRUD patterns — Rails or Laravel's convention-heavy approach will get you further, faster, with less boilerplate.
  • If you need maximum control, explicitness, and performance, and your team is comfortable writing more code per feature — Gin or raw Express.
  • If you're building something with heavy I/O concurrency needs (real-time features, high request volume with lots of waiting on external services) and want Python's ecosystem — FastAPI's async model is a genuine advantage over traditional Django.
  • If you're in a large enterprise context with complex dependency graphs and strict typing needs — Spring's dependency injection and structure, while verbose, scales organizational complexity well.
  • If you want a "just works," huge-ecosystem, batteries-included option in Python — Django remains extremely strong, especially for content-heavy or admin-heavy applications (its auto-generated admin panel alone saves enormous time).

The deeper point of this whole article, though, is that these frameworks aren't really competing on features — they're expressing different opinions about the same six-step request lifecycle we walked through at the start. Once you can see that lifecycle clearly, evaluating a new framework you've never used becomes much faster: you're not learning something entirely new, you're just learning this framework's opinion about routing, middleware, ORMs, and concurrency.


Further Reading

  • Express.js middleware documentation — for the canonical explicit middleware chain pattern
  • Django's request/response cycle documentation — one of the most thoroughly documented lifecycles of any framework
  • "Designing Data-Intensive Applications" by Martin Kleppmann — not framework-specific, but essential for understanding the database-interaction tradeoffs underlying ORM design
  • Go's net/http source code — genuinely readable, and a great way to see what a framework is built on top of

Top comments (0)