Module Authoring Guide
Монгол
·
English
Welcome to the Gerege Nexus Module Authoring Guide! This guide explains how external developers can write, register, and distribute custom business application modules for the platform.
Module architecture overview
In Gerege Nexus, business modules are written in Go as compile-time
packages under backend/internal/apps/. (The Go module path
stays github.com/gerege-systems/open-gerege-mn-erp/backend
— an identifier, not a brand.)
Every module MUST implement the Module interface defined
in backend/internal/module.go:
type Module interface {
ID() string
Name() string
Version() string
Dependencies() []Dependency
Permissions() []PermissionDefinition
Menus() []MenuDefinition
RegisterRoutes(r chi.Router, tenantAuthMiddleware func(http.Handler) http.Handler)
}
Quick start: scaffold a module in one command
make new-app slug=helpdesk name="Helpdesk" name_mn="Тусламжийн төв"
# or: ./scripts/new-app.sh helpdesk "Helpdesk" "Тусламжийн төв"
This generates the backend module (full Module
implementation with tenant-scoped CRUD), the goose migration, the
manifest, the catalog/apps.json entry with
translations.mn, and a frontend page — and wires the module
into apps.Build
(backend/internal/apps/runtime.go) and the menu blueprints
automatically (with a printed manual instruction if an anchor is not
found). Then run the migration and restart the API. The steps below
explain what the generator produces.
Step by step: creating a new module
Step 1:
Define Module Struct & Register in appregistry
Create a new directory
backend/internal/apps/invoices/invoices.go.
The module type is unexported (module,
not Module): what the outside may see is the constructor
and the internal.Module interface methods, and nothing
else. New returning an unexported type is fine — the
composition root (apps/runtime.go) holds the value as an
internal.Module. The rule is held by
internal/architecture/export_surface_test.go.
package invoices
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/gerege-systems/open-gerege-mn-erp/backend/internal"
"github.com/gerege-systems/open-gerege-mn-erp/backend/internal/platform/appregistry"
)
type module struct {
db *pgxpool.Pool
}
func New(db *pgxpool.Pool) *module {
m := &module{db: db}
appregistry.Register(m)
return m
}
func (m *module) ID() string { return "io.example.invoices" }
func (m *module) Name() string { return "Invoicing & Billing" }
func (m *module) Version() string { return "1.0.0" }
func (m *module) Dependencies() []internal.Dependency {
return []internal.Dependency{
{ID: "io.example.contacts", VersionConstraint: "^1.0.0"},
{ID: "io.example.products", VersionConstraint: "^1.0.0"},
}
}
Step 2: Define Permissions and Menus
func (m *module) Permissions() []internal.PermissionDefinition {
return []internal.PermissionDefinition{
{Code: "invoices.read", Name: "View Invoices"},
{Code: "invoices.manage", Name: "Create & Edit Invoices"},
}
}
func (m *module) Menus() []internal.MenuDefinition {
return []internal.MenuDefinition{
{ID: "menu_invoices", Label: "Invoices", Path: "/invoices", Icon: "file-text", Order: 30},
}
}
Step 3: Register HTTP Routes with App Gate Middleware
The middleware handed to RegisterRoutes is the
platform's app gate: session authentication + an
app_installations check for the tenant + the default
permission mapping (GET/HEAD → <prefix>.read, other
methods → <prefix>.manage).
func (m *module) RegisterRoutes(r chi.Router, tenantAuthMiddleware func(http.Handler) http.Handler) {
r.Route("/api/v1/invoices", func(sub chi.Router) {
sub.Use(tenantAuthMiddleware)
sub.Get("/", m.handleListInvoices)
sub.Post("/", m.handleCreateInvoice)
})
}
Public routes, and the rule about them
RegisterRoutes receives the root
router, not a pre-gated group. Mounting a path outside
tenantAuthMiddleware is one line and looks exactly like
mounting one inside it.
That is deliberate: a module may need to serve a caller who holds no session. The cost is that a private route can become public by accident, and nothing in the diff would say so. So the rule is:
A route reachable without a session must be named in
publicRoutesinbackend/internal/platform/route_policy_test.go.
The test walks the real routing table, calls every route with no
credentials, and fails on anything that answers 200 or
201 without being on the list. It also fails on a name in
the list that nothing serves any more, so a renamed route cannot leave
an entry behind that quietly widens the next route to take its name.
Adding a name is then a visible act in a review. When you add one, say in a comment beside it which authority the route relies on instead of a session (a signature, a client secret, a single-use token).
Step 3.5: Add a database migration
If your module owns tables, add a goose migration under
backend/db/migrations/control/000NN_<module>.sql
(next free number, both -- +goose Up and
-- +goose Down sections). Runtime DDL is forbidden — the
schema must come from migrations only. Scope every table with a
tenant_id column and put it in the unique constraints
(UNIQUE (tenant_id, ...)).
During the schema-per-tenant transition (Phase A) a business table
lives in two places: control/ (the historical
public copy) and db/migrations/tenant/ (the
line applied to every tenant schema). When adding or altering a business
table, add the same DDL to tenant/ as a NEW numbered
migration. Never edit the 0001_tenant_init.sql
baseline.
No foreign keys into public. A tenant
schema has to stand on its own: tenant_id and
created_by simply hold control-plane ids. One such key puts
EVERY schema in the database into the lock set of any statement touching
public.tenants — one delete takes fifteen thousand locks at
a hundred workspaces — and deadlocks against provisioning.
tenant/0005 has the full reasoning.
Step 4: Create App Manifest JSON
Add a manifest file in
catalog/manifests/invoices.json:
{
"id": "io.example.invoices",
"name": "Invoices",
"version": "1.0.0",
"platform": ">=0.1.0 <2.0.0",
"dependencies": [
{ "id": "io.example.contacts", "version_constraint": "^1.0.0" },
{ "id": "io.example.products", "version_constraint": "^1.0.0" }
]
}
The field names must match appcatalog.Manifest
exactly:
| Field | Type | Notes |
|---|---|---|
id |
string | Must equal the id of the matching
catalog/apps.json entry |
version |
string | Valid semver |
platform |
string | Semver constraint checked against the platform version
(1.0.0) |
dependencies |
array of {id, version_constraint} |
Not an object — {} fails to parse |
permissions |
array of objects
{code, name, description} |
Only with kind: "external". A compiled
app declaring these is refused at boot — see the note below |
kind |
internal (default) or external |
Empty means internal — everything else in this guide is
about those |
external |
Object — only with kind: "external" |
See §External apps below |
The file name must be
catalog/manifests/<slug>.json, where
<slug> is the slug used in
catalog/apps.json (lowercase letters, digits,
- and _). A manifest that fails to load or
whose id disagrees with the catalog entry is a
startup error — the server refuses to boot rather than
silently installing the app with an empty dependency, permission and
menu set.
Permissions and menus are NOT in the manifest. A compiled app's permissions come from
Permissions()and its menus fromMenus()— the code that enforces a permission is the code that should declare it.The manifest used to carry both, and this guide used to say "keep the two in sync". That advice could not work: nothing read the manifest's copy, so a divergence was invisible, and all ten manifests ended up with a menu order that disagreed with their module's (billing said 40, the code said 20). The
menusfield is gone, and a compiled app declaringpermissionsis now refused byValidateManifestat boot — the only reliable way to keep two copies in sync is not to have a second one.An external app is the opposite case: it has no Go module, so its manifest is the only statement there is, and
permissionsare required there.
External apps
(kind: "external")
An app does not have to be compiled into this binary. With
kind set to external the app runs somewhere
else and talks to the platform over OAuth2/OIDC; the manifest says where
the tile points, which redirects it has, and what it is asking for:
{
"id": "com.vendor.crm",
"kind": "external",
"external": {
"launch_url": "https://crm.vendor.example/app",
"redirect_uris": ["https://crm.vendor.example/oidc/callback"],
"scopes": ["openid", "contacts.read"],
"event_types": ["billing.invoice.created"],
"webhook_url": "https://crm.vendor.example/hooks/gerege"
}
}
Validation happens where the manifest is read — at boot — rather than being discovered later by somebody staring at a redirect that goes nowhere:
launch_urlmust be absolute (an app running elsewhere is the definition)- HTTPS is required; only loopback (
localhost,127.0.0.1,::1) is excepted, so a developer can point the catalog at their laptop - at least one
redirect_urisentry — otherwise the OAuth client has nowhere to return to event_typeswithout awebhook_urlis refused: the subscription would have nowhere to deliver- an external app may not declare module
dependencies; it calls the API and cannot be ordered against the module graph
The installer looks for no compiled module for these, and takes their permissions from the manifest.
Step 5: Register the
app in catalog/apps.json
Add an entry to catalog/apps.json to index the new app
in the App Store. The apps database table is synchronised
from that file on every boot, so no manual SQL is required. Each entry
carries these fields:
{
"id": "io.example.invoices",
"slug": "invoices",
"name": "Invoices",
"description": "Invoice management",
"icon_url": "/icons/invoices.png",
"category": "Finance",
"visibility": "public",
"version": "1.0.0",
"translations": {
"mn": {
"name": "Нэхэмжлэх",
"description": "Нэхэмжлэхийн удирдлага",
"category": "Санхүү"
}
}
}
The translations.mn block is what makes the App Store
Mongolian-first — do not omit it. id must equal the
manifest's id, and slug must equal the
manifest's file name.
Step 6: Wire up the composition point and the frontend
Instantiate the module once in
apps.Build(backend/internal/apps/runtime.go) alongside the other modules and add it to the slice it returns — the constructor self-registers inappregistry. There is no route wiring to write: the server mounts every module behind the app gate keyed on its ownID().The platform does not import the apps.
internal/platformknows only theinternal.Moduleinterface; the process composes the set (cmd/apicallsplatform.NewServer(db, catalogPath, platform.WithModules(apps.Build))). That is what keeps a module extractable without surgery on the core, andbackend/internal/architecture/platform_boundary_test.goguards it — an app import anywhere underinternal/platformfails the test.Register a menu blueprint in
backend/internal/platform/menu/(blueprintsmap) if your app should show grouped sidebar menus. A missing blueprint means the sidebar menu for your app does not render — this exact bug shipped once (commitc8e8c25, esign module).Add the frontend page: the app root at
frontend/app/<module>/page.tsx. Blueprint sub-screens resolve through the dynamicfrontend/app/module/[app]/[feature]/route; paths without a real page show a "coming soon" placeholder.
Talking to other modules
A module that needs another module's data must go through its
exported service API, never its tables. See
docs/RESEARCH_ERP_MODULARITY.md for why every ERP converges
on this.
The consumer declares the narrow interface it needs at the call site; the composition happens in the module's own constructor:
// billing declares only what it uses — not contacts' whole surface.
type contactLookup interface {
Get(ctx context.Context, tenantID, id string) (*contacts.Contact, error)
}
func New(db *pgxpool.Pool) *billingModule {
m := &billingModule{db: db, contacts: contacts.NewService(db)}
appregistry.Register(m)
return m
}
Rules that follow from that:
- Reference by ID, with a foreign key. Store
contact_id, not a copy of the contact. - Snapshot what is legally significant. An invoice
must show the party as it was at issuance, so billing copies the
resolved name into
contact_name— and a later rename never rewrites the document. - Dependency direction is one-way.
billing → contactsis declared in the manifest;contactsmust not learn aboutbilling. - Master-data modules stay at the bottom.
contacts,productsand friends exportGetplus anErrNotFoundsentinel and depend on nothing above them, which keeps the module graph acyclic by construction.
Translations
Every user-facing string belongs in
frontend/src/lib/i18n/addons/<app>.ts with keys
shaped <module>.<kind>.<term>. Never
write a literal in a component, and never branch on the locale in code.
See TRANSLATION_GUIDE.md.
Backend menu labels carry their Mongolian text in the
Labels map of the MenuDefinition, and
app-store copy lives in catalog/apps.json under
translations.mn.
Maintainers
- Gerege Systems Development Team (@gerege-systems)
- Gemini AI, Claude AI