This is Why FastAPI is NOT(!) Production-Ready Yet
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.
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.
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 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
- Asynchronous Magic: PyNest and SQLAlchemy 2.0 Drive a 25% Improvement in Python Apps Performance
- Beyond FastAPI: The Evolution of Python Microservices in 2024 with PyNest
- Dependency Injection 101 — Simplifying Dependency Injection in Python Web Apps with PyNest
- PyNest on PyPI: https://pypi.org/project/pynest-api
- Official Documentation: https://pythonnest.github.io/PyNest/
- GitHub Repository: https://github.com/PythonNest/PyNest
In Plain English 🚀
Thank you for being a part of the In Plain English community! Before you go:
- Be sure to clap and follow the writer ️👏️️
- Follow us: X | LinkedIn | YouTube | Discord | Newsletter
- Visit our other platforms: CoFeed | Differ
- More content at PlainEnglish.io