Module System
IOTA SDK’s module system enables modular, composable architecture. Each module is self-contained and can be added or removed without affecting others.
Module Lifecycle
1. Registration Phase
During application startup, each component contributes providers and runtime slices to the composition engine. The engine compiles a container for the active capabilities, attaches it to the application, and starts hooks when the process needs long-lived workers:
Schema/migration files are typically embedded in modules; the application loads migrations from its configured migrations directory (e.g. MigrationsDir), not by modules calling a migration manager during registration.
2. Initialization Phase
Services are wired together via dependency injection:
3. Running Phase
The application serves requests with all modules active:
Module Structure
Required Files
modules/{module}/
├── component.go # Component interface implementation
└── links.go # Navigation definitionStandard Structure
modules/{module}/
├── domain/ # Business logic
│ ├── aggregates/ # Aggregate roots
│ ├── entities/ # Domain entities
│ └── value_objects/ # Value objects
├── infrastructure/
│ └── persistence/ # Data access
│ ├── models/ # Database models
│ ├── schema/ # SQL migrations
│ └── *.go # Repository implementations
├── services/ # Business services
├── presentation/
│ ├── controllers/ # HTTP handlers
│ └── templates/ # Templ files
├── component.go # Component registration
└── links.go # NavigationModule Interface
Runtime composition is built from composition.Component implementations:
Registration Methods
| Method | Purpose | Called In |
|---|---|---|
composition.Provide[T](b, value) | Eager value provider for T | Services layer |
composition.ProvideFunc(b, ctor) | Reflection-injected constructor; params resolved by type | Services layer |
composition.ProvideFuncAs[I](b, ctor) | Same, also bridged to interface key I | Services layer |
composition.ProvideAs[I, C](b, value) | Eager value bound to both concrete and interface keys | Services layer |
composition.ProvideDefault[T](b, value) | Overridable default provider — downstream Provide[T] wins silently | Services layer |
composition.ProvideDefaultAs[I, C](b, value) | Overridable default bound to both concrete and interface keys | Services layer |
composition.RemoveProvider[T](b) | Escape hatch — delete an upstream provider before registering a replacement | Services layer |
composition.RemoveHook(b, name) | Delete an upstream hook whose Name matches | Infrastructure |
composition.ContributeControllers(b, fn) | Closure returning HTTP handlers | Presentation layer |
composition.ContributeControllersFunc(b, ctor) | Reflection-injected controller factory | Presentation layer |
composition.ContributeHooks(b, fn) | Start/stop lifecycle hooks | Infrastructure |
composition.ContributeEventHandler(b, handler) | Auto-subscribed eventbus handler (value-form) | Infrastructure |
composition.ContributeEventHandlerFunc[T](b, factory) | Auto-subscribed handler resolved from a typed service | Infrastructure |
composition.ContributeSchemas(b, fn) | GraphQL schemas built from resolved services | Presentation |
composition.ContributeApplets(b, fn) | Applet manifests for the SDK applet runtime | Presentation |
composition.ContributeMiddleware(b, fn) | Router-level middleware | Presentation |
composition.ContributeSpotlightProviders(b, fn) | Spotlight search providers | Navigation / actions |
composition.ContributeSpotlightAgent(b, fn) | Singleton spotlight AI agent | Navigation / actions |
composition.AddNavItems(b, items...) | Sidebar / navigation entries (legacy, hand-authored) | Presentation |
composition.AddNavNodes(b, nodes...) | Descriptor-backed navigation catalog (grouping nodes) | Presentation |
composition.AddNavProviders(b, providers...) | Request-scoped runtime navigation (tenant/role/data-driven) | Navigation / actions |
composition.AddQuickLinks(b, links...) | Spotlight quick links (legacy; derived nodes generate these automatically) | Navigation / actions |
composition.AddHashFS(b, fs...) | Hashed static assets | Presentation |
composition.AddAssets(b, fs...) | Raw embed.FS asset bundles | Presentation |
composition.AddControllers(b, ctrls...) | Pre-built controller instances | Presentation |
Service Registration
Service Pattern
Services are registered with composition and resolved by type:
Dependency Injection
Services declare dependencies explicitly and resolve them inside provider factories:
Migrating From Module To Component
When migrating old code:
- Replace the old module constructor with
NewComponent() composition.Component. - Move the old wiring phase into
Build(builder). Prefer the reflection helpers (ProvideFunc,ProvideFuncAs[I],ContributeControllersFunc) over closure factories — the injector resolves typed dependencies automatically. - Migrate static contributions (locales, nav items, quick links, hashfs bundles) to the
Add*helpers — zero closure boilerplate. - Core services (
*pgxpool.Pool,eventbus.EventBus,*i18n.Bundle,*logrus.Logger,application.Application,spotlight.Service,application.Huber) are auto-registered byEngine.Compile. Take them as typed parameters inProvideFunc/ContributeControllersFuncconstructors, orcomposition.Resolve[T](container)from inside a Contribute* closure.
Overriding Upstream Components
Downstream components frequently need to swap an upstream-registered service, repository, or controller with a customised implementation. The composition engine exposes two opt-in mechanisms for this:
ProvideDefault[T] — anticipated extension points
When an upstream component knows a provider is a reasonable default but
expects downstream consumers to replace it, register it with
ProvideDefault[T] instead of Provide[T]:
// modules/storage/component.go (upstream)
composition.ProvideDefaultAs[upload.Storage, *fs.Storage](builder, fs.NewStorage)A downstream component that needs a different backend simply registers its own provider for the same key — the engine silently drops the default when the override is installed:
// modules/ali/component.go (downstream, Requires: []string{"storage"})
composition.ProvideFuncAs[upload.Storage](builder, s3.NewStorage)Two ProvideDefault calls for the same key still error loudly (two
components cannot both claim the default slot). Two plain Provide calls
also error, as before.
RemoveProvider[T] — escape hatch for non-default providers
If an upstream component did not mark its provider overridable but you
still need to replace it, use RemoveProvider[T] to explicitly delete the
upstream entry before registering your own:
// modules/custom/component.go (downstream, Requires: []string{"core"})
composition.RemoveProvider[user.Repository](builder)
composition.ProvideFunc(builder, custom.NewUserRepository)RemoveProvider + Provide works inside the same component because the
engine processes the builder’s removals before its own registrations —
so the downstream Provide does not collide with the upstream provider
it just evicted.
Controller and hook overrides
Controllers declare identity, ordering, replacement intent, and route metadata
through Controller.Descriptor(). To override an upstream controller, return a
descriptor with a stable downstream ID and list the upstream controller IDs in
Replaces:
func (c *UsersController) Descriptor() application.ControllerDescriptor {
descriptor := application.Descriptor("custom.users", 0, application.Route("", "/users"))
descriptor.Replaces = []string{"core.users"}
return descriptor
}Hooks are still identified by Hook.Name and can be removed explicitly:
composition.RemoveHook(builder, "crm-client-handler")Controller replacements are applied before duplicate-ID and route-collision
validation, so the replaced controller’s Register method never runs and its
routes never reach the router. Hook removals work as a filter over materialised
hooks and prevent the removed hook’s Start/StopFn from running.
Navigation and route authorization
A descriptor is the single source of truth not just for routes but for the
feature’s navigation presence and authorization. RouteSpec carries an
AuthPolicy, and ControllerDescriptor.Nav declares the sidebar/spotlight
entries the controller exposes:
func (c *ExpenseCategoriesController) Descriptor() application.ControllerDescriptor {
return application.Descriptor("finance.expense_category", 0,
application.Get(c.basePath, application.RequireAll("finance.expense_categories.view")),
).WithNav(application.NavNode{
ID: "finance.expense_categories", Parent: "finance.enums",
TitleKey: "NavigationLinks.ExpenseCategories", Path: c.basePath,
})
}- Authorization is declarative via
application.Public(),application.RequireAll(perms...), orapplication.RequireAny(perms...). TheProvideUsermiddleware enforces it (401 unauthenticated, 403 missing permission), and sidebar visibility plus spotlight quick links derive from the same policy — including theAll/Anycombination logic — so a link is never shown to a user the route would reject. - Projection. A
NavNodewith aPathis a leaf; itsPathmust resolve to a registered route (query strings are ignored when matching). Nodes without aPathare grouping/taxonomy nodes, contributed module-level viacomposition.AddNavNodes. Both project into thetypes.NavigationItemtree and the spotlight quick-link set automatically — do not hand-author those. - Validation is fail-fast at composition: duplicate node IDs, a
Paththat resolves to no route, a same-module orphan parent, or a cycle abort the build. A cross-module orphan (an optional module’s group is absent) is skipped with a warning rather than failing. - Runtime navigation. Tenant-, role-, or data-driven menus (e.g. EDO) are
contributed through
application.NavProviderviacomposition.AddNavProviders; their nodes are validated against the route registry when emitted and dropped with a log on invalid output — never a panic.
Ordering guarantees
Override and removal semantics depend on topological ordering of component
builders. A downstream component that overrides an upstream provider must
declare Requires: []string{upstreamName} in its Descriptor so the
engine runs the upstream build first. Without it, the upstream provider may
be added to the container after the downstream removal pass has executed,
leaving the upstream entry in place and the override silently ineffective.
| Scenario | Outcome |
|---|---|
ProvideDefault + downstream Provide (with Requires) | Downstream wins silently |
ProvideDefault + downstream Provide (without Requires) | Undefined — may silently keep upstream |
Provide + downstream Provide | Error — “duplicate provider” |
Two ProvideDefault for the same key | Error — “duplicate default provider” |
RemoveProvider[T] for a key never provided | No-op |
ControllerDescriptor.Replaces for an unknown ID | Error |
Controller Registration
Routing
Controllers define their base path during registration:
Permission Integration
Controllers check permissions via middleware:
Cross-Module Communication
Service Access
Modules access other modules via typed container lookups:
Event-Driven Communication
Loose coupling via domain events:
Module Configuration
Module Options
Components accept configuration via options pattern:
Environment-Based Config
Configuration can vary by environment:
| Environment | Configuration | Purpose |
|---|---|---|
| Development | Debug logging, test endpoints | Development |
| Staging | Production-like, limited data | Testing |
| Production | Optimized, no debug info | Production |
Best Practices
- Single Responsibility - Each module handles one business domain
- Explicit Dependencies - Declare all required services
- No Circular Dependencies - Module A shouldn’t depend on B if B depends on A
- Event-Based Coupling - Use events for loose coupling
- Interface Segregation - Small, focused service interfaces
Built-in Modules
Built-in components are loaded from modules.Components() (see modules/load.go): core, hrm, finance, projects, logging, warehouse, crm, website, billing, oidc, and testkit. BiChat and Superadmin are registered separately by the command roots that need them.
Next Steps
- Multi-Tenancy - How modules handle tenant isolation
- Domain-Driven Design - DDD patterns in modules
- Core Module - See a complete module implementation