Skip to content

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 driverservices/storage_*.py, services/mail_*.py, services/search_*.py are the integration points for swapping backends.
  • Use the registered config providercore.conf.config.MY_SETTING reads from settings.py, so adding a setting + reading it from your code is preferred over editing core/.

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:

Client → Uvicorn (ASGI) → Middleware Stack → Router → Controller Method → Response

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 OPTIONS requests 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):

  1. form / dict annotation → entire parsed request body (JSON or form data)
  2. Path parametersrequest.path_params[name], with type casting from annotation
  3. Query parametersrequest.query_params[name], with type casting from annotation
  4. Default valueparam.default if none of the above matched, or None

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/jsonawait 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
  • admin role bypasses all role and permission checks.
  • Permissions must be loaded at login with await load_permissions(request.session, user.id).
  • @token_required stores the decoded payload in request.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):

from core.registry import get_model

AuditLog = get_model('AuditLog')
await AuditLog.create(...)

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):

from core.conf import config

secret = config.JWT_SECRET


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_SECRET differs from SECRET_KEY in production
  • JWT_SECRET has 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.