Separate concerns
- Lesson 6
- 40 min
- Module 2: Build a small FastAPI service
Lesson 6 of 21 · Build and Deploy Your First APISeparate concerns
1. Understand the web request
2. Build a small FastAPI service
3. Design a usable contract
4. Document the API with OpenAPI and Swagger UI
5. Explore and test with Postman
6. Test and harden the service
7. Deploy and verify
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 ready
What you need to know
- Modules and imports, from Modules and imports: Worked example: split the script.
- The models
TicketInandClassification, from Request and response models: Models describe the data.
What you need on your computer
ticket_api/main.pywith the models, the keyword rules andPOST /classify, from Request and response models: Worked example: the classify endpoint. If you addedchannelin that lesson’s “Your turn” task, remove it, as the task says. If your file is different or broken, replace it with the worked example again.- The folder
requestsin the project, withbilling.jsonandempty-subject.json. You made it in Request and response models: Guided practice: build and call the endpoint. The table in Valid and invalid requests has the content of each file. - The development server runs, as in Environment and first endpoint: Step 3: start the server.
- If the project is missing, make it again: do Environment and first endpoint, then the guided practice of Request and response models.
Tested with Python 3.14.6 and FastAPI 0.142.2 on macOS, and Pyodide 314.0 (browser).
Three concerns, three modules
Section titled “Three concerns, three modules”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.
Worked example: the classifier module
Section titled “Worked example: the classifier module”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.
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.
Tab adds four spaces. Press Esc, then Tab, to leave the box. Ctrl+Enter runs the code.
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:
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from ticket_api.classifier import KeywordClassifierfrom 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:
from fastapi import FastAPI
from ticket_api.classifier import KeywordClassifierfrom 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.
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.
Guided practice: split the project
Section titled “Guided practice: split the project”-
Make
ticket_api/classifier.pywith the code from the worked example. -
Make
ticket_api/models.py. MoveCategory,TicketInandClassificationfrommain.pyinto it, with the imports that they need (Literal,BaseModel,Field). -
Make
ticket_api/routes.pywith the code above. -
Replace
ticket_api/main.pywith the short version. DeleteKEYWORDSfrom it: the classifier has its own copy now. -
Save, and check the server’s terminal. If it shows an
ImportError, compare the import lines with the code above. -
Send
requests/billing.jsonagain: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. -
Send
requests/empty-subject.json. It still gives422. 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:
- Predict the result for
{"subject": "Refund for the lost parcel"}, and check it with curl. - Predict the result for
{"subject": "I want my money back"}, and check it. - Open
http://127.0.0.1:8000/docsin your browser, and findClassificationunder Schemas at the bottom. Isrefundin 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.
shipping, priority1, status200. The text has oneshippingword (parcel) and onerefundword. It is a tie, andshippingcomes first inKEYWORDS, so it wins.lostmakes it urgent.500 Internal Server Error.refundwins, butClassificationonly allows the five values ofCategory, so Pydantic refuses the result, as in the common mistake of the last lesson.- 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:
- Read the last line of the error: which name, and which module.
- Check that the name is defined in the module that the import line names.
TicketInis inticket_api.modelsnow. - Check that each module imports every name that it uses, for example
Fieldinmodels.py. - 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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
Recap
- Keep each concern in its own module: HTTP in
routes.py, data inmodels.py, classification inclassifier.py.main.pybuilds 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.