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.RemoveController(b, key) | Delete an upstream controller whose Key() matches | Presentation 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.AddLocales(b, fs) | Translation bundles (no closure) | Presentation |
composition.AddNavItems(b, items...) | Sidebar / navigation entries | Presentation |
composition.AddQuickLinks(b, links...) | Spotlight quick links | 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 and hooks are identified by Controller.Key() and Hook.Name
respectively, not by type. Use the string-keyed removal helpers:
composition.RemoveController(builder, "/users")
composition.RemoveHook(builder, "crm-client-handler")Controller removals are applied as a filter over the materialised controller
list — the evicted controller’s Register method never runs and its route
never reaches the router. Hook removals work the same way and prevent the
removed hook’s Start/StopFn from running.
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 |
RemoveController(key) for an unknown key | No-op |
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