A small FastAPI service that puts two Hugging Face models behind an HTTP API: one suggests a minimum age rating for a text, the other flags hate speech.
This is a proof of concept, not a reference implementation. There were two things worth validating:
- Whether a layered structure (domain / infrastructure / presentation) is worth the extra indirection when the interesting part of the app is a model call.
- Whether running the models locally is practical: no external inference API, no per-request cost, everything on the machine that serves the requests, and what that costs in performance (cold-start download and load time, latency per request, memory held by a process that keeps the models resident).
It is kept deliberately small, so anything a production service would need, persistence, auth, rate limiting, batching, monitoring, model evaluation, is out of scope on purpose.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload
Swagger UI: http://127.0.0.1:8000/docs
The models are downloaded from Hugging Face and loaded on the first request that needs them, so that request is slow (a few GB on a cold cache). Every request after it reuses the loaded model.
pip install -r requirements-dev.txt
pytest
The tests replace both services with fakes through FastAPI's dependency overrides, so they never download or run a model.
POST /v1/age-classification
{
"text": "string"
}
{
"rating": 12,
"label": "12+"
}
POST /v1/hate-speech/detect
{
"text": "string"
}
{
"is_hate_speech": true,
"confidence_score": 0.9712
}
POST /v1/hate-speech/analyze
Same request, plus the score of every toxicity category:
{
"text": "string",
"is_hate_speech": true,
"confidence_score": 0.9712,
"detected_categories": ["toxic"],
"categories": [
{ "category": "toxic", "confidence": 0.9712, "above_threshold": true },
{ "category": "insult", "confidence": 0.1043, "above_threshold": false }
],
"model": "unitary/toxic-bert",
"analyzed_at": "2026-01-01T00:00:00Z"
}
GET /health: liveness check that does not touch the models.
| Feature | Model | Task |
|---|---|---|
| Age classification | facebook/bart-large-mnli |
zero-shot classification against the age labels in HuggingFaceAgeService |
| Hate speech | unitary/toxic-bert |
multi-label toxicity classification |
Both are English-language models. Configurable through environment variables:
AGE_MODEL, HATE_SPEECH_MODEL, HATE_SPEECH_THRESHOLD, TORCH_DEVICE
(auto-detects cuda / mps / cpu when unset). See app/config.py.
- The age scale is a hand-written mapping from zero-shot labels to ages. It was never evaluated against a labelled dataset, and zero-shot confidence on these labels is often low.
- Hate speech detection inherits the biases and the English-only training data
of
unitary/toxic-bert. - Models are held in memory in a single process. One worker per machine is the practical limit.
fastapi-content-moderation/
├── app/
│ ├── config.py # Settings, read from environment variables
│ │
│ ├── domain/ # Core of the app, no framework or ML imports
│ │ ├── entities/ # Objects with identity and behaviour
│ │ ├── services/ # Interfaces (ABCs) the domain depends on
│ │ ├── usecases/ # One class per business operation
│ │ ├── value_objects/ # Immutable, self-validating values
│ │ └── errors.py # Expected failures, mapped to HTTP in main.py
│ │
│ ├── infrastructure/ # Hugging Face implementations of the interfaces
│ │ └── providers.py # Composition root: builds each service once
│ │
│ └── presentation/ # HTTP layer (FastAPI)
│ ├── age_classification/ # routes.py, controller.py, schemas.py
│ └── hate_speech/
│
├── tests/ # pytest, with fake services
└── main.py # App instance, routers, exception handlers
Each feature in presentation/ is split the same way: routes.py declares the
endpoints and their dependencies, controller.py maps between schemas and the
domain, schemas.py holds the request/response models.
graph LR
main[main.py] --> presentation
presentation[presentation<br/>routes, controllers, schemas] --> usecases
usecases[domain<br/>usecases, entities, value objects] --> interfaces
interfaces[domain<br/>service interfaces]
infrastructure[infrastructure<br/>Hugging Face services] -.implements.-> interfaces
providers[infrastructure<br/>providers.py] --> infrastructure
presentation -.depends on.-> providers
The domain declares what it needs as abstract classes and never imports
transformers or fastapi. providers.py is the only place that knows which
concrete implementation is used, which is what lets the tests swap in fakes.