Factorizer/Cluster ERP WikiCluster ERP Türkçe
Home / Contributing

Tutorial

Write a backend module

A guided walk from an empty folder to a registered Cluster ERP backend module with models, routes, metadata, and EventBus handlers.

This tutorial takes a developer from an empty folder under src/cluster_erp/modules/ to a module that the server loads, that serves routes, that publishes metadata to the clients, and that talks to other modules over the EventBus. It uses the small records module as the worked example. It does not cover web screens in detail; for those see add a screen with metadata.

What you will build

At the end you have a package src/cluster_erp/modules/<module_name>/ with a module.py whose class Module subclasses ModuleBase. The server discovers it at start-up, calls its lifecycle methods, mounts its routes under /api/v1/<module_name>, and includes its metadata in /api/v1/_meta. The architecture tests pass. Background on the contract is on module lifecycle.

The sequence diagram shows the calls the server makes into a module.

sequenceDiagram
 participant App as server/app.py lifespan
 participant Reg as ModuleRegistry
 participant Mod as Module
 App->>Reg: load_all(app, bus, database, enabled_modules)
 Reg->>Mod: initialize(database)
 Reg->>Mod: register_routes(app)
 Reg->>Mod: register_events(bus)
 App->>Mod: get_metadata() for /api/v1/_meta
 App->>Reg: shutdown_all()
 Reg->>Mod: shutdown()

In prose: at start-up the lifespan calls load_all with the list of enabled modules (src/cluster_erp/server/app.py:140). For each name the registry imports cluster_erp.modules.<name>.module, creates Module(), and calls initialize, register_routes, and register_events in that order (src/cluster_erp/core/module_registry.py:39-52). The metadata route reads get_metadata of every loaded module through effective_module_meta (src/cluster_erp/server/app.py:1537-1538). At shutdown the registry calls shutdown on every module and logs, but does not raise, an error (src/cluster_erp/core/module_registry.py:74-80).

Before you start

  • A working checkout and database, as in local development setup.
  • Python 3.12 or later (pyproject.toml:9).
  • The development tools from the dev extra: mypy, pytest, and Ruff (pyproject.toml:79-84).

Step 1: Create the package

Create src/cluster_erp/modules/<module_name>/ with an __init__.py. The registry treats every package under cluster_erp.modules as a module candidate (src/cluster_erp/core/module_registry.py:26-30). The folder name becomes the module name that the boundary test checks (tests/test_architecture_boundaries.py:25-27).

Keep one sub-folder per sub-module, each with models.py, schemas.py, services.py, and routes.py, as in src/cluster_erp/modules/records/deactivation_reasons/. Add a top-level models.py that imports every sub-module model. Alembic imports exactly that file for each module (migrations/env.py:76-85); src/cluster_erp/modules/records/models.py is an example.

Step 2: Define the models

Subclass BaseModel from cluster_erp.core.models. You then get a ULID primary key, the tenant column, the audit columns, the soft-delete columns, and the version counter (src/cluster_erp/core/models.py:17-78). Declare uniqueness only on live rows with a partial index, as DeactivationReason does (src/cluster_erp/modules/records/deactivation_reasons/models.py:16-25). Money columns use Numeric and Decimal; see money and Decimal.

Step 3: Add the migration

The business schema comes only from the Alembic history; a module never creates tables itself. Add a revision under migrations/versions/. The procedure, the naming, and the checks are on database migrations. CI applies the whole history to an empty database and requires exactly one head (.github/workflows/ci.yml:113-117).

Step 4: Write schemas, services, and routes

Put business logic in a service class that takes an AsyncSession, for example DeactivationReasonService. Routes create the service and validate input with Pydantic schemas. Guard each route with require_permission, the one import from another module that the boundary test allows (src/cluster_erp/modules/records/deactivation_reasons/routes.py:10, src/cluster_erp/modules/records/deactivation_reasons/routes.py:24-36, tests/test_architecture_boundaries.py:24).

Permission codes of the form <entity>.create, .read, .update, and .delete are seeded at start-up for every entity in the module metadata (src/cluster_erp/server/app.py:642-652, src/cluster_erp/modules/auth/permissions/services.py:44-54). The details are on roles and permissions.

Step 5: Write module.py

Create module.py with a class named exactly Module. The registry raises ModuleLoadError when that class is missing (src/cluster_erp/core/module_registry.py:45-47). Implement the abstract members name, version, description, initialize, register_routes, register_events, shutdown, and get_metadata (src/cluster_erp/core/module_base.py:24-74).

From src/cluster_erp/modules/records/module.py:36-55:

    async def initialize(self, database: Database) -> None:
        import cluster_erp.modules.records.models  # noqa: F401

    def register_routes(self, app: FastAPI) -> None:
        from cluster_erp.modules.records.deactivation_reasons.routes import (
            router as reasons_router,
        )
        from cluster_erp.modules.records.lifecycle_routes import (
            router as lifecycle_router,
        )
        from cluster_erp.modules.records.retention.routes import (
            router as retention_router,
        )

        app.include_router(reasons_router, prefix=self.api_prefix)
        app.include_router(lifecycle_router, prefix=self.api_prefix)
        app.include_router(retention_router, prefix=self.api_prefix)

    def register_events(self, bus: EventBus) -> None:
        pass

api_prefix defaults to /api/v1/<name> (src/cluster_erp/core/module_base.py:36-38). The architecture test requires at least one route decorator in the module (tests/test_architecture_boundaries.py:63-72).

Step 6: Declare the metadata

get_metadata returns a ModuleMeta with the module's name, icon, sidebar, and entities (src/cluster_erp/core/metadata.py:719-740). The records module returns one entity and an empty sidebar (src/cluster_erp/modules/records/module.py:60-76). The web app and the desktop client build their lists, forms, and cards from this object; see metadata model. tests/test_metadata_driven_rules.py calls get_metadata on every discovered module, so the method must work without a database (tests/test_metadata_driven_rules.py:23-29).

Step 7: Connect to other modules

Do not import another module. To react to something, subscribe to an Event in register_events; to ask for data, send a Query that exactly one module answers (src/cluster_erp/core/event_bus.py:55-102). New contracts go into src/cluster_erp/core/contracts.py as frozen dataclasses. The products_and_services module shows both patterns: it registers query handlers and subscribes to an event (src/cluster_erp/modules/products_and_services/module.py:131-147). Inside a handler, use request_database() so the handler reads the current tenant's database (src/cluster_erp/core/module_base.py:40-59).

Step 8: Enable the module

The lifespan loads only the names in enabled_modules (src/cluster_erp/server/app.py:140). The default list holds 28 names (src/cluster_erp/config/settings.py:539-568). Add the new name there, or set the list through the environment as described on settings reference. A name in the list without a package is skipped with a warning (src/cluster_erp/core/module_registry.py:68-71).

Check your result

Run the gates that CI runs, from the repository root:

python -m ruff check src tests
python -m mypy src
python -m pytest -q --no-header -p no:cacheprovider

Source: .github/workflows/ci.yml:51-52, .github/workflows/ci.yml:124. Expected result, derived from tests/test_architecture_boundaries.py:46-72: the module appears in no offender list, and it is not reported as missing a module.py or an API. At start-up the server logs the module in its "Loaded modules" line (src/cluster_erp/server/app.py:141-144).

What you learned

Next steps


Verified against commit 92f8051 on 2026-11-13.

Verified against commit 92f8051 · 2026-11-13