The Adaptable Backend: Python RestAPIs using FastAPI

By Hendrix Roa
December 5, 2025
8 min read
Posted in The Adaptable Backend: Python
The Adaptable Backend: Python RestAPIs using FastAPI

Restful APIs have become a standard today to centralize all data management and complicated business logic that MVC monolith applications are struggling to handle. The separation between frontend (client code) and backend (Database, hard logic) facilitates a lot to operate with different components at same time, one backend to different client apps like iOS, android, web and others.

Python has been one of the top languages for many years, leading the board of this year! StackOverflow survey 2025. It has been adopted for many companies as their primary language, especially with the rise of AI and Data Science.

But what make it special?

Python’s simplicity and vast ecosystem make it ideal for rapid development. With the introduction of modern tools and async capabilities, it’s now a powerhouse for high-performance backends. Here a few uses cases where Python works excellent:

  • AI/ML Integration
  • Data Processing
  • Microservices
  • Real-time applications (with ASGI)

In this series, we will use Python as a First-Class Citizen, treating it with the same architectural rigor often reserved for languages like Java or TypeScript.

Why not use Python’s http.server directly?

We definitely work with the http.server module directly to attend a request and send a response. For a few endpoints are ok, problems happens when the application becomes bigger. The criteria I recommend choosing a Framework can be divided in 2 categories:

  • High level details:
    • Active community
    • Addressing Security issues path
  • Tools that make you live easier:
    • Comprehensive documentation
    • Easy configuration
    • Good integration with OpenAPI spec (aka swagger): A way to expose interactive documentation of RestAPIs

I choose FastAPI Framework because it is widely used, high-performance, and matches the expectations of the series. In subsequent articles I demonstrate that changing between frameworks cannot be dramatic.

Installing necessary tools

The Python ecosystem has evolved. While pip is the standard, we will use uv, a modern, fast package manager written in Rust. It replaces pip, pip-tools, and virtualenv with a single tool that is 10-100x faster.

Installation page

Having uv installed, next thing is to open a terminal window.

All code will be uploaded in a Github repository.

Using Python as First Citizen

In this series we implement the First Citizen approach with Python to minimize the external frictions with external libraries and make Python the protagonist of the backend.

Our first command will be to create a new folder.

Create project directory
mkdir -p adaptable-backend-python

Go to the directory:

Navigate to project directory
cd adaptable-backend-python

Initialize the project with uv:

Initialize uv project
uv init

This will create a pyproject.toml file, which is the modern standard for Python project configuration (similar to package.json in Node.js).

Configure Python version

This step is crucial to maintain a consistent version. uv handles this automatically via .python-version or pyproject.toml.

pyproject.toml
[project]
name = "adaptable-backend-python"
version = "0.1.0"
description = "An adaptable backend"
requires-python = ">=3.12"
dependencies = []

Installing libraries

In the modern Python ecosystem, we use pyproject.toml to manage dependencies.

Installing development dependencies (using dependency-groups):

Install development dependencies
uv add --dev ruff mypy pytest pytest-asyncio httpx
PackageDescription
ruffAn extremely fast Python linter and formatter, written in Rust. Replaces Black, Isort, and Flake8.
mypyStatic type checker for Python.
pytestThe standard testing framework for Python.

Installing production dependencies:

Install production dependencies
uv add fastapi uvicorn pydantic
PackageDescription
fastapiThe modern, fast web framework for building APIs with Python.
uvicornA lightning-fast ASGI server implementation, needed to run FastAPI.
pydanticData validation and settings management using Python type hints.

Creating the folder structure

Let’s create a decoupled folder structure, similar to what we’d do in a scalable Node.js project:

Create folder structure
mkdir -p src/apps/rest_api/frameworks/fastapi/rest_controllers
mkdir -p src/core/configuration

We will separate our core logic from the framework implementation.

Let’s create our first DTO (note_dto.py) inside rest_controllers.

src/apps/rest_api/frameworks/fastapi/rest_controllers/note_dto.py
from pydantic import BaseModel, Field
class NoteDto(BaseModel):
id: int = Field(..., example=1)
content: str = Field(..., example="Hello World")

In the folder src/apps/rest_api/frameworks/fastapi/rest_controllers, create a new file note_rest_controller.py:

src/apps/rest_api/frameworks/fastapi/rest_controllers/note_rest_controller.py
from fastapi import APIRouter
from .note_dto import NoteDto
class NoteRestController:
def __init__(self) -> None:
self.router = APIRouter(prefix="/notes", tags=["notes"])
self._setup_routes()
def _setup_routes(self) -> None:
self.router.add_api_route(
"/",
self.get_notes,
methods=["GET"],
response_model=list[NoteDto],
summary="Get all notes",
)
async def get_notes(self) -> list[NoteDto]:
return [
NoteDto(id=1, content="Hello 1"),
NoteDto(id=2, content="Hello 2"),
]
note_controller = NoteRestController()
router = note_controller.router

We are using a Class-Based Controller approach here to mirror the MVC pattern, keeping our routes encapsulated.

Next, we’ll create the server factory in the FastAPI folder:

src/apps/rest_api/frameworks/fastapi/fastapi_server_factory.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .route import router as api_router
class FastAPIServerFactory:
@staticmethod
def create() -> "FastAPIServer":
app = FastAPI(
title="AdaptNotes API",
description="The AdaptNotes API documentation",
version="1.0.0",
docs_url="/docs",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
return FastAPIServer(app)
class FastAPIServer:
def __init__(self, app: FastAPI):
self.app = app
self.server = None
async def listen(self, port: int) -> None:
config = uvicorn.Config(self.app, host="0.0.0.0", port=port, log_level="info")
self.server = uvicorn.Server(config)
await self.server.serve()

And the generic Server class that abstracts the framework, the dictionary pattern is used to support multiple frameworks using the strategy pattern:

src/apps/rest_api/server.py
from .frameworks.fastapi.fastapi_server_factory import FastAPIServerFactory
class Server:
servers = {
"fastapi": FastAPIServerFactory,
}
def __init__(self) -> None:
self.server = None
async def run(self, port: int) -> None:
framework = "fastapi"
self.server = self.servers[framework].create()
await self.server.listen(port)

Finally, the entry point main.py:

src/apps/rest_api/main.py
import asyncio
from src.apps.rest_api.server import Server
async def main() -> None:
server = Server()
await server.run(port=3000)
if __name__ == "__main__":
asyncio.run(main())

Automating commands with Makefile

Running uv run python src/apps/rest_api/main.py each time is tedious. We’ll use a Makefile to create command aliases. This is the standard approach in Python projects:

Makefile
.PHONY: dev help
help:
@echo "Available commands:"
@echo " make dev - Run the REST API development server"
dev:
uv run python src/apps/rest_api/main.py

Now, run it with make:

Start development server
make dev

You should see:

INFO: Uvicorn running on http://0.0.0.0:3000

Go to http://localhost:3000/docs to see the Swagger UI!

Configuration: Environment variables

We are hard-coding values like port. Let’s fix that with a Pure Python configuration system, avoiding external libraries like python-dotenv for the core logic.

Creating the configuration class

We’ll create a Singleton configuration class that loads from .env using only the standard library.

src/core/configuration/configuration.py
import os
from pathlib import Path
from typing import Any, Literal, TypedDict
from dataclasses import dataclass
class EnvConfig(TypedDict, total=False):
NODE_ENV: Literal["development", "production", "test"]
PORT: int
@dataclass
class EnvVarSchema:
required: bool
type: type
default: Any = None
class Configuration:
_instance: "Configuration | None" = None
def __init__(self) -> None:
self._config: dict[str, Any] = {}
# Define schema with validation rules
self._env_schema: dict[str, EnvVarSchema] = {
"NODE_ENV": EnvVarSchema(required=True, type=str),
"PORT": EnvVarSchema(required=True, type=int),
}
self._load_env_file()
self._validate_config()
def __new__(cls) -> "Configuration":
if cls._instance is None:
cls._instance = super(Configuration, cls).__new__(cls)
return cls._instance
def get(self, key: str) -> Any:
return self._config.get(key)
def _validate_config(self) -> None:
# Validates all environment variables against schema
# Raises ValueError if required variables are missing
# ... (implementation details)
def _parse_value(self, value: str, value_type: type) -> Any:
# Converts string values to correct types (int, str, etc.)
# ... (implementation details)
def _load_env_file(self) -> None:
# Loads .env file and populates os.environ
# ... (implementation details)
config = Configuration()

Now use it in main.py:

src/apps/rest_api/main.py
import uvicorn
from src.core.configuration.configuration import config
from src.apps.rest_api.frameworks.fastapi.fastapi_server_factory import FastAPIServerFactory
factory = FastAPIServerFactory()
server = factory.create()
app = server.app
if __name__ == "__main__":
import signal
import sys
def handle_exit(sig, frame):
print("\nShutting down gracefully...")
sys.exit(0)
signal.signal(signal.SIGINT, handle_exit)
signal.signal(signal.SIGTERM, handle_exit)
port = config.get("PORT")
env = config.get("NODE_ENV")
reload = env == "development"
try:
uvicorn.run(
"src.apps.rest_api.main:app",
host="0.0.0.0",
port=port,
reload=reload,
log_level="info"
)
except KeyboardInterrupt:
pass

The configuration now validates all required environment variables on startup. If PORT is missing, it will raise a clear error message instead of failing silently.

The Adaptable Backend Series

This article is part of The Adaptable Backend series, where we explore professional backend architecture using practical examples. We focus on decoupling business logic from frameworks, making your application adaptable to change.

Check out other articles in the series:

Python Series

Conclusion

In this series we explore an alternative way to use backend frameworks in Python. We used uv for fast package management, FastAPI for the web layer, and a decoupled architecture that keeps our core logic independent.

In the next article we will start creating a separated way to use dependency injection that does not depend on frameworks, giving you all control.

  • Have you tried this approach before?
  • What do you think of the separation of concerns apply?

Let me know in the comments.

Comments

Loading comments...

You Might Also Like