Architecture¶
How Nori processes a request from the network socket to the response, including the middleware stack, dependency injection, and error handling.
Understanding how a request flows through Nori helps you debug faster, write better middleware, and know exactly where to put your logic. There's no magic — just a clear pipeline.
Why This Stack¶
Nori is built on three foundations, each chosen for a specific reason:
- Starlette — A lightweight ASGI framework that gives us routing, middleware, WebSockets, and test clients without imposing opinions on the rest. It's fast, well-maintained, and stays out of the way. We add the opinions on top.
- Tortoise ORM — The only Python ORM that is async-native. It doesn't wrap synchronous calls in
run_in_executor— it speaks async all the way to the database driver. In an async framework, the ORM shouldn't be the piece that blocks the event loop. - Jinja2 — The most widely known Python template engine. No proprietary syntax, no learning curve. If you've used it anywhere else, you already know how it works in Nori.
Everything else — authentication, validation, CSRF, JWT, collections, job queues — is built in pure Python with no external dependencies. These three are the pillars. The remaining dependencies are infrastructure: servers (Uvicorn, Gunicorn), database drivers (asyncmy, asyncpg), form parsing, and environment loading. None of them contain application logic — that part is ours to maintain, audit, and understand.
File Ownership¶
A Nori project is a mix of framework-owned files (replaced wholesale on framework:update) and site-owned files (preserved across updates). Knowing the boundary up front prevents the most common upgrade footgun: editing framework code, then losing those edits on the next update.
| Path | Owner | Replaced on framework:update |
|---|---|---|
rootsystem/application/core/** |
Framework | Yes — wholesale |
rootsystem/application/models/framework/** |
Framework | Yes — wholesale |
requirements.nori.txt |
Framework | Yes |
rootsystem/application/asgi.py |
You | No (idempotent patches injected by core._patches) |
rootsystem/application/settings.py |
You | No |
rootsystem/application/routes.py |
You | No |
rootsystem/application/modules/** (controllers) |
You | No |
rootsystem/application/models/*.py (your models — NOT in framework/) |
You | No |
rootsystem/application/seeders/** |
You | No |
rootsystem/application/services/** (mail, storage, search drivers) |
You | No |
rootsystem/application/commands/** (your custom CLI commands) |
You | No |
rootsystem/application/migrations/** |
You (generated by aerich) |
No — but don't hand-edit: regenerate via migrate:make (see note below) |
rootsystem/templates/**, rootsystem/static/** |
You | No |
nori.py (entry script at project root) |
You (with framework seed) | No |
requirements.txt, requirements-dev.txt |
You | No (-r requirements.nori.txt injected once by core._patches) |
pyproject.toml, pytest.ini, Dockerfile, docker-compose.yml, gunicorn.conf.py, .env.example, .dockerignore, .gitignore, .pre-commit-config.yaml, LICENSE |
You (with framework seed at install time) | No |
Rule of thumb: if a file lives under core/ or models/framework/, or is named requirements.nori.txt, treat it as read-only. Everything else under rootsystem/application/ is yours — with the migrations caveat below.
Migrations look like framework code but aren't¶
Files under rootsystem/application/migrations/<app>/0_*.py look auto-generated and "framework-y", and they are — generated by aerich against your engine on migrate:init / migrate:make. The framework never ships them, never replaces them, never patches them. But they are also not meant to be hand-edited: aerich tracks migration state inside the files (specifically MODELS_STATE since aerich 0.9.2 — see the v1.15.0 release notes). Editing a migration file by hand can desync that state and cause future migrate:make invocations to crash or produce invalid SQL.
The right pattern is always: change your model definition, then python3 nori.py migrate:make <name> to let aerich generate the next migration file. To revisit a faulty migration in development, migrate:downgrade it, edit the model, and migrate:make again. To wipe and start over in dev: migrate:fresh.
The one acceptable hand-edit is the rare case where aerich generates an obviously-wrong destructive operation (e.g. DROP COLUMN + ADD COLUMN instead of ALTER COLUMN for a rename). Even then, prefer fixing the model definition and regenerating, or use a custom one-off migration written deliberately.
What happens if you edit a framework file anyway¶
framework:update always backs up the framework directories before replacing them, in rootsystem/.framework_backups/v<previous>_<timestamp>/. So a lost edit is recoverable — but you have to know to look in the backup directory. The pre-flight message in framework:update lists the paths it is about to replace, so you can Ctrl-C if needed.
If you find yourself wanting to patch framework behavior, the right pattern is almost always:
- Override at the controller layer — add or replace a controller method in
modules/. - Add a service driver —
services/storage_*.py,services/mail_*.py,services/search_*.pyare the integration points for swapping backends. - Use the registered config provider —
core.conf.config.MY_SETTINGreads fromsettings.py, so adding a setting + reading it from your code is preferred over editingcore/.
If none of those work, open an issue — that's a signal Nori is missing an extension point.
Authoritative source¶
The lists above are derived from _FRAMEWORK_DIRS and _FRAMEWORK_FILES in core/cli.py and the paths array in .starter-manifest.json. If those change, this table changes — they are the source of truth. The framework:update pre-flight prints the live values from _FRAMEWORK_DIRS / _FRAMEWORK_FILES, so the runtime output stays in sync even if this page drifts.
Don't shadow stdlib at the application root¶
nori.py inserts rootsystem/application/ at the front of sys.path so your code can write from core import … without a package prefix. The side effect is that a file at that root with a stdlib name — json.py, os.py, re.py, etc. — will shadow the stdlib module for the entire process and produce confusing import errors elsewhere.
Don't do it. Keep your code in modules/, models/, templates/, or sub-packages of those — the framework convention already steers you away from the application root.
Request Lifecycle¶
Every HTTP request flows through this pipeline:
Middleware Stack¶
Middleware is registered in asgi.py. Starlette wraps middleware in order, so the first registered middleware runs first during the request phase. Here is the execution order as the request enters:
Request ──→ Request ID
↓
Security Headers
↓
CORS (if enabled)
↓
Session
↓
CSRF
↓
Application (Router → Controller)
↓
CSRF
↓
Session
↓
CORS (if enabled)
↓
Security Headers
↓
Request ID
↓
Response ←──┘
What Each Middleware Does¶
| Middleware | Module | Purpose |
|---|---|---|
| RequestIdMiddleware | core.http.request_id |
Generates a UUID per request (or propagates incoming X-Request-ID). Stored in request.state.request_id. Added to every response as X-Request-ID header. Enables end-to-end tracing across logs and services. |
| SecurityHeadersMiddleware | core.http.security_headers |
Injects security headers on every response: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 1; mode=block, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=(), Strict-Transport-Security (HSTS, 1 year). Optional CSP if configured. |
| CORSMiddleware | starlette.middleware.cors |
Only added if CORS_ORIGINS is set in .env. Handles OPTIONS preflight and adds Access-Control-* headers. |
| SessionMiddleware | starlette.middleware.sessions |
Creates and validates signed session cookies using SECRET_KEY. Populates request.session as a dict-like object. Required by CSRF, auth decorators, and flash messages. |
| CsrfMiddleware | core.auth.csrf |
Validates CSRF tokens on state-changing methods (POST, PUT, DELETE, PATCH). Skips safe methods (GET, HEAD, OPTIONS). Checks X-CSRF-Token header first, then _csrf_token form field (form bodies only — JSON clients must send the header). Auto-generates token if missing. Returns 403 on mismatch, 413 on oversized body (DoS protection, 10 MB limit). |
Why This Order Matters¶
Middleware order matters. Request ID wraps everything because every log line needs a trace. CSRF runs before your code because forged requests should never reach a controller. Session loads early because auth decorators depend on it.
- Request ID first: ensures every log message — including middleware errors — has a trace ID.
- Security headers early: guarantees all responses get security headers, even on middleware failures.
- CORS before session: preflight
OPTIONSrequests must be handled before session cookie processing. - Session before CSRF: CSRF middleware reads
scope['session']to get/set the CSRF token, so the session must be populated first. - CSRF last: processes the request body after all other middleware can read from the request.
Routing¶
Routes are defined explicitly in routes.py as Starlette Route and Mount objects:
from starlette.routing import Route, Mount
article = ArticleController()
routes = [
Route('/', homepage, methods=['GET'], name='page.home'),
Mount('/articles', routes=[
Route('/', article.index, methods=['GET'], name='articles.index'),
Route('/{id:int}', article.show, methods=['GET'], name='articles.show'),
Route('/', article.store, methods=['POST'], name='articles.store'),
]),
]
Key conventions:
- Always provide methods= to be explicit about allowed HTTP methods.
- Always provide name= for reverse routing in templates and redirects.
- Controllers are instantiated once globally, not per-request.
Dependency Injection (@inject)¶
The @inject() decorator on controller methods automatically maps request data into function parameters. It reads the function signature once at decoration time (not per request) and injects values on each call.
Resolution Order¶
For each parameter in the function signature (excluding self and request):
form/dictannotation → entire parsed request body (JSON or form data)- Path parameters →
request.path_params[name], with type casting from annotation - Query parameters →
request.query_params[name], with type casting from annotation - Default value →
param.defaultif none of the above matched, orNone
How It Works¶
from core.http.inject import inject
class ProductController:
@inject()
async def update(self, request, product_id: int, form: dict):
# product_id: auto-cast from path param /products/{product_id}
# form: entire request body as dict (JSON or form-encoded)
...
Type Casting¶
Type coercion is applied only for simple types: int, float, str, bool. If a parameter has one of these annotations (e.g. product_id: int), @inject casts the raw string value. If casting fails (e.g. int('abc')), the parameter falls back to its default value or None.
Complex generic types (list[int], dict[str, Any], etc.) are not coerced — the raw value is passed as-is. Parse these manually from request.json() or request.form().
Form Data Source¶
@inject detects the content type automatically:
- application/json → await request.json()
- Everything else → await request.form() → converted to dict
If parsing fails (e.g. malformed JSON), the decorator returns a 400 Bad Request response with {"error": "Invalid request body"} instead of silently proceeding with empty data. Type coercion failures on path and query parameters are logged as warnings and fall back to the parameter's default value.
Error Handling¶
Production Mode (DEBUG=false)¶
Two custom error handlers are registered:
404 Not Found — content-negotiated:
- If Accept: application/json → {"error": "Not Found"} with status 404
- Otherwise → renders rootsystem/templates/404.html
500 Internal Server Error:
- Logs the full exception with traceback via nori.asgi logger
- Renders rootsystem/templates/500.html
Development Mode (DEBUG=true)¶
Starlette's built-in debug error pages are used, showing full tracebacks in the browser.
Authentication Decorators¶
These decorators wrap controller methods and run before the method body:
| Decorator | Checks | On failure (JSON) | On failure (HTML) |
|---|---|---|---|
@login_required |
request.session['user_id'] exists |
401 Unauthorized | Redirect to /login |
@require_role('admin') |
request.session['role'] matches |
403 Forbidden | Redirect to /forbidden |
@require_any_role('admin', 'editor') |
Role matches any | 403 Forbidden | Redirect to /forbidden |
@require_permission('articles.edit') |
Permission in session cache | 403 Forbidden | Redirect to /forbidden |
@token_required |
Valid JWT in Authorization: Bearer header |
401 Unauthorized | 401 Unauthorized |
adminrole bypasses all role and permission checks.- Permissions must be loaded at login with
await load_permissions(request.session, user.id). @token_requiredstores the decoded payload inrequest.state.token_payload.
Decoupling: Registry & Config¶
Nori uses a decoupled architecture to ensure the core remains agnostic to the application structure, facilitating seamless framework updates.
1. Model Registry (core.registry)¶
The core never imports models directly from the models/ directory. Instead, models are registered at application startup and retrieved by name.
Registration (models/__init__.py):
from core.registry import register_model
from models.audit_log import AuditLog
from models.job import Job
register_model('AuditLog', AuditLog)
register_model('Job', Job)
Retrieval (core/audit.py):
This prevents circular dependencies and allows the framework core to be replaced or updated without breaking the application logic.
2. Configuration Provider (core.conf)¶
Core modules access settings through a configuration provider instead of importing settings.py directly. This allows the core to be distributed as a standalone library.
Initialization (asgi.py):
# 1. Bootstrap hook — runs BEFORE any framework/third-party import so
# observability SDKs (Sentry, OTel, Datadog) can patch libraries at
# import time. Optional rootsystem/application/bootstrap.py with a
# bootstrap() function. Silent if absent.
from core.bootstrap import load_bootstrap
load_bootstrap()
# 2. Settings + config provider
import settings
from core.conf import configure
configure(settings)
The bootstrap hook is the very first thing in asgi.py — before Starlette, Tortoise, httpx, or any other instrumentable library is imported. This is the only place observability SDKs can hook all of them. See Observability.
Usage (core/auth/jwt.py):
Background Tasks¶
Nori wraps Starlette's BackgroundTask with error logging:
from core.tasks import background, run_in_background
# Create a task — pass it to a response's background= parameter
task = background(send_welcome_email, user.email)
return JSONResponse({'ok': True}, background=task)
# Or attach a task to an existing response
response = JSONResponse({'ok': True})
run_in_background(response, send_welcome_email, user.email)
return response
If the background callable raises an exception, the error is logged (not swallowed, not re-raised). The HTTP response has already been sent, so the user is not affected.
Logging¶
from core.logger import get_logger
log = get_logger('mymodule') # Creates 'nori.mymodule' logger
log.info('User %d logged in', user_id)
Configuration (.env)¶
| Var | Values | Default |
|---|---|---|
LOG_LEVEL |
DEBUG, INFO, WARNING, ERROR, CRITICAL |
DEBUG if DEBUG=true, else INFO |
LOG_FORMAT |
text, json |
text |
LOG_FILE |
File path (optional) | None (stdout only) |
JSON Format¶
When LOG_FORMAT=json, each log line is a JSON object with: timestamp (ISO 8601 UTC), level, logger, message, exception (if any), request_id (if set by RequestIdMiddleware).
Settings Validation¶
validate_settings() runs at startup (called in the ASGI lifespan context) and checks:
- Database credentials are present for non-SQLite in production
- Template and static directories exist on disk
JWT_SECRETdiffers fromSECRET_KEYin productionJWT_SECREThas minimum 32 characters (HMAC-SHA256 security)
In production (DEBUG=false), validation failures raise RuntimeError — the app will not start with unsafe configuration. In development, issues are returned as warning strings.