Skip to content

Separate concerns

  • Lesson 6
  • 40 min
  • Module 2: Build a small FastAPI service

What you will be able to do

Split the API into parts with one job each: the HTTP layer, the data models, and the classifier. Later courses replace the keyword classifier with a trained model. With this structure, that change touches one module, and the API around it stays the same.

Before you startWhat to know and to have readyAbout 40 minutes

What you need to know

What you need on your computer

Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS, and Pyodide 314.0 (browser).

A concern is one kind of job. main.py now mixes three:

Concern Question it answers Module
HTTP Which method and path? Which status code? routes.py
Data What does a valid ticket and a valid result look like? models.py
Classification Which category and priority for this text? classifier.py

The classifier must not know anything about HTTP. It takes text and returns a prediction, like the pure functions in Function contracts. Then you can check it without a server, and replace it without a change to the routes. main.py only builds the application from the parts.

The classifier gets better rules too. It counts the keywords for each category, and chooses the category with the most. It marks a ticket as urgent (priority 1) when it has an urgent word. The result is a small dataclass, Prediction, with no HTTP or Pydantic in it.

ticket_api/classifier.py
from dataclasses import dataclass
KEYWORDS = {
"billing": ("invoice", "charged", "refund", "payment"),
"login": ("password", "sign in", "log in", "two-factor", "locked out"),
"shipping": ("parcel", "delivery", "courier", "tracking"),
"account": ("account", "company name", "user"),
}
URGENT_WORDS = ("cannot", "locked out", "charged twice", "lost")
@dataclass(frozen=True)
class Prediction:
category: str
priority: int
confidence: float
class KeywordClassifier:
"""A deterministic stand-in for a trained model: the same text always
gives the same prediction."""
version = "keywords-1.0"
def predict(self, text: str) -> Prediction:
lowered = text.lower()
scores = {
category: sum(word in lowered for word in words)
for category, words in KEYWORDS.items()
}
best = max(scores, key=scores.get)
if scores[best] == 0:
return Prediction(category="other", priority=3, confidence=0.2)
urgent = any(word in lowered for word in URGENT_WORDS)
confidence = min(0.5 + 0.2 * scores[best], 0.95)
return Prediction(
category=best, priority=1 if urgent else 2, confidence=round(confidence, 2)
)
Part Meaning
sum(word in lowered for word in words) Counts the keywords of one category in the text. True counts as 1.
max(scores, key=scores.get) The category with the highest count. If two have the same count, the first one in KEYWORDS wins, so the result is always the same.
confidence 0.7 for one keyword, 0.9 for two, never more than 0.95. A trained model gives a real probability. This one only imitates the shape.
version Every response says which classifier made it. When the classifier changes, the clients can see it.

The classifier is plain Python, so you can try it right here:

Run Python in your browser

Predict, then run the classifier

Before you run it, predict the three results. The second text has one billing word and one shipping word. Which category wins, and why? Then add a text of your own.

Dependencies: give the route its classifier

Section titled “Dependencies: give the route its classifier”

The route needs a classifier object. It could make one itself, or import a global one. But then a test, or a later version with a real model, could not give it a different one. FastAPI has a better way: a dependency. You write a function that provides the object, and the route asks for it with Depends:

ticket_api/routes.py
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from ticket_api.classifier import KeywordClassifier
from ticket_api.models import Classification, TicketIn
router = APIRouter()
def get_classifier(request: Request) -> KeywordClassifier:
return request.app.state.classifier
@router.post("/classify")
def classify(
ticket: TicketIn,
classifier: Annotated[KeywordClassifier, Depends(get_classifier)],
) -> Classification:
prediction = classifier.predict(f"{ticket.subject}\n{ticket.body}")
return Classification(
category=prediction.category,
priority=prediction.priority,
confidence=prediction.confidence,
model_version=classifier.version,
)
Part Meaning
APIRouter() A group of routes in its own module. main.py adds it to the application.
get_classifier(request) The dependency. It returns the one classifier that the application keeps in app.state.
Annotated[KeywordClassifier, Depends(get_classifier)] “This parameter is a KeywordClassifier, and FastAPI gets it by calling get_classifier.”

For each request, FastAPI calls get_classifier, and passes the result to classify. The route does not know where the classifier comes from. This is dependency injection: the caller provides what a function needs, instead of the function making it. In Module 6, a test uses it to give the route a classifier that is down, on purpose.

The models move to models.py unchanged, and main.py becomes short:

ticket_api/main.py
from fastapi import FastAPI
from ticket_api.classifier import KeywordClassifier
from ticket_api.routes import router
app = FastAPI(title="Ticket Classifier API")
app.state.classifier = KeywordClassifier()
app.include_router(router)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}

app.state.classifier = KeywordClassifier() makes the classifier once, when the application starts. A trained model can take seconds to load, so it must not load again for each request.

A POST /classify request with a JSON body goes to FastAPI, which checks the body against TicketIn. If it is not valid, the response is 422 with every problem. If it is valid, FastAPI calls the route function classify in routes.py with the TicketIn and with the classifier object from the dependency get_classifier. The route sends the text to KeywordClassifier.predict in classifier.py, gets a Prediction back, and returns a Classification, which becomes the 200 JSON response.
FastAPI checks the input and provides the classifier. The route connects HTTP to the classifier. The classifier knows nothing about HTTP.
Text description of the diagram

A POST /classify request arrives with a JSON body. FastAPI checks the body against TicketIn. If it is not valid, FastAPI answers 422 with every problem, and the route never runs. If it is valid, FastAPI calls get_classifier to get the classifier object. Then it calls the route function classify in routes.py with the ticket and the classifier. The route sends the text to KeywordClassifier.predict in classifier.py, which returns a Prediction. The route turns it into a Classification, and FastAPI sends it as the 200 JSON response.

  1. Make ticket_api/classifier.py with the code from the worked example.

  2. Make ticket_api/models.py. Move Category, TicketIn and Classification from main.py into it, with the imports that they need (Literal, BaseModel, Field).

  3. Make ticket_api/routes.py with the code above.

  4. Replace ticket_api/main.py with the short version. Delete KEYWORDS from it: the classifier has its own copy now.

  5. Save, and check the server’s terminal. If it shows an ImportError, compare the import lines with the code above.

  6. Send requests/billing.json again:

    Output
    {"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}

    The result changed from Module 2’s first version: two billing words give 0.9, and “charged twice” makes it urgent.

  7. Send requests/empty-subject.json. It still gives 422. The models did not change, only their module.

Your turn: a classification endpoint with a documented schema

Section titled “Your turn: a classification endpoint with a documented schema”

The help desk wants a new category, refund, separate from billing, for the words refund and money back. Make the change in one module only, and decide which one. Add refund at the end of KEYWORDS. Then:

  1. Predict the result for {"subject": "Refund for the lost parcel"}, and check it with curl.
  2. Predict the result for {"subject": "I want my money back"}, and check it.
  3. Open http://127.0.0.1:8000/docs in your browser, and find Classification under Schemas at the bottom. Is refund in its list of categories?
Show the answer

The keywords belong to the classifier, so classifier.py changes: add "refund": ("refund", "money back"), and remove "refund" from billing.

  1. shipping, priority 1, status 200. The text has one shipping word (parcel) and one refund word. It is a tie, and shipping comes first in KEYWORDS, so it wins. lost makes it urgent.
  2. 500 Internal Server Error. refund wins, but Classification only allows the five values of Category, so Pydantic refuses the result, as in the common mistake of the last lesson.
  3. No. The schema still lists the five old categories.

So a second module must change: add "refund" to Category in models.py. A new output value changes the contract with the clients. So it is right that it needs a change in models.py, not only in the classifier. Undo both changes before the next module. The rest of the course uses the five categories.

Common mistake

ImportError after moving code

Symptom: after you move code into a new module, the server’s terminal shows an error, and every request fails with Couldn't connect to server:

ImportError: cannot import name 'TicketIn' from 'ticket_api.routes'

or NameError: name 'Field' is not defined.

Cause: an import line still points to the old module, or the new module uses a name without importing it. The development server cannot load the application, so nothing listens on the port.

Fix:

  1. Read the last line of the error: which name, and which module.
  2. Check that the name is defined in the module that the import line names. TicketIn is in ticket_api.models now.
  3. Check that each module imports every name that it uses, for example Field in models.py.
  4. Save. The development server tries again by itself.

Check your understanding

Why does the classifier return a Prediction dataclass instead of the Classification model from models.py?

Show the answer

The classifier must not depend on the API. Classification is part of the HTTP contract: it has the model_version field and the limits that clients rely on. Prediction is only the classifier’s answer. A new classifier can be, for example, a trained model in a later course. Keeping them apart means that it only has to return a Prediction, and the contract with the clients does not change.

Check your understanding

Module check: send a valid ticket, a ticket without a subject, and a ticket with a 200-character subject. Give the status code of each, and explain who decided it: FastAPI, your route, or the classifier.

Show the answer

The valid ticket gives 200: FastAPI checked the body, the route asked the classifier, and returned the Classification. The missing subject gives 422, decided by FastAPI from TicketIn (missing, at ["body", "subject"]), before the route ran. The 200-character subject also gives 422 from FastAPI (string_too_long), because of max_length=120. The classifier only decides the content of a 200 response, never the status code.

Recap

  • Keep each concern in its own module: HTTP in routes.py, data in models.py, classification in classifier.py. main.py builds the application.
  • The classifier is plain Python. It knows nothing about HTTP, and returns a Prediction.
  • A dependency (Depends) gives a route what it needs. The route does not make it.
  • Make expensive objects, such as a model, once when the application starts, and keep them in app.state.

You now have a working, structured API. In the next module, you design its contract: clear routes, one error shape, and safe browser access.

Next: Resource and endpoint design