A module is one file of business logic, plugged into the root files at fixed anchors. Logic in modules/, SQL in db.py, routes in app.py — never scattered. This walks the four edits by hand so you can see exactly where each piece lands.
A module is a single file of functions and orchestration. It calls db.py for data and core/ for infrastructure — it never opens a raw connection, writes SQL, or defines its own security. Give it a typed exception so callers branch on a class, not a string. Here's a minimal modules/notes.py.
from __future__ import annotations
import db
class NoteError(Exception):
"""A note operation the caller can recover from (e.g. empty body)."""
def create_note(owner_id: int, body: str) -> int:
"""Persist a note and return its id; refuse an empty body."""
body = body.strip()
if not body:
raise NoteError("Note body is empty. Fix: type something before saving.")
return db.insert_note(owner_id, body)
def list_notes(owner_id: int) -> list[dict]:
"""Return this owner's notes, newest first."""
return db.notes_for_owner(owner_id)All SQL lives in db.py — never in the module. Put the table at the -- <fabbro:schema> anchor (SQL comment syntax, because # is invalid inside the schema string), and the queries the module calls at # <fabbro:data_functions>.
At -- <fabbro:schema>, inside the schema script:
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TEXT NOT NULL
);At # <fabbro:data_functions>:
def insert_note(owner_id: int, body: str) -> int:
with connection() as con:
cur = con.execute(
"INSERT INTO notes (owner_id, body, created_at) VALUES (?,?,?)",
(owner_id, body, now()),
)
return cur.lastrowid
def notes_for_owner(owner_id: int) -> list[dict]:
with connection() as con:
rows = con.execute(
"SELECT id, body, created_at FROM notes "
"WHERE owner_id=? ORDER BY created_at DESC",
(owner_id,),
).fetchall()
return [dict(r) for r in rows]The schema is applied idempotently on every boot — a fresh CREATE TABLE IF NOT EXISTS is safe to re-run.
At the # <fabbro:imports> anchor near the top of app.py, add the import so your routes can reach the module.
from modules import notes
At # <fabbro:routes>, add thin routes: read the request, call the module, return a response. The route owns HTTP; the module owns logic. Gate it with @security.require_auth (a logged-in session) or @auth.require_role(...) (a session with a specific role).
@app.route("/notes", methods=["GET"])
@security.require_auth
def notes_page():
items = notes.list_notes(session["uid"])
return render_template("notes.html", notes=items)
@app.route("/notes", methods=["POST"])
@security.require_auth
def notes_create():
body = _require_form("body") # aborts 400 naming a missing field
notes.create_note(session["uid"], body)
return redirect(url_for("notes_page"))CSRF is already enforced on every mutation by the security layer — your form just needs the csrf_token() hidden field the layouts provide. You write no CSRF code.
Restart the app. The new table is created on boot, the routes register, and a syntax slip in any anchor surfaces immediately as an import error — not a silent half-wired route.