Module System
How OGMF features are packaged as self-contained modules, discovered at runtime, filtered by license, and merged into a single registry that drives routing, navigation, and slot-based extension.
Introduction
OGMF is assembled at runtime from modules. Every feature — CRM, workshop, entities, the dev playground — lives under src/modules/{domain}/ as a self-contained unit that owns its own components, stores, context, hooks, services, and types. The rule is deliberate and load-bearing: code MUST live in the module that owns it. There is no shared "components" dumping ground for feature code, and no module reaches into another module's internals.
A module does not register itself imperatively. Instead it exports a declarative description of what it contributes to the app — its pages, its sidebar entries, its extension points — and the runtime does the wiring. The app boots, discovers every module on disk, drops the ones the current user is not licensed for, and folds the survivors into one registry that the router and navigation read from.
A module is a unit of ownership and licensing, not just a folder. Two modules can collaborate through declared imports, but neither owns the other's code, and a module the user is not licensed for never even loads.
The contract
A module is a single default-exported ModuleDefinition, declared in a module.ts file at the module root. The definition is intentionally small — four fields, three of them optional:
| Field | Type | Purpose |
|---|---|---|
id | string | Unique identifier. MUST match the folder name (src/modules/{id}/), because discovery derives the id from the path and filtering matches it against the license list. |
routes | RouteObject[] (optional) | React Router route objects. Omit if the module has no pages of its own. |
sidebar | SidebarItem[] (optional) | Navigation entries this module adds to the shared sidebar tree. |
slots | SlotContribution[] (optional) | Contributions this module makes to other modules' extension points. |
The definition is authored with satisfies ModuleDefinition — not a type annotation — so each field keeps its precise literal type (the exact id string, the concrete sidebar tree) while still being checked against the contract. This is how a typo in a field name is a compile error without widening id to string or routes to RouteObject[].
import type { ModuleDefinition } from "@/core";
import { routes } from "./routes";
import { ContactRound } from "lucide-react";
export default {
id: "crm",
routes,
sidebar: [
{
label: "crm",
icon: ContactRound,
order: 20,
privilege: "crm",
children: [
{ label: "painelresumo", to: "/crm/dashboard", privilege: "crm:painelresumo", order: 10 },
{ label: "agenda", to: "/crm/agenda", privilege: "crm:agenda", order: 20 },
],
},
],
} satisfies ModuleDefinition;The ModuleDefinition type, the SidebarItem and SlotContribution shapes, and the slotContribution helper are all exported from @/core. See Anatomy for the full authoring guide.
The lifecycle pipeline
From source files to a running app, a module passes through one pipeline:
Modules are discovered by import.meta.glob, which Vite resolves at build time into a map of file path → lazy import for every @/modules/**/module.ts and @/addons/**/module.ts. That map is filtered by license inside createRegistry: the module id is extracted from each path and the import only runs if the id is in the user's licensedModules set (sourced from the login API via window.OG.modules). Only the licensed modules' code is ever fetched. The surviving definitions are then merged — every module's routes are concatenated, every module's sidebar items are flattened and sorted by order, and every slots contribution is grouped by slot name and sorted by order — into a single ModuleRegistry. The app reads from that registry: RegistryProvider builds it once and exposes it through useModuleRoutes, useSidebar, and useRegistry.
Two roots feed discovery, not one: built-in modules (@/modules/) and licensed addons (@/addons/). Both follow the identical {id}/module.ts convention, and extractModuleId handles both path shapes, so an addon is just a module that ships separately.
Filtering happens before import, by set membership. A module the user is not licensed for is never imported, so its code never enters the bundle's executed path — licensing is also a code-loading boundary, not merely a visibility toggle.
See Discovery & Licensing for the glob patterns, the id-extraction rule, duplicate/missing-id handling, and the provider + hooks.
Two extensibility patterns
OGMF has exactly two ways for one module's behavior to reach another, and choosing the right one is an architectural decision, not a style preference.
1. Declared module-to-module dependencies. When a module needs a specific, known component or service from another module, it imports it directly. The workshop module importing a customer combobox from entities is a declared, deliberate dependency: the consumer knows exactly what it wants and from whom. This is normal, encouraged, and fully type-checked — there is no indirection layer to go through.
2. The slot system. When a module wants to expose a place where any module may contribute, without the owner knowing — or wanting to know — who will fill it, it renders a named <Slot>. Other modules contribute to that slot from their module.ts via slotContribution(...). The owner depends on the slot name and its contribution contract, never on the contributors. This is the open/closed boundary: the customer detail page can grow new sections from unrelated modules without a single edit to the page.
Import directly when the consumer knows the specific thing it needs — that is a declared dependency between two named modules. Use a slot when the owner exposes a place and does not, and should not, know who will fill it. Direct imports are for known collaborators; slots are for unknown extensibility. Reaching for a slot to wire two modules you already know about adds indirection for no benefit; reaching for a direct import where the owner shouldn't know its extenders breaks the boundary.
See Slots for <Slot>, the slotContribution helper, and the contribution contract.
Section map
Anatomy
The ModuleDefinition shape, the satisfies pattern, and authoring a module.ts.
Discovery & Licensing
Glob discovery across modules and addons, license filtering, the merged registry, and the provider + hooks.
Routing
How a module's RouteObject[] joins the app route tree under the shared layout shell.
Navigation
The SidebarItem tree, privilege gating, and how items are merged and sorted by order.
Slots
The <Slot> render point, slotContribution, and letting any module extend a place.
The module system is the structural counterpart to the Form Builder — where the form builder is shared infrastructure that every module uses, the module system is the boundary that decides what a module is, when its code loads, and how it reaches the rest of the app.
OGMF
The OfficeGest Modular Frontend — the OfficeGest web client, embedded as a web component and built from self-contained modules.
Anatomy of a Module
The ModuleDefinition contract every OGMF module exports — its members, the satisfies authoring pattern, and worked examples from the workshop and crm modules.