Skip to main content

The Module interface

Every Orkestra module — the eight core ones included — implements the same contract. It is deliberately tiny:

type Module interface {
// Name returns the unique identifier (e.g. "billing", "sales").
Name() string
// Category returns whether this module is core, toggleable, or external.
Category() ModuleCategory
// Init initializes repositories, services, and handlers.
Init(deps *Dependencies) error
}

Three methods. Everything else a module can do — routes, background work, config, collections, nav entries, permissions — is an optional sub-interface, discovered by type assertion at runtime.

That shape is a deliberate constraint rather than minimalism for its own sake. This interface is the public SDK surface, so every method on it is frozen: widening Module later would break every addon written against it. New capabilities have to arrive as new sub-interfaces, which is a change no existing module has to notice.

Category

ValueMeaning
CategoryCoreAlways active, cannot be disabled. An init failure is fatal.
CategoryToggleableEnable and disable from the admin UI; no external dependencies.
CategoryExternalNeeds external service credentials to function.

Dependencies — what Init receives

type Dependencies struct {
DB *mongo.Database
RedisAdapter RedisClient
Platform PlatformInfo
Logger *slog.Logger
Services *ServiceRegistry
ConfigService *ModuleConfigService
}

Init is where you construct repositories, services, and handlers, resolve whatever you need from the ServiceRegistry, and register what you provide. It is not where you start background work — that is Start.

The optional sub-interfaces

Implement only what you need. A pure service provider with no HTTP surface implements none of the routing ones; a module with no background work implements none of the lifecycle ones.

Lifecycle

InterfaceMethodCalled
RoutableRegisterRoutes(ri *RouteInfo)At boot, for every module — including disabled ones, whose routes are then gated to 503
StartableStart(ctx) errorAfter Init, for enabled modules only — and again on each hot-enable
StoppableStop(ctx) errorOn hot-disable and on host shutdown
HealthCheckableHealthCheck(ctx) errorPolled by the module health endpoint

Start and Stop are called per enable and disable, not only at boot. A module toggled at /admin/modules starts or stops immediately, so both must be safe to call more than once over a process lifetime.

Declarations

InterfaceMethodPurpose
HasDependenciesDependencies() []stringModule names this one needs. The registry topologically sorts on this, so init order is always correct.
HasServiceContractsProvidedServices(), RequiredServices(), OptionalServices()The registry keys this module publishes and consumes
HasCollectionsCollections() []CollectionSpecMongoDB collections, auto-created with their indexes at boot
HasConfigSchemaConfigSchema() []ConfigFieldAdmin-editable fields. Seeded from each field's EnvVar or default at first boot; the admin UI renders the form from this.
HasConfigGroupsConfigGroups() []ConfigGroupPresentation grouping for those fields. Purely cosmetic — omitting it renders a flat form.
HasPermissionsPermissions() []PermissionSpecPermission keys, collected into the authz catalog at boot
HasNavItemsNavItems() []NavItemSpecSidebar entries for the navigation aggregator
HasNotificationTemplatesNotificationTemplates() []NotificationTemplateSpecDefault email templates
HasCapabilitiesCapabilities() []CapabilityEntitlement-gated capabilities
HasDisplayInfoDisplayName(), Description()Human-readable labels for the admin UI
HasDefaultEnabledEnabled() boolWhether a fresh install starts with this module on
HasInfraContainersInfraContainers() []InfraContainerSpecDocker containers the registry starts before Start and stops after Stop
HasPreflightPreflight(ctx) errorA pre-init check that can refuse a bad configuration early

BaseModule

In-tree modules embed BaseModule, which implements every sub-interface with a sensible default — empty slices, no-op lifecycle, CategoryCore. Embed it and override only the methods you care about, and every type assertion still succeeds.

An addon written outside the monorepo can skip it and implement just the sub-interfaces it wants. The registry handles both shapes identically.

The order things happen in

  1. Every module is constructed from the catalog.
  2. The registry topologically sorts them by Dependencies().
  3. Nav items are collected and stamped with their owning module — before any Init runs, which is why navigation sees the full set during its own Init.
  4. Init runs in dependency order.
  5. The union of every Permissions() is registered, and the system roles are seeded from the now-complete catalog.
  6. RegisterRoutes runs for every module, enabled or not.
  7. Start runs for enabled modules only.

Two consequences worth internalizing: a disabled module is still initialized and still has routes — they are gated, not absent — and anything you declare is read before you are initialized, so a declaration method cannot depend on state that Init sets up. If a nav entry needs to appear conditionally, emit it unconditionally and let RequiresConfig gate it.

Common mistakes

  • Starting goroutines in Init. They will run for a module that is disabled. Use Start.
  • Reading config in a declaration method. ConfigSchema and NavItems are called before Init; there is nothing to read yet.
  • Importing another module's services/ or repository/ package. Always go through pkg/sdk/iface plus a registry lookup. This is enforced by review and is the one rule that keeps modules independently removable.
  • Assuming Stop is only for shutdown. It runs on every hot-disable.

Ready to build one? See Build your first addon.