This is Why FastAPI is NOT(!) Production-Ready Yet

Itay
Python in Plain English
8 min readJun 19, 2024

Abstract

In modern web development, managing dependencies efficiently is crucial for creating scalable and maintainable applications. Dependency Injection (DI) and Inversion of Control (IoC) are two design principles that address this need. This article explores how DI and IoC are implemented in two popular Python frameworks: FastAPI and PyNest. We will introduce both frameworks, delve into their respective approaches to DI, and provide a comprehensive comparison to help you choose the best fit for your next project.

Dependency Injection is the Achilles’ heel of FastAPI

Introduction to FastAPI

FastAPI is a modern, fast (high-performance) web framework for building APIs with Python 3.7+ based on standard Python type hints. It offers automatic generation of OpenAPI and JSON Schema, making it a favorite among developers for creating APIs quickly and efficiently. FastAPI’s DI approach is built-in, leveraging Python’s type hints to inject dependencies seamlessly.

FastAPI’s DI Approach

In FastAPI, dependency injection is handled using the Depends keyword in the function signature of the path operation function (route handler). This tells FastAPI to call the dependency function and use the result as the argument value for that parameter.

Example: FastAPI Dependency Injection

from fastapi import Depends, FastAPI

app = FastAPI()

# Dependency function
async def common_parameters(q: str = None, skip: int = 0, limit: int = 100):
return {"q": q, "skip": skip, "limit": limit}

# Route that uses the dependency
@app.get("/items/")
async def read_items(commons: dict = Depends(common_parameters)):
return commons

In this example, common_parameters is a dependency function that is executed before read_items whenever the /items/ route is accessed.

Redundancy in Dependency Injection

FastAPI requires dependencies to be injected at the function level for each route. If multiple routes require the same dependency, it must be injected into each route handler function separately. This can lead to repetitive and verbose code, particularly in applications with a large number of routes and shared dependencies.

Example

from fastapi import Depends, FastAPI

app = FastAPI()

# Dependency
class Logger:
def __init__(self):
print("Logger Starting")
time.sleep(2)
print(f"Logger Started at - {self}")

def log(self, message):
print(f"Logging - {message}")


# Multiple routes injecting the same dependency
@app.get("/items/")
async def read_items(logger: Annotated[Logger, Depends(Logger)]):
logger.log("list of items")
return {"message": "Items"}

@app.post("/items/")
async def create_item(logger: Annotated[Logger, Depends(Logger)]):
logger.log("creating item")
return {"message": "Item created"}

We can see that with every new route that will require the DB, we will have to explicitly inject it into our route. Let’s scale it a little bit and imagine that all of our routes need to inject our shared logger, shared config, and db connection, so it will end up with a lot of code that we will have to write (more of the same).

Injecting Class Dependencies in FastAPI

Let’s explore a scenario where we inject single and multiple dependencies in FastAPI routes and the problem of reinitialization of objects with every call.

Injecting a single dependency — In this example we will create a Logger object that will be injected into our API route.

from fastapi import FastAPI, Depends

app = FastAPI()

class Logger:
def __init__(self):
print("Logger Starting")
time.sleep(2)
print(f"Logger Started at - {self}")
self.params = {}

def log(self, message):
print(f"Logging - {message}")

@app.get("/")
def get(logger: Logger = Depends(Logger)):
logger.log("Endpoint hit")
return "Logger works"

if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

Output:

Logger Starting
Logger Started at - <main.Logger object at 0x102f3ddf0>
Logging - Endpoint hit
INFO: 127.0.0.1:64670 - "GET / HTTP/1.1" 200 OK

Logger Starting
Logger Started at - <main.Logger object at 0x102f3dbe0>
Logging - Endpoint hit
INFO: 127.0.0.1:64670 - "GET / HTTP/1.1" 200 OK

Logger Starting
Logger Started at - <main.Logger object at 0x102f3dbb0>
Logging - Endpoint hit
INFO: 127.0.0.1:64670 - "GET / HTTP/1.1" 200 OK

Let’s examine the output. We can see that when we tried to access the root route of the application, the logger object was initialized and set to the location “0x102f3ddf0”. Then, we access the same route for the second time, and once again, the logger object has initialized, only this time it sets to another memory location. each time we call the root route, we pay 2 seconds in latency only for the logger initialization.

Now, what happens when we have some service, that we want to use, and it depends on the logger?

from fastapi import FastAPI, Depends
import time
import random

class Logger:
def __init__(self):
print("Logger Starting")
time.sleep(2)
print(f"Logger Started at - {self}")

def log(self, message):
print(f"Logging - {message}")

class Service:
def __init__(self, logger: Logger = Depends(Logger)):
self.logger = logger
print("Service Starting")
time.sleep(1)
print(f"Service Started at - {self}")

def do(self):
self.logger.log("Doing something")
return f"Do something, {random.random()}"

app = FastAPI()

@app.get("/")
def get(service: Service = Depends(Service)):
return f"{service.do()}"

if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

Of course, you got it right! FastAPI and its lack of support in IOC containers that will manage your dependencies are basically cause that for each call to this endpoint, we will pay 3 seconds of latency, only for initialize objects that are already been init. This pattern is a true Anti-Pattern and perhaps exposes the Achilles’ heel of fastapi.

After spending hours trying to solve those issues, I understand that Fastapi itself is not capable of that and there is a need for a new, more holistic approach. This is why I ended up creating PyNest, A python meta-framework that focused on dependency injection and modularity.

PyNest: A Modular DI Approach

PyNest’s DI system is designed to reduce repetitive code and streamline the development process, especially for larger applications. PyNest provides a structured DI system that enables dependencies to be injected once, typically at the controller class level, promoting code reusability and adherence to the DRY principle. This structure means that once a dependency is injected into a controller, it can be used across all the controller’s route methods without the need for further injection, thus simplifying the codebase.

PyNest — More Holistic Approach to Dependency Injection

Inject Once, Use many

With PyNest modular architecture, you can Inject your dependency into the constructor of a controller class, which will enable you to use this dependency across all routes without the need to inject it over and over again into every route.

Example —

from nest.core import Injectable, Controller, Get, Post

@Injectable
class Logger:
def __init__(self):
print("Logger Starting")
time.sleep(2)
print(f"Logger Started at - {self}")

def log(self, message):
print(f"Logging - {message}")


@Controller("items")
class ItemsController:

# Inject Once
def __init__(self, logger: Logger):
self.logger = logger


# Use Many
@Get("/")
async def read_items(self):
self.logger.log("list of items")
return {"message": "Items"}

@Post("/{item}")
async def create_item(self, item: str):
self.logger.log("creating item")
return {"message": f"Item created - {item}"}

This modular approach enables us to inject as many dependencies as we wish into the controller constructor and access these dependencies in our class methods. The result is much cleaner code, where you don’t have to rewrite code and make things more complex than they should be.

Embrace the Power of the Singleton Pattern

As we discussed earlier, the most significant drawback of FastAPI’s DI mechanism is its lack of using the singleton pattern for managing dependencies. We observed that dependencies must be initialized with every incoming request.

In PyNest, we leverage the “injector” library under the hood, which is a package for managing dependencies in modern Python applications. The injector supports the singleton pattern, as well as multi-binding. When a class is registered as a dependency by marking itself as Injectable, the injector creates an instance of the class and stores its reference. Every call to this injectable object goes through the injector, which returns the singleton instance of any injectable object

Let’s examine this with code

First, let’s arrange all the relevant imports from PyNest

import logging
import os

from nest.core import (
Controller,
Delete,
Get,
Injectable,
Module,
Post,
Put,
PyNestFactory,
)
import time

Then we will declare Two Providers, that we want to inject, and our main Service that contains the logic layer.

# Config Provider
@Injectable()
class ConfigService:
def __init__(self):
time.sleep(2)
print(f"ConfigService starting - {self}")
self.config = os.environ

def get(self, key: str):
return self.config.get(key)

# Logger Provider
@Injectable()
class Logger:
def __init__(self, config_service: ConfigService):
time.sleep(2)
print(f"Logger Starting - {self}")
self.config_service = config_service
self.log = logging.getLogger(__name__)


# Our Main Service
@Injectable()
class ItemService:
def __init__(self, logger: Logger):
time.sleep(2)
print(f"ItemService starting - {self}")
self.logger = logger
self.items = [{"item_name": "Foo"}, {"item_name": "Bar"}, {"item_name": "Baz"}]

def get(self):
self.logger.log.info("Endpoint hit")
return self.items

def post(self, item: dict):
self.items.append(item)
return self.items

def put(self, item: dict):
self.items.append(item)
return self.items

def delete(self, item: dict):
self.items.remove(item)
return self.items

Now, Let’s create a Controller and inject to our service —

@Controller("items")
class ItemController:
def __init__(self, item_service: ItemService):
print("ItemController starting - {self}")
self.item_service = item_service

@Get("/")
def get(self):
return self.item_service.get()

@Post("/")
def post(self, item: dict):
return self.item_service.post(item)

@Put("/")
def put(self, item: dict):
return self.item_service.put(item)

@Delete("/")
def delete(self, item: dict):
return self.item_service.delete(item)

Amazing, we are almost there. Now let’s define our App module and run the application -

@Module(
controllers=[ItemController],
providers=[Logger],
)
class AppModule:
pass


app = PyNestFactory.create(AppModule)

if __name__ == "__main__":
import uvicorn

uvicorn.run(app.http_server, host="0.0.0.0", port=8623)

Output —

ConfigService starting - <__main__.ConfigService object at 0x10444d580>
Logger Starting - <__main__.Logger object at 0x10444daf0>
ItemService starting - <__main__.ItemService object at 0x10444d190>

INFO: Started server process [64770]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8623 (Press CTRL+C to quit)


INFO: 127.0.0.1:63810 - "GET /items/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:63824 - "PUT /items/ HTTP/1.1" 200 OK
INFO: 127.0.0.1:63840 - "POST /items/ HTTP/1.1" 200 OK

Wow! This is really something. We can see that we only initialize once our Injectables object, and from that point on, our container is managing the instances for those objects.

PyNest Dependency Injection System
PyNest Dependency Injection System

PyNest DI Manifest

Injectable Objects

  • Injectable-to-Injectable Injection: Injectable objects can inject other injectable objects, creating a consistent and unified hierarchy of dependencies.

Controllers

  • Controller Injection: Controllers are capable of injecting injectable objects, allowing them to delegate responsibilities to services and repositories as needed.

Dependency Graph

  • Acyclic Dependency Graph: The dependencies must form a Directed Acyclic Graph (DAG). There should be no circular dependencies to maintain a tractable dependency resolution and prevent runtime errors or infinite loops.

Dependency Resolution and Management

  • During the initialization of the application, the IoC container resolves all dependencies, creates instances of objects that haven’t been registered yet, and manages those instances to ensure they are served wherever they are injected.

Exporting Providers

  • A module can export providers, which can then be used or injected by other modules within the application.

Inter-Module Provider Injection

  • For a module to inject a provider from another module, it must explicitly import the module containing the desired provider.

Instance Referencing and Reuse

  • When an injected provider is called by the application, it references the instance that has already been initialized and reuses it. This prevents the creation of unnecessary instances of a provider, adhering to patterns like Singleton when needed.

Conclusion: Why PyNest May Be Preferable for DI

PyNest’s approach to DI provides clear advantages in terms of code organization and maintainability, especially for larger projects where modularity and avoidance of repetition are critical. By allowing dependencies to be injected at higher levels within the application’s structure, PyNest facilitates a more DRY codebase, reduces the potential for errors, and streamlines the process of refactoring and testing.

In contrast, while FastAPI’s DI system has its own set of strengths, its function-level injection requirement can introduce verbosity and redundancy, which may detract from the maintainability and scalability of web applications as they grow in complexity.

Resources

In Plain English 🚀

Thank you for being a part of the In Plain English community! Before you go:

--

--

Building Data Infrastructure @Lemonade | Ex-Meta | Author of PyNest - Python framework built on top of FastAPI that follows the modular architecture of NestJS