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.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.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 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 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.

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...), or application.RequireAny(perms...). The ProvideUser middleware enforces it (401 unauthenticated, 403 missing permission), and sidebar visibility plus spotlight quick links derive from the same policy — including the All/Any combination logic — so a link is never shown to a user the route would reject.
  • Projection. A NavNode with a Path is a leaf; its Path must resolve to a registered route (query strings are ignored when matching). Nodes without a Path are grouping/taxonomy nodes, contributed module-level via composition.AddNavNodes. Both project into the types.NavigationItem tree and the spotlight quick-link set automatically — do not hand-author those.
  • Validation is fail-fast at composition: duplicate node IDs, a Path that 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.NavProvider via composition.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.

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
ControllerDescriptor.Replaces for an unknown IDError

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