Build a module

Add your first feature the Fabbro way

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.

Anchors, not guesswork. Each root file carries marker comments — # <fabbro:routes> and friends. You insert at the marker; the file stays parseable and the doctor can verify the wiring. Insertion is explicit and visible — the framework never detects your code by magic.

1 · Create the module file

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)

2 · Add schema + data functions in db.py

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.

3 · Import the module in app.py

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

4 · Wire the routes

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.

5 · Verify it boots

Restart the app with fabbro start (Ctrl-C the running stack first). 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.

fabbro start
Need async? If a route kicks off slow work — sending mail, calling a third-party API — don't do it inline. Enqueue it at the # <fabbro:jobs> anchor with worker.enqueue("task", payload) and handle it in a workers/<name>.py process. See Getting started · step 7.
On the roadmap: fabbro module new <name> will generate this file and insert every anchor for you, validating syntax after each edit. It isn't in this release yet — for now the edits are by hand, which is also the best way to learn where the seams are. Loading the architecture skill into your AI agent lets it do the same insertions and respect the same anchors.
← Back to playbooks