Per-module Base
Every module that declares SQLModel tables calls create_module_base("<name>") once, and inherits from the returned class:
from simple_module_db.base import create_module_base
Base = create_module_base("orders")
class Order(Base, table=True):
__tablename__ = "orders_order"
id: int | None = Field(default=None, primary_key=True)
...create_module_base returns a SQLModel base bound to a private MetaData object. This isolation is what lets Alembic autogenerate attribute each table to a specific module and what makes build_module_metadata() able to assemble the combined target metadata.
Single shared schema
All modules — whether on Postgres or SQLite — live in the host's single schema. There is no per-module schema policy. __tablename__ must be prefixed with the module name to avoid collisions (orders_order, users_user). create_module_base doesn't enforce the prefix; it's a convention the framework relies on.
The same migrations apply to Postgres and SQLite. There is no provider branching in model metadata.
The build_module_metadata() function
Alembic's autogenerate needs a single MetaData object describing every table it should manage. Each module has its own — so host/migrations/env.py calls:
from simple_module_db.base import build_module_metadata
target_metadata = build_module_metadata()This function iterates every discovered module, imports its models submodule (if one exists), and unions all the per-module MetaDatas into one. Autogenerate then diffs the DB against that union.
If a module has no models.py, it contributes nothing — fine. If a module has a models.py that doesn't import, the union fails — fix the import.
make_include_object()
Alembic's include_object callback filters which tables autogenerate considers. host/migrations/env.py calls make_include_object(target_metadata) from simple_module_db to:
- Include only tables present in the combined module
MetaData(the allowlist is{t.name for t in metadata.tables.values()}). - Exclude everything else — tables owned by the Alembic runtime (
alembic_version) and any host-owned tables that aren't part of an installed module. - Optionally skip unmodeled foreign-key constraints (
ignore_unmodeled_fks=Trueby default) so migration-level cross-module FKs aren't dropped on every autogen run.
If you write a one-off host-level table that autogenerate shouldn't track, keep it out of the module metadata — make_include_object already excludes anything not in the allowlist.
Naming rules
- Module name must match
ModuleMeta.name.lower(). The framework caches the Base by module name; a mismatch causes silent metadata drift. - Module names should be identifiers:
[a-z][a-z0-9_]*. Hyphens break SQL identifier parsing on some providers. - Don't rename a module after it ships without migrating data. The table prefix is durable across deployments.
Tables across modules
If your module needs to reference another module's table by foreign key, import its model:
# modules/invoices/invoices/models.py
from orders.models import Order
class Invoice(Base, table=True):
__tablename__ = "invoices_invoice"
id: int | None = Field(default=None, primary_key=True)
order_id: int = Field(foreign_key="orders_order.id")Caveats:
- Add
depends_on=["Orders"]inInvoicesModule.meta— modules are loaded in topological order; withoutdepends_on,orders.modelsmight not be imported wheninvoices.modelsruns. - Uninstalling
OrderswhileInvoicesstill references it produces a DB error — cross-module FKs are a commitment.
If you can avoid a hard FK (store order_id: int without the constraint), module lifecycles stay more independent. Prefer application-level validation for loose coupling.
Inspecting at runtime
For debugging, you can dump the registered tables:
from simple_module_db.base import build_module_metadata
meta = build_module_metadata()
for t in meta.sorted_tables:
print(t.name)This is also what the boot-time SM011 check uses — it compares this set against the Alembic history to detect tables that exist in code but not in any migration.