Intro
There is a moment early in every software engineering journey where you must stop thinking in scripts and start thinking in systems. You may be currently working on a non-trivial CLI project or product and realizing that complexity increases disproportionately as you write more code. That your code is tightly coupled so making changes in one aspect affects others when it shouldn't or simply that you'd like to serve your application to more people across the internet so now you need to consider traffic, scaling and robustness.
I faced that situation myself 5 months ago, and that's why I'm here to share 3 lessons I learned transforming a local Python CLI app into a containerized, layered REST API using FastAPI and Docker.
For learning purposes, I decided keeping my domain models small, prioritizing depth over breadth. My guiding philosophy was Pain-Driven Development (PDD): I only added architectural complexity when the absence of it physically hurt my development process.
Note on CLI vs. APIs:
Backend APIs are not inherently superior to CLI apps; they just serve different purposes. CLI apps are fantastic for Proofs of Concept. But the moment a product requires scalability, concurrency, or cloud deployment, it must evolve into a headless Backend API.
Context: The Setup (Why WSL2 for Serious Python Projects)
More than a reactive pain, this is a proactive action that's good to embrace as it is a good practice.
This is because while basic Python snippets work the same on basically any OS, things take a turn once we start talking about production-level systems.
The main reasons to use WSL2 (Windows Subsystem for Linux) for production-level Python applications are:
- Parity with Production: AWS, GCP, and Azure run on Linux. Developing on a totally different OS introduces deployment uncertainty.
- Python Web Servers: ASGI servers like Uvicorn use Linux-kernel-specific features extensively for networking. Trying to run them on Windows adds unnecessary overhead.
- Containerization (Docker): Docker is Linux-native. It leverages Linux kernel features like namespaces and cgroups for process isolation. As I will explain later, modern cloud backends must be containerized.
Lesson 1: The Fat Routers (Layering and Decoupling)
Many beginners cram all their endpoints into the entrypoint of their application (main.py). Even worse, they write all the execution code directly inside those endpoints. That means cramming serialization, database connections, business logic, and HTTP semantics into a single function!
This approach is easier at the very beginning, but it creates massive technical debt. It promotes spaghetti code, hinders modularity, and, most importantly, makes testing your endpoints unfeasible.
[Code Block 1: The Fat Router (No SRP)]
@router.post("/api/v1/bank-account")
def create_account(payload: AccountCreate) -> AccountRead:
# 1. Deserialize (HTTP Concern)
dict_payload = payload.model_dump()
# 2. Business Logic (Domain Concern)
if payload.balance < 0:
raise HTTPException(status_code=400, detail="Invalid balance")
# 3. Database Connection & Persistence (Infrastructure Concern)
db_connection = sqlite3.connect("data.db")
db_connection.execute(...)
return AccountRead(**dict_payload)
The Solution: Layered Architecture
Think of your app like an onion. Each layer has a specific responsibility. Inner layers must be completely oblivious to outer layers, while outer layers depend on inner layers. Dependencies point inwards.
- Domain Layer: Contains your core classes and the invariants they enforce.
- Repository Layer: Contains all persistence logic (Database connections, SQL, or JSON file writing).
- Service Layer: Your business logic (Use Cases). It orchestrates the Domain and the Repository.
- Router Layer: Handles the HTTP lifecycle. It catches HTTP requests, calls the Service layer, and returns HTTP responses.
- Schemas Layer: Implements DTOs (Data Transfer Objects) so your endpoints know the exact JSON structure to accept and return.
Note for MVC developers: Do not write a "Controller" layer. In FastAPI, the Router endpoints are the controllers. Adding a separate controller layer is redundant. I made that mistake myself.
Here is how [Code Block 1] looks after applying Layered Architecture:
[Code Block 2: Layered Architecture (Hardcoded Dependencies)]
@router.post("/api/v1/bank-account")
def create_account(payload: AccountCreate) -> AccountRead:
# The Router only handles HTTP and pure Python translation!
account_domain = payload.to_domain()
# Instantiate the Service & Repo locally
repo = BankAccountRepository()
service = BankAccountService(repo=repo)
# Call the application logic
created_account = service.create_account(account_domain)
return AccountRead.from_domain(created_account)
Lesson 2: Testing & Dependency Injection (Hitting 80%+ Coverage)
[Code Block 2] is beautifully decoupled logically, but it has a fatal flaw. Look at the hardcoded lines: repo = BankAccountRepository().
If we try to test this endpoint using FastAPI's TestClient, look what happens:
[Code Block 3: The Leaky Test]
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
def test_create_account_success():
payload = {"name": "Test Account", "balance": 100}
response = client.post("/api/v1/bank-account", json=payload)
assert response.status_code == 200
Run this and go check your production database. Congratulations: your testing code just leaked into production infrastructure. Because the repository was hardcoded inside the endpoint, the test wrote fake data to your real database.
Enter Dependency Injection (DI)
(DI from now on) is a structural design pattern. Instead of the endpoint creating its own repository, we inject the required service into the endpoint from the outside. FastAPI natively supports this via the Depends() function.
You can intuitively think of the interplay between endpoints and DI as a contractor(endpoint) his materials supplier(repository). A contractor could manufacture his own lumber and steel, but then he's locked into whatever he built. By delegating that to a specialist supplier(Dependency provider), the contractor stays flexible β you can swap suppliers without touching the contractor at all.
Let's refactor the endpoint one last time to use DI:
[Code Block 4: The Injected Endpoint]
# Create the dependency provider
def get_bank_account_service() -> BankAccountService:
repo = BankAccountRepository()
return BankAccountService(repo=repo)
@router.post("/api/v1/bank-account")
def create_account(
payload: AccountCreate,
service: BankAccountService = Depends(get_bank_account_service) # Provide the dependency
) -> AccountRead:
account_domain = payload.to_domain()
created_account = service.create_account(account_domain)
return AccountRead.from_domain(created_account)
Testing via Dependency Overrides (DO)
Because our router now accepts the service from the outside, we can easily intercept it during testing. We use FastAPI's dependency_overrides to swap the real database for a fake, test-scoped temporary file (tmp_path).
[Code Block 5: Safe Integration Testing]
@pytest.fixture
def test_client(tmp_path):
test_file = tmp_path / "test_repo.json"
# Create a fake service pointing to the temporary file
def override_service():
repo = BankAccountRepository(storage_file=test_file)
return BankAccountService(repo=repo)
# Override the dependency globally for the test
app.dependency_overrides[get_bank_account_service] = override_service
client = TestClient(app)
yield client
# Clean up after the test
app.dependency_overrides.clear()
By isolating our infrastructure into a Repository layer, orchestrating it via a Service layer, and injecting it via DI, we can perform integration testing without ever touching our production data.
Lesson 3: The "It Works on My Machine" Pain (Containerization)
For early-stage projects, git and a virtualenv are usually enough for managing dependencies. However, the moment you want to prepare an app for the cloud, you must understand that modern cloud servers are stateless and rely entirely on containers.
When traffic spikes, AWS won't pull your Git repo and run pip installβthat is far too slow and error-prone. Instead, it asks for a Docker Image of your project so it can spin up identical containers in milliseconds.
I wrote a Dockerfile to package my OS, Python version, and dependencies. But when I ran the container, added a workout routine, and restarted the container, I realized something initially baffling: My data was gone.
Because I was using JSON-based persistence (local state), and because Docker containers are strictly ephemeral (destroyed on restart), all my data vanished.
That was the ultimate signal: to make an application truly stateless and scalable, local files must be migrated to a dedicated Database.
Conclusion & What's Next
By adopting a Layered Architecture, injecting dependencies, and containerizing the environment, your API can go from a fragile local script to a robust, nearly cloud-ready system with good test coverage. This is the way I've bridged the "CLI script" to a truly headless API.
However, as Lesson 4 proved, relying on JSON for storage means a system is not truly stateless.
Phase 2 of this project begins now: For my own project, I am ripping out the JSON repository and swapping it for a standalone PostgreSQL database, entirely without touching my HTTP routers or core domain logic.
Check out the source code for this architecture on my GitHub, or connect with me on LinkedIn to follow along as I build out Phase 2!

Top comments (0)