Switching from Spring Boot to FastAPI - A 3-Day Crash Course for Spring Developers

A practical three-day learning path for Spring Boot developers who want to become productive with FastAPI quickly.

May 24, 202615 min read

If you come from a Spring Boot background, FastAPI will feel both familiar and refreshingly lightweight. This three-day guide turns your Spring knowledge into a practical FastAPI skillset fast. Each day has concrete goals, separate code snippets, and a short exercise so you leave with a working app.

Why this guide

  • Spring Boot already taught you how to think about APIs, validation, dependency injection, persistence, and testing.
  • FastAPI uses the same problem areas, but with fewer abstractions and more explicit code.
  • You are not starting over. You are translating familiar backend concepts into a different framework and language.

Prerequisites

  • Comfortable with Spring Boot basics: controllers, DI, JPA, and properties.
  • Basic Python: functions, classes, and reading code.
  • Python 3.10+ with <code>pip</code> and <code>virtualenv</code> or <code>pipx</code>/<code>poetry</code>.

Quick setup

bash
# macOS / Linux
python -m venv .venv
source .venv/bin/activate
pip install fastapi uvicorn[standard] pydantic sqlalchemy alembic

# run dev server
uvicorn app.main:app --reload

Day 1 - FastAPI fundamentals

Goal: create a small FastAPI app, understand routing, dependency injection, and Pydantic models.

Spring mapping

  • <code>@RestController</code> -&gt; <code>APIRouter</code> and function endpoints
  • <code>@Autowired</code> / DI -&gt; <code>Depends()</code> and callable dependencies
  • DTOs / request validation -&gt; Pydantic models

What you should finish

  • A running FastAPI app with one router and one POST endpoint.
  • A clear mapping from Spring controller methods to FastAPI route functions.
  • A working <code>/docs</code> page for interactive testing.

Suggested pace

  • Morning: set up the virtual environment and project structure.
  • Afternoon: add routes, request models, and response payloads.
  • Evening: verify everything in Swagger UI and note the Spring equivalent for each piece.

1. Project scaffold

Create <code>app/main.py</code>:

python
from fastapi import FastAPI

from app.api import router

app = FastAPI(title="Todo (FastAPI)")
app.include_router(router)

Create <code>app/api.py</code>:

python
from fastapi import APIRouter
from pydantic import BaseModel

router = APIRouter(prefix="/api")


class TodoIn(BaseModel):
    title: str
    done: bool = False


@router.get("/", tags=["root"])
def root():
    return {"status": "ok"}


@router.post("/todos")
def create_todo(payload: TodoIn):
    return {"id": 1, **payload.model_dump()}

2. Run and test

bash
uvicorn app.main:app --reload --port 8000
# open http://localhost:8000/docs for the interactive OpenAPI UI

3. Exercise

  • Add <code>GET /todos/{id}</code> and <code>GET /todos</code> endpoints.
  • Implement a simple in-memory store to persist todos during runtime.

Day 1 review

  • Which Spring annotation did each FastAPI feature replace?
  • Where did validation move in your new mental model?
  • What is simpler in FastAPI than in the Spring version you know?

Day 1 checklist

  • App boots with <code>uvicorn</code> and <code>/docs</code> works.
  • Pydantic models handle request validation.
  • One Spring controller maps to one <code>APIRouter</code>.

Day 2 - Data, persistence, and testing

Goal: replace in-memory storage with a database and write unit or integration tests.

Spring mapping

  • <code>@Entity</code> + <code>JpaRepository</code> -&gt; SQLAlchemy or SQLModel plus repository functions
  • <code>@SpringBootTest</code> / Testcontainers -&gt; <code>pytest</code>, <code>httpx</code>, and Testcontainers-Python

What you should finish

  • A persistence layer that survives beyond a single request.
  • At least one automated test that exercises the HTTP layer.
  • A mental model for how FastAPI testing differs from <code>@SpringBootTest</code>.

Suggested pace

  • Morning: choose the ORM and create the schema.
  • Afternoon: wire database startup and CRUD functions.
  • Evening: write tests for the happy path and one validation failure.

1. Pick an ORM approach

For Spring developers, SQLModel is a smooth, Pydantic-first ORM. Otherwise use SQLAlchemy 1.4+.

bash
pip install sqlmodel[asyncio] databases asyncpg pytest httpx

2. Minimal persistence with SQLModel and SQLite

Create <code>app/models.py</code>:

python
from sqlmodel import Field, SQLModel


class Todo(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    title: str
    done: bool = False

Create <code>app/db.py</code>:

python
from sqlmodel import SQLModel, create_engine

engine = create_engine("sqlite:///./test.db", echo=True)


def init_db():
    SQLModel.metadata.create_all(engine)

Wire it in your startup:

python
from fastapi import FastAPI

from app.db import init_db

app = FastAPI()


@app.on_event("startup")
def on_startup():
    init_db()

3. Testing

Use <code>pytest</code> with <code>httpx</code> or <code>TestClient</code> for sending requests to the test server.

python
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)


def test_root():
    response = client.get("/api/")
    assert response.status_code == 200

4. Exercise

  • Persist todos to the DB and implement <code>POST</code>, <code>GET</code>, <code>PUT</code>, and <code>DELETE</code> for full CRUD.
  • Add tests that run against in-memory SQLite, or Testcontainers if you prefer.

Day 2 review

  • What is the FastAPI equivalent of a JPA repository in your app?
  • Which tests feel lighter than the Spring version, and why?
  • What parts of the stack still need careful design even in a smaller framework?

Day 2 checklist

  • Replace the in-memory store with SQLModel or SQLAlchemy.
  • Add tests with <code>pytest</code> and <code>TestClient</code>.
  • Understand lifecycle hooks like <code>startup</code> and <code>shutdown</code>.

Day 3 - Security, async, deployment, and Spring analogies

Goal: add auth, understand async patterns, containerize, and deploy a small app.

1. Security basics

  • Spring: <code>spring-boot-starter-security</code> and modern security configuration.
  • FastAPI: OAuth2 + JWT or packages like <code>fastapi-users</code>.

What this teaches you:

  • Spring security is powerful but layered.
  • FastAPI security is usually smaller and more direct.
  • The important part is still the same: protect routes, validate identity, and keep auth logic out of business handlers.

Minimal token flow:

python
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")


def get_current_user(token: str = Depends(oauth2_scheme)):
    if token != "fake-token":
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    return {"username": "dev"}

2. Async concurrency

  • FastAPI supports <code>async def</code> endpoints.
  • Prefer async DB drivers like <code>asyncpg</code> when using async endpoints.
  • Spring is often thought of as thread per request; FastAPI pushes you toward the event loop and non-blocking work.

This matters because Spring developers often think in terms of request threads, interceptors, and servlet filters. FastAPI pushes you to think in terms of awaitable work, event loops, and blocking calls that should be isolated.

3. Containerize and deploy

Dockerfile example:

dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY pyproject.toml requirements.txt ./
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

Build and run:

bash
docker build -t todo-fastapi:latest .
docker run -p 8000:80 todo-fastapi:latest

4. Exercise

  • Add JWT auth to protect the <code>POST</code>, <code>PUT</code>, and <code>DELETE</code> endpoints.
  • Containerize the app and deploy it to your cloud of choice.

Day 3 review

  • Which parts of your Spring Boot experience transfer directly?
  • Which parts need a new mental model in FastAPI?
  • What would you keep the same if you were rebuilding this service in Python?

Day 3 checklist

  • Protect endpoints with OAuth2 or JWT.
  • Understand async endpoints and when to use them.
  • Build the Docker image and run it locally.

Common Spring to FastAPI mappings

  • Controllers: <code>@RestController</code> -&gt; <code>APIRouter</code> and function endpoints
  • DI: <code>@Autowired</code>, <code>@Service</code> -&gt; <code>Depends()</code> and explicit dependency callables
  • DTOs and validation: Javax or Bean Validation -&gt; Pydantic models and validators
  • Config: <code>application.properties</code> -&gt; Pydantic <code>BaseSettings</code> or environment variables
  • Lifecycle: <code>@PostConstruct</code> and <code>@PreDestroy</code> -&gt; <code>@app.on_event(&quot;startup&quot;)</code> and shutdown hooks
  • Testing: <code>@SpringBootTest</code> -&gt; <code>pytest</code> plus <code>TestClient</code> or Testcontainers

Further resources

  • Official FastAPI docs: <a href="https://fastapi.tiangolo.com" target="_blank" rel="noreferrer">fastapi.tiangolo.com</a>
  • SQLModel docs: <a href="https://sqlmodel.tiangolo.com" target="_blank" rel="noreferrer">sqlmodel.tiangolo.com</a>
  • FastAPI Tutorials and Examples: <a href="https://github.com/tiangolo/fastapi" target="_blank" rel="noreferrer">github.com/tiangolo/fastapi</a>
  • Async Python: <a href="https://docs.python.org/3/library/asyncio.html" target="_blank" rel="noreferrer">docs.python.org/3/library/asyncio.html</a>

Final tips

  • Start with synchronous code if you do not need concurrency; convert endpoints to <code>async def</code> once the DB and libraries support it.
  • Use Pydantic models aggressively; they replace a lot of Java boilerplate.
  • Embrace the OpenAPI docs at <code>/docs</code> because they are useful for iterating quickly.
  • If you rely on Spring ecosystem features such as security, data, or cloud, look for mature FastAPI libraries before re-implementing from scratch.
  • If you already know Spring Boot, treat FastAPI as a translation exercise: controllers become routes, DTOs become Pydantic models, services stay services, and tests become more direct.

Want a starter repo scaffold with FastAPI, SQLModel, tests, a Dockerfile, and JWT auth? I can generate it next.

Deep dive - recipes and examples

Below are ready-to-copy recipes to accelerate each day. They are intentionally small, focused, and annotated so you can paste them into your project and run them quickly.

In-memory store (complete example)

Create <code>app/store.py</code> for a tiny runtime-backed store used during Day 1 exercises:

python
# app/store.py
from typing import List, Optional
from threading import Lock

_lock = Lock()
_todos: List[dict] = []
_next_id = 1

def list_todos() -> List[dict]:
    return list(_todos)

def get_todo(todo_id: int) -> Optional[dict]:
    return next((t for t in _todos if t["id"] == todo_id), None)

def create_todo(data: dict) -> dict:
    global _next_id
    with _lock:
        item = {"id": _next_id, **data}
        _next_id += 1
        _todos.append(item)
    return item

def update_todo(todo_id: int, data: dict) -> Optional[dict]:
    with _lock:
        t = get_todo(todo_id)
        if not t:
            return None
        t.update(data)
        return t

def delete_todo(todo_id: int) -> bool:
    with _lock:
        global _todos
        new = [t for t in _todos if t["id"] != todo_id]
        if len(new) == len(_todos):
            return False
        _todos = new
        return True

Wire into <code>app/api.py</code> with <code>Depends</code> to show a simple DI pattern:

python
from fastapi import Depends

def get_store():
    # could swap for a DB-backed store later
    from app.store import list_todos, create_todo, get_todo
    return {"list": list_todos, "create": create_todo, "get": get_todo}

@router.get("/todos")
def list_all(store=Depends(get_store)):
    return store["list"]()

@router.get("/todos/{id}")
def read(id: int, store=Depends(get_store)):
    t = store["get"](id)
    if not t:
        raise HTTPException(status_code=404)
    return t

@router.post("/todos")
def create(payload: TodoIn, store=Depends(get_store)):
    return store["create"](payload.model_dump())

SQLModel CRUD pattern (sync)

Create <code>app/crud.py</code> to isolate DB access (keep your routers thin):

python
# app/crud.py
from sqlmodel import Session, select
from app.db import engine
from app.models import Todo

def create_todo(session: Session, title: str, done: bool = False) -> Todo:
    todo = Todo(title=title, done=done)
    session.add(todo)
    session.commit()
    session.refresh(todo)
    return todo

def get_todo(session: Session, todo_id: int) -> Todo | None:
    return session.get(Todo, todo_id)

def list_todos(session: Session):
    return session.exec(select(Todo)).all()

def update_todo(session: Session, todo_id: int, **fields):
    t = session.get(Todo, todo_id)
    if not t:
        return None
    for k, v in fields.items():
        setattr(t, k, v)
    session.add(t)
    session.commit()
    session.refresh(t)
    return t

def delete_todo(session: Session, todo_id: int) -> bool:
    t = session.get(Todo, todo_id)
    if not t:
        return False
    session.delete(t)
    session.commit()
    return True

In your route, open a session per-request:

python
from sqlmodel import Session
from app.db import engine
from app.crud import create_todo, list_todos

@router.post('/todos')
def create_api(payload: TodoIn):
    with Session(engine) as session:
        return create_todo(session, **payload.model_dump())

@router.get('/todos')
def list_api():
    with Session(engine) as session:
        return list_todos(session)

Alembic quick start

  1. <code>pip install alembic</code>
  2. <code>alembic init alembic</code>
  3. Edit <code>alembic/env.py</code> to import <code>SQLModel.metadata</code> and set the <code>target_metadata</code>.
  4. <code>alembic revision --autogenerate -m &quot;create todos&quot;</code>
  5. <code>alembic upgrade head</code>

Include a short note: prefer running migrations from your dev/startup script, and keep <code>alembic</code> out of normal request paths.

Tests - sync and async examples

Sync <code>pytest</code> using <code>TestClient</code>:

python
# tests/test_api_sync.py
import pytest
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_root():
    r = client.get('/api/')
    assert r.status_code == 200

Async <code>pytest</code> using <code>httpx.AsyncClient</code> and an <code>async</code> test server (useful for async DB drivers):

python
# tests/test_api_async.py
import pytest
from httpx import AsyncClient
from app.main import app

@pytest.mark.asyncio
async def test_root_async():
    async with AsyncClient(app=app, base_url='http://test') as ac:
        r = await ac.get('/api/')
        assert r.status_code == 200

Database fixtures (sqlite in-memory) — example:

python
# tests/conftest.py
import pytest
from sqlmodel import SQLModel, create_engine, Session
from app.main import app

@pytest.fixture(scope='function')
def db_engine():
    engine = create_engine('sqlite:///:memory:')
    SQLModel.metadata.create_all(engine)
    yield engine
    engine.dispose()

@pytest.fixture
def client(db_engine):
    # monkeypatch app.db.engine or inject session factory
    yield from []

Notes: adapt the fixtures to your chosen DB driver (sync vs async) and prefer <code>pytest-asyncio</code> for async tests.

JWT auth recipe (simple)

Dependencies: <code>pip install python-jose[cryptography] passlib[bcrypt]</code>

python
# app/auth.py
from datetime import datetime, timedelta
from jose import jwt
from passlib.context import CryptContext

SECRET = 'change-me-in-production'
ALGO = 'HS256'
pwd_ctx = CryptContext(schemes=['bcrypt'], deprecated='auto')

def hash_password(password: str) -> str:
    return pwd_ctx.hash(password)

def verify_password(plain: str, hashed: str) -> bool:
    return pwd_ctx.verify(plain, hashed)

def create_access_token(sub: str, minutes: int = 15) -> str:
    now = datetime.utcnow()
    payload = {'sub': sub, 'iat': now, 'exp': now + timedelta(minutes=minutes)}
    return jwt.encode(payload, SECRET, algorithm=ALGO)

Protect endpoints with <code>OAuth2PasswordBearer</code> and verify the token in a dependency (see Day 3 example above). For production, rotate secrets and use asymmetric keys if possible.

Docker - multi-stage build and runtime notes

Use a multi-stage Dockerfile to install build dependencies separately and keep final image small:

dockerfile
FROM python:3.11-slim as builder
WORKDIR /app
COPY pyproject.toml requirements.txt ./
RUN pip install --prefix=/install -r requirements.txt

FROM python:3.11-slim
COPY --from=builder /install /usr/local
WORKDIR /app
COPY . .
ENV PYTHONUNBUFFERED=1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]

Runtime tuning tips:

  • Use <code>--workers</code> via <code>gunicorn</code> + <code>uvicorn.workers.UvicornWorker</code> for multi-core.
  • Set timeout and keep-alive to match your cloud provider defaults.

CI example - GitHub Actions (minimal)

Create <code>.github/workflows/ci.yml</code>:

yaml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      - run: python -m pip install -r requirements.txt
      - run: pytest -q

--- If you&#39;d like, I can now generate a starter scaffold repo with these files (minimal FastAPI app, <code>app/store.py</code>, <code>app/models.py</code>, <code>app/db.py</code>, <code>app/crud.py</code>, tests, Dockerfile, and CI). Tell me which pieces you want generated and I&#39;ll create them next.