jfastframework
Docs Español PyPI GitHub

Services, modules and layouts

Two levels of generation:

jfast new service billing            # a whole service
jfast new module invoice             # a domain module inside it

Both are compositions, not fixed templates. You choose the shape, per module, and the choice is remembered.


Services

jfast new service billing                            # JSON API
jfast new service storefront --kind web --port 8020  # server-rendered
jfast new service billing --agent-docs               # + AGENTS.md and skills
Kind Renders Enabled plugins Extra needed
api JSON observability, metrics, database [server,db,metrics]
web HTML + web [server,db,metrics,web]
spa — a frontend project none; it is npm
gateway — gateway [server,gateway]

A frontend is a service like any other. It logs the same way, reports health the same way, deploys the same way, and lives in the same port block. The only difference is what comes out of the handlers.

Both backend kinds are generated with main.py, jfast.toml, .env.example, conftest.py, requirements.txt, shared/, .gitignore and a README. The web kind adds templates/base.html, templates/index.html, static/app.css and a root web.py router.

contracts.toml is not among them. Its layer paths are a layout's, and a service has no layout until it has a module, so it arrives with the first one — see Module layouts below.

--agent-docs additionally writes AGENTS.md and .jfast/skills/ — see Working with AI agents.


Module layouts

Four, and each module picks its own. That is the point of a modular monolith: a catalogue module is four files, and an orders module that has to be testable without a database wants ports and adapters. Forcing both into the same shape makes one of them wrong.

jfast new module invoice                        # asks
jfast new module invoice --layout hexagonal     # or say

Run without --layout in a terminal and it asks:

Architecture for 'invoice'
  › modular      a folder per layer. Start here: it grows without being moved.
    layered      a file per layer. For a table with an API and little else.
    screaming    one file per use case. When the verbs matter more than the nouns.
    hexagonal    ports and adapters. When the domain must be testable with no database.

  choice [modular] ›

With no terminal it does not ask. A piped install, a script or a CI job gets modular rather than a prompt nobody can see. A wizard that blocks a pipeline is worse than a flag nobody set.

The first module also writes contracts.toml, with the layer paths of the layout you picked. That is the earliest honest moment: jfast new service has no module and no layout, and the guess it used to make — layered, always — matched none of the files the other three layouts generate, so their contracts enforced nothing and reported a pass.

A later module in a different layout does not rewrite it. A contract on disk is a document someone has had the chance to edit, and its layer paths are the least of what it carries. Add the second layout's paths yourself; nothing else will, and contracts check cannot see the gap while the first layout's layers still match their own modules. If the whole service moves to another layout, the check does report it, as layer-unmatched.

layered

The familiar shape. Good when the module is mostly CRUD and the interesting part is the data, not the rules.

modules/invoice/
├── router.py       HTTP in, response out
├── service.py      business rules
├── repository.py   queries
├── models.py       SQLAlchemy
├── schemas.py      Pydantic
├── enums.py
├── public.py       what other modules may call
├── README.md
└── tests/

modular

Layered, one folder per concern. The boundaries are identical — its contract is the layered one with different paths — so the only thing the folders buy is room: a concern can grow to several files without anybody having to decide where the new one goes.

modules/invoice/
├── api/routes.py
├── models/
│   ├── invoice_entity.py       SQLAlchemy
│   └── invoice_models.py       Pydantic
├── repositories/invoice_repository.py
├── services/invoice_service.py
├── validations/invoice_validation.py
├── enums.py
├── public.py                   what other modules may call
├── README.md
└── tests/

validations/ is the one genuinely new idea: business rules that are not schema shape. "This name is already taken" needs the rest of the table; "you cannot deactivate the last active one" needs the current row. Neither is something Pydantic can express, and both belong somewhere a reader can find them.

Every folder has an __init__.py that re-exports its public names, so callers import from the package rather than reaching into a file.

Reach for it when a module outgrows four files — not before. Six folders around one CRUD entity is ceremony.

screaming

The directory listing is the feature list. Good when the module has real rules worth protecting from the framework, and when you want new capabilities to arrive as new files rather than as new methods on a class nobody can navigate.

modules/invoice/
├── invoice.py           the domain: entity + rules, framework-free
├── use_cases/
│   ├── create_invoice.py
│   ├── list_invoices.py
│   ├── get_invoice.py
│   ├── update_invoice.py
│   └── delete_invoice.py
├── storage.py           SQLAlchemy model + repository, with to_domain()
├── http.py              router + wire schemas
├── public.py            what other modules may call
├── README.md
└── tests/
    ├── test_invoice_domain.py      no database, no fakes, no event loop
    └── test_invoice_use_cases.py   fake repository, nothing else

hexagonal

Ports and adapters. The domain declares an abstract port, infrastructure implements it, and the application layer depends only on the port.

modules/invoice/
├── domain/
│   ├── entities.py      plain dataclasses, no ORM
│   ├── ports.py         the Protocol the application depends on
│   └── enums.py
├── application/use_cases.py
├── infrastructure/
│   ├── orm.py           SQLAlchemy model
│   └── repository.py    implements the port
├── adapters/http.py     the FastAPI router
├── public.py            what other modules may call
├── README.md
└── tests/
    ├── test_invoice_domain.py      no database, no FastAPI, milliseconds
    └── test_invoice_use_cases.py   an in-memory fake implementing the port

The rule that makes it worth its cost: domain/ may import nothing. Not SQLAlchemy, not FastAPI, not the application layer. The generated contract enforces exactly that:

[layers.domain]
paths = ["modules/*/domain/*.py"]
may_import = []
forbid_packages = ["sqlalchemy", "fastapi", "starlette", "httpx", "redis"]

The moment the domain imports the ORM, the thing you were buying — a domain testable in milliseconds with no database — is gone, and you are paying four folders for nothing. So it is checked rather than remembered.

Use it when the rules are the product, when more than one entry point drives the same behaviour, or when the domain must be tested exhaustively and fast. It is the most expensive layout here. Most modules do not need it.

Choosing

Reach for it when
modular Default. A folder per layer, so a module grows without being reorganised.
layered A table with an API and little else: five files is the whole module.
screaming The verbs matter more than the nouns; capabilities arrive as files.
hexagonal The domain must be testable with no database, or the rules are the product.

You do not have to be right on day one. Layouts are per module, so the next one can differ, and moving between them is a refactor inside one folder.

What every layout guarantees

All four export the same three names, which is what lets everything else stay layout-agnostic:

Export Used by
router main.py, spliced in by the generator
build_service(session, tenant_id) the HTMX overlay, workers, anything else
CreatePayload the HTMX form handler, which must build one without knowing the layout

Those are for the app. What other modules see is a fourth file, public.py, and nothing else — next section.


Communication between modules

Queries through a facade, effects through events, nothing through shared/.

Sooner or later module B needs data module A owns. There are four ways to get it, and only one of them survives the day A changes:

Way What it costs contracts check
Import A's service, repository or entity B depends on A's internals. Rename a method in A and B breaks; neither can become a service without the other. cross-module
Raw SQL against A's tables from B The same coupling, with nothing to see it. No import to grep; A renames a column and B fails in production. cross-module-sql
Move the code to shared/ shared/ becomes a second home for behaviour. A repository there is two modules sharing a table. — (that is why the rule is explicit)
Call a function in A's public.py A promises a function and a DTO; everything behind it stays A's to change. passes, once declared

The facade

Every generated module has modules/<name>/public.py, and it is the only file another module may import from it. Say the asesor module — an advisor that answers questions about spending — needs comprobante's spending by category.

The owner exposes a function and a DTO:

# modules/comprobante/public.py
from __future__ import annotations

from dataclasses import dataclass
from datetime import date
from typing import TYPE_CHECKING

from .repositories import ComprobanteRepository

if TYPE_CHECKING:
    from sqlalchemy.ext.asyncio import AsyncSession


@dataclass(frozen=True, slots=True)
class CategorySpending:
    category: str
    total_cents: int
    receipts: int


async def spending_by_category(
    session: AsyncSession, *, tenant_id: str, since: date, until: date
) -> list[CategorySpending]:
    repository = ComprobanteRepository(session, tenant_id=tenant_id)
    rows = await repository.spending_by_category(since=since, until=until)
    return [CategorySpending(category=c, total_cents=t, receipts=n) for c, t, n in rows]

The query itself lives in comprobante's repository, next to the table it reads. The caller imports the facade and nothing else:

# modules/asesor/services/asesor_service.py
from modules.comprobante.public import spending_by_category

spending = await spending_by_category(session, tenant_id=tenant_id, since=start, until=end)

And says so in contracts.toml:

[modules.asesor]
depends_on = ["comprobante"]

jfast new module appends an empty [modules.<name>] depends_on = [] for every module it generates, so adding an edge is always a line a reviewer sees change. The generated public.py ships with one example — get_<name>(session, *, tenant_id, <name>_id) -> <Name>Summary | None — wired through that layout's own repository.

Why it takes the session and a tenant_id

Effects go through events

A facade answers questions. When asesor needs to react to something comprobante did — a receipt was categorised, so the advice is stale — it does not get called back. comprobante publishes an event in the same transaction as the write, through the outbox, and asesor subscribes:

# modules/comprobante/services/comprobante_service.py -- next to the write
from jfastframework.events import Event

await outbox.publish(
    session, "comprobantes", Event(type="comprobante.categorized", data={"id": c.id}, key=str(c.id))
)
# modules/asesor/tasks.py
from jfastframework.events import Event, subscribe
from jfastframework.tasks import TaskSession

@subscribe("comprobante.categorized")
async def refresh_advice(event: Event, session: TaskSession) -> None:
    ...
# contracts.toml
[modules.comprobante]
publishes = ["comprobante.categorized"]

outbox.publish queues one job per subscriber in the same transaction as the write, and jfast worker runs it as the tenant that published. No broker is involved: this works on the default PostgreSQL stack. comprobante never learns that asesor exists and asesor declares no depends_on for it — so there is no edge either way, and no cycle. An event nobody subscribes to is refused in the request, and contracts check reports a subscription nobody declares publishing. Delivery guarantees are in Queues and events; @on(topic) over Kafka is for other services, not for modules of this one.

The payoff: extracting a module

The day comprobante moves out into its own service (jfast new service comprobante), the change on this side is one file: public.py keeps its signatures and its DTOs, and its body becomes a call to the new service through the http plugin. The session argument simply stops being used. asesor's import line does not change, because it never knew where the answer came from.

That only works if public.py was the only way in. A module that also reached into comprobante's repository, or queried its tables, has to be found and rewritten first — which is what the checks below exist to prevent.

What is checked

Rule Fires when
cross-module a module imports anything of another module other than modules/<other>/public.py
undeclared-dependency it imports modules.<other>.public without <other> in its depends_on
module-cycle the graph of declared depends_on plus actual facade imports has a cycle
public-leak public.py imports or re-exports an ORM entity, or imports fastapi/starlette
cross-module-sql a string in one module holds SQL naming a table another module owns

The messages, waivers and the one switch that turns them off are in Contracts.


The layout is remembered

jfast new module records the choice in jfast.toml:

# How each module was generated, so later commands know where a
# new file belongs. Written by `jfast new module`.
[modules.invoice]
layout = "hexagonal"
ui = "api"

[modules.catalogue]
layout = "layered"
ui = "api"

Asking again each time eventually gets a different answer, and guessing from the folders on disk breaks the moment somebody adds one. The runtime ignores the table — it is CLI bookkeeping that happens to live in the file that was already there.


Modules register themselves

main.py ships with two markers, and the generator splices into them:

from modules.invoice import router as invoice_router
# [jfast:imports]

ROUTERS: list[APIRouter] = [
    invoice_router,
    # [jfast:routers]
]

app = create_app(routers=ROUTERS)

Idempotent: generating the same module twice does not mount it twice. Keep the markers. Without them the generator prints what to paste rather than guessing a line number — it will not fail your scaffold over it, but it stops registering for you.


UI: JSON, HTML, or both

jfast new module invoice --ui api    # JSON only (default)
jfast new module invoice --ui htmx   # JSON plus server-rendered pages

--ui htmx is an overlay, composed on top of any layout rather than duplicated per layout. It adds:

modules/invoice/web.py           HTML router, mounted at /ui/invoices
templates/invoice/index.html     the page
templates/invoice/_rows.html     the table body fragment
templates/invoice/_row.html      one row
templates/base.html              only if the project has none

The HTML surface lives under /ui/. The JSON router already owns /invoices and declares the same verbs on it, so two routers on one prefix meant whichever registered first answered — browsing returned JSON, and the form POSTed into the API handler. Distinct prefixes mean neither can shadow the other regardless of the order in main.py.

Path Returns
/invoices JSON
/ui/invoices the page, or a fragment for an hx-get

The JSON router stays. A module serves both surfaces from the same rules, which is the point: the HTML views are not a second implementation.

Partial rendering

HTMX sends HX-Request: true and expects a fragment. render() handles both from one handler:

return render(request, "invoice/index.html", {"page": page},
              partial="invoice/_rows.html")

Browser navigation gets the whole page. hx-get gets just the rows. One handler, one context, no duplicated markup — the page {% include %}s the same fragment it returns.

Errors

With the web plugin enabled, a JFastError raised during an HTMX request comes back as an HTML fragment instead of problem+json. HTMX swaps the response body into the DOM, so JSON would render to the user as raw text. Plain requests still get problem+json, which keeps a mixed API/web service honest on both surfaces.


Fields: generate the module you meant

A module generated with no fields carries an example -- name, description, is_active -- that fits no real domain. The first module built on 0.1.0a10 went from 654 generated lines to 276 kept. Say what the module holds instead, and every place the example used to be gets the real fields:

jfast new module presupuesto \
  --fields "cartera_id:int, mes:str(7), gasto:money, leida:bool=false, nota:text?" \
  --unique "cartera_id,mes"

That writes the entity (with its unique constraint), the create, update and read models with matching limits, a repository finder per unique key, the rule that turns a taken key into a readable 409 -- on create and on the update that touches the key -- the public.py DTO with the real fields, and tests that exercise every one of them. It works for all four layouts. For a module whose fields are not known yet, or that holds nothing but relations:

jfast new module alerta --bare      # the structure, no fields, no example

The grammar

One field per comma; commas inside parentheses do not split.

field := name ":" type ["?"] ["=" default]
Type Python Column On the wire
int int INTEGER
bigint int BIGINT
str(N) str VARCHAR(N) max_length=N; min_length=1 unless nullable
str str VARCHAR(255) as str(255)
text str TEXT min_length=1 unless nullable
bool bool BOOLEAN
float float FLOAT
decimal(P,S) Decimal NUMERIC(P,S) max_digits=P, decimal_places=S
money int BIGINT integer minor units: 1050 is 10.50
date date DATE
datetime datetime TIMESTAMPTZ AwareDatetime: a naive one is a 422
json dict[str, Any] JSONB (JSON off PostgreSQL)
enum(a,b,...) a StrEnum, <Module><Field> VARCHAR + CHECK (field IN ('a', 'b')) the enum: any other value is a 422

Every mistake is refused before a file is written, with the fix in the message: an unknown type lists the ones there are, id or tenant_id says they are already on every entity, str(0) points at text, and a name that would shadow something the generated code uses (payload, json, model_...) asks for another name.

tenant_id is on every generated entity whatever the fields -- see going multitenant later: it is what turns "we have a second customer" into a backfill instead of a schema rewrite.

--ui htmx is refused with --fields or --bare: the pages it draws are for the example fields. Generate the API module and write the pages for yours.

Who may call the routes

The generated routes read the caller the way the service is configured, so a module is never more open than the service around it:

jfast.toml enables Routes Tenant
tenancy tenant_id: str = Depends(current_tenant) in the factory the caller's; 401 without a session, 403 without a tenant
auth or accounts APIRouter(dependencies=[Depends(require_auth)]) none: one customer
neither open whatever the request resolved to -- nothing

--access open|auth|tenant overrides it for one module.

Every module is formatted and typed

What the generator writes passes the project's own gates -- ruff check ., ruff format --check ., mypy . (strict) and pytest -- in every layout and every form; the generated project's gates has the details. New files are also run through the project's ruff when it is installed (it is in requirements-dev.txt), because a long module name makes lines no template can wrap in advance. Without ruff the files are written as rendered, and ruff format . finishes the job.

Every module also gets an empty tasks.py: the place its @task and @subscribe declarations go, found at boot by the API and by jfast worker alike. See queues and events.


Table names

Table names are pluralised: order → orders, category → categories. Not cosmetics — singular nouns collide with SQL reserved words far more often than plurals (order, user, group). Override when it guesses wrong:

jfast new module order --table sales_orders

A project that names its modules in Spanish gets Spanish plurals. Say so once, in jfast.toml:

[scaffold]
language = "es"

and camion becomes camiones, sucursal becomes sucursales, lapiz becomes lapices, lunes stays lunes. In a compound name the head noun takes the plural, as it does in Spanish: orden_compra → ordenes_compra. --language en or --language es overrides the project for one module; anything else is refused. Without the setting, English rules apply, as before.


Combinations

Four layouts × two UIs = eight, from six template trees. Composition rather than eight copies that drift apart:

jfast new module invoice --layout modular --ui htmx
jfast new module ledger  --layout hexagonal

Every combination is exercised in CI. scripts/smoke_layouts.sh generates all four layouts and asserts each renders, imports, mounts its routes and passes its own contract; scripts/smoke_htmx.sh submits the actual form on each; and scripts/smoke_generated_quality.sh runs ruff, ruff format, mypy and pytest on every layout with example fields, --fields and --bare.


After generating

jfast contracts check                       # before anything else
pytest modules/invoice/tests
alembic revision --autogenerate -m "add invoices"
alembic upgrade head

Or all of it at once, with the database up and the migration applied first:

jfast dev

See The local loop.

Read the generated migration before applying it. Autogenerate misses server-side defaults, enum changes and index renames.

Generated files carry a .jfast-template stamp recording which template and which framework version produced them. That stamp is what a future jfast upgrade uses to show a diff instead of a rewrite.