Skip to Content
ArchitectureModule System

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 definition

Standard 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 # Navigation

Module Interface

Runtime composition is built from composition.Component implementations:

Registration Methods

MethodPurposeCalled In
composition.Provide[T](b, value)Eager value provider for TServices layer
composition.ProvideFunc(b, ctor)Reflection-injected constructor; params resolved by typeServices layer
composition.ProvideFuncAs[I](b, ctor)Same, also bridged to interface key IServices layer
composition.ProvideAs[I, C](b, value)Eager value bound to both concrete and interface keysServices layer
composition.ProvideDefault[T](b, value)Overridable default provider — downstream Provide[T] wins silentlyServices layer
composition.ProvideDefaultAs[I, C](b, value)Overridable default bound to both concrete and interface keysServices layer
composition.RemoveProvider[T](b)Escape hatch — delete an upstream provider before registering a replacementServices layer
composition.RemoveController(b, key)Delete an upstream controller whose Key() matchesPresentation layer
composition.RemoveHook(b, name)Delete an upstream hook whose Name matchesInfrastructure
composition.ContributeControllers(b, fn)Closure returning HTTP handlersPresentation layer
composition.ContributeControllersFunc(b, ctor)Reflection-injected controller factoryPresentation layer
composition.ContributeHooks(b, fn)Start/stop lifecycle hooksInfrastructure
composition.ContributeEventHandler(b, handler)Auto-subscribed eventbus handler (value-form)Infrastructure
composition.ContributeEventHandlerFunc[T](b, factory)Auto-subscribed handler resolved from a typed serviceInfrastructure
composition.ContributeSchemas(b, fn)GraphQL schemas built from resolved servicesPresentation
composition.ContributeApplets(b, fn)Applet manifests for the SDK applet runtimePresentation
composition.ContributeMiddleware(b, fn)Router-level middlewarePresentation
composition.ContributeSpotlightProviders(b, fn)Spotlight search providersNavigation / actions
composition.ContributeSpotlightAgent(b, fn)Singleton spotlight AI agentNavigation / actions
composition.AddLocales(b, fs)Translation bundles (no closure)Presentation
composition.AddNavItems(b, items...)Sidebar / navigation entriesPresentation
composition.AddQuickLinks(b, links...)Spotlight quick linksNavigation / actions
composition.AddHashFS(b, fs...)Hashed static assetsPresentation
composition.AddAssets(b, fs...)Raw embed.FS asset bundlesPresentation
composition.AddControllers(b, ctrls...)Pre-built controller instancesPresentation

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:

  1. Replace the old module constructor with NewComponent() composition.Component.
  2. 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.
  3. Migrate static contributions (locales, nav items, quick links, hashfs bundles) to the Add* helpers — zero closure boilerplate.
  4. Core services (*pgxpool.Pool, eventbus.EventBus, *i18n.Bundle, *logrus.Logger, application.Application, spotlight.Service, application.Huber) are auto-registered by Engine.Compile. Take them as typed parameters in ProvideFunc / ContributeControllersFunc constructors, or composition.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.

ScenarioOutcome
ProvideDefault + downstream Provide (with Requires)Downstream wins silently
ProvideDefault + downstream Provide (without Requires)Undefined — may silently keep upstream
Provide + downstream ProvideError — “duplicate provider”
Two ProvideDefault for the same keyError — “duplicate default provider”
RemoveProvider[T] for a key never providedNo-op
RemoveController(key) for an unknown keyNo-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:

EnvironmentConfigurationPurpose
DevelopmentDebug logging, test endpointsDevelopment
StagingProduction-like, limited dataTesting
ProductionOptimized, no debug infoProduction

Best Practices

  1. Single Responsibility - Each module handles one business domain
  2. Explicit Dependencies - Declare all required services
  3. No Circular Dependencies - Module A shouldn’t depend on B if B depends on A
  4. Event-Based Coupling - Use events for loose coupling
  5. 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

Last updated on