Modules are the core organizational unit in modkit. They define boundaries, declare dependencies, and control what's visible to other parts of your app.
Every module implements the Module interface:
type Module interface {
Definition() ModuleDef
}ModuleDef has four key fields:
| Field | Purpose |
|---|---|
Name |
Unique identifier for the module |
Imports |
Other modules this module depends on |
Providers |
Services created in this module |
Controllers |
HTTP controllers created in this module |
Exports |
Tokens visible to modules that import this one |
type AppModule struct {
db *DatabaseModule
}
func (m *AppModule) Definition() module.ModuleDef {
return module.ModuleDef{
Name: "app",
Imports: []module.Module{m.db},
Providers: []module.ProviderDef{
{Token: TokenService, Build: buildService},
},
Controllers: []module.ControllerDef{
{Name: "AppController", Build: buildController},
},
Exports: []module.Token{TokenService},
}
}Modules must be passed as pointers to ensure stable identity across shared imports:
// Correct: pass pointer
app, err := kernel.Bootstrap(&AppModule{})
if err != nil {
log.Fatal(err)
}
// Wrong: value type loses identity
app, err = kernel.Bootstrap(AppModule{}) // rejected
if err != nil {
// handle error
}If two modules import the same dependency, they must share the same pointer:
dbModule := &DatabaseModule{}
usersModule := &UsersModule{db: dbModule}
ordersModule := &OrdersModule{db: dbModule} // same pointer
app := &AppModule{
users: usersModule,
orders: ordersModule,
}The kernel may call Definition() multiple times during graph construction. It must be side-effect free and return consistent metadata:
// Good: pure function
func (m *AppModule) Definition() module.ModuleDef {
return module.ModuleDef{
Name: "app",
// ...
}
}
// Bad: side effects
func (m *AppModule) Definition() module.ModuleDef {
m.counter++ // don't do this
return module.ModuleDef{...}
}Tokens are module.Token values (strings) that identify providers:
const TokenDB module.Token = "database.connection"
module.ProviderDef{
Token: TokenDB,
Build: func(r module.Resolver) (any, error) {
return sql.Open("mysql", dsn)
},
}Providers are:
- Built on first
Get()call (lazy) - Cached as singletons
- Scoped to module visibility rules
Controllers are built by the kernel and must implement http.RouteRegistrar:
type RouteRegistrar interface {
RegisterRoutes(router Router)
}Example:
module.ControllerDef{
Name: "UsersController",
Build: func(r module.Resolver) (any, error) {
db, err := module.Get[*sql.DB](r, TokenDB)
if err != nil {
return nil, err
}
return NewUsersController(db), nil
},
}Visibility is strictly enforced:
flowchart TB
subgraph DB["📦 DatabaseModule"]
direction LR
DB_P["<b>Providers</b><br/>• db.connection<br/>• db.internal"]
DB_E["<b>Exports</b><br/>• db.connection"]
end
subgraph Users["📦 UsersModule"]
direction LR
U_I["<b>Imports</b><br/>DatabaseModule"]
U_P["<b>Providers</b><br/>• users.service"]
U_A["<b>Can Access</b>"]
end
subgraph Access["Access Check"]
A1["✅ db.connection<br/><small>exported by DatabaseModule</small>"]
A2["✅ users.service<br/><small>own provider</small>"]
A3["❌ db.internal<br/><small>not exported</small>"]
end
DB_E -->|"exports"| U_I
U_A --> Access
style DB fill:#e3f2fd,stroke:#1565c0,color:#1565c0
style Users fill:#fff3e0,stroke:#e65100,color:#e65100
style Access fill:#fafafa,stroke:#757575,color:#424242
style A1 fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20
style A2 fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20
style A3 fill:#ffcdd2,stroke:#c62828,color:#b71c1c
Rules:
- A module can access its own providers
- A module can access tokens exported by modules it imports
- Accessing non-visible tokens returns
TokenNotVisibleError
A module can re-export tokens it can already access by listing them in its own Exports. This is useful for passing through shared dependencies or creating a public facade.
For a feature-level comparison with NestJS module behavior, see the NestJS Compatibility Guide.
type UsersModule struct {
db *DatabaseModule
}
func NewUsersModule(db *DatabaseModule) *UsersModule {
return &UsersModule{db: db}
}
func (m *UsersModule) Definition() module.ModuleDef {
return module.ModuleDef{
Name: "users",
Imports: []module.Module{m.db},
Providers: []module.ProviderDef{{
Token: TokenUsersService,
Build: func(r module.Resolver) (any, error) {
db, err := module.Get[*sql.DB](r, TokenDB)
if err != nil {
return nil, err
}
return NewUsersService(db), nil
},
}},
Exports: []module.Token{TokenUsersService, TokenDB},
}
}Re-exported tokens must be exported by the imported module (not just provided).
Invalid re-export errors are raised when a module lists tokens in Exports that it cannot safely re-export from its imports.
Exporting a token that an import does not export
reexporter := mod("Reexporter", []module.Module{imported}, nil, nil, []module.Token{token})Expected error:
export not visible: module="Reexporter" token="private.token"
Ambiguous re-export from multiple imports
reexporter := mod("Reexporter", []module.Module{left, right}, nil, nil, []module.Token{token})Expected error:
export token "shared.token" in module "Reexporter" is exported by multiple imports: [Left Right]
Fix: ensure the module imports the provider's module and that module exports the token, or export the token from only one import to remove ambiguity.
type DatabaseModule struct {
dsn string
}
func NewDatabaseModule(dsn string) *DatabaseModule {
return &DatabaseModule{dsn: dsn}
}
func (m *DatabaseModule) Definition() module.ModuleDef {
return module.ModuleDef{
Name: "database",
Providers: []module.ProviderDef{{
Token: TokenDB,
Build: func(r module.Resolver) (any, error) {
return sql.Open("mysql", m.dsn)
},
}},
Exports: []module.Token{TokenDB},
}
}type UsersModule struct {
db *DatabaseModule
}
func NewUsersModule(db *DatabaseModule) *UsersModule {
return &UsersModule{db: db}
}
func (m *UsersModule) Definition() module.ModuleDef {
return module.ModuleDef{
Name: "users",
Imports: []module.Module{m.db},
Providers: []module.ProviderDef{{
Token: TokenUsersService,
Build: func(r module.Resolver) (any, error) {
db, err := module.Get[*sql.DB](r, TokenDB)
if err != nil {
return nil, err
}
return NewUsersService(db), nil
},
}},
Controllers: []module.ControllerDef{{
Name: "UsersController",
Build: func(r module.Resolver) (any, error) {
svc, err := module.Get[UsersService](r, TokenUsersService)
if err != nil {
return nil, err
}
return NewUsersController(svc), nil
},
}},
Exports: []module.Token{TokenUsersService},
}
}flowchart TB
subgraph main["main()"]
direction TB
M1["db := NewDatabaseModule(dsn)"]
M2["users := NewUsersModule(db)"]
M3["orders := NewOrdersModule(db, users)"]
M4["app := &AppModule{...}"]
M5["kernel.Bootstrap(app)"]
end
M1 --> M2
M2 --> M3
M3 --> M4
M4 --> M5
subgraph Graph["Module Graph"]
App["AppModule"]
Users["UsersModule"]
Orders["OrdersModule"]
DB["DatabaseModule"]
end
App --> Users
App --> Orders
Users --> DB
Orders --> DB
Orders -.->|"uses export"| Users
M5 -.-> Graph
style main fill:#fafafa,stroke:#757575,color:#424242
style App fill:#fff3e0,stroke:#e65100,color:#e65100
style Users fill:#e3f2fd,stroke:#1565c0,color:#1565c0
style Orders fill:#e3f2fd,stroke:#1565c0,color:#1565c0
style DB fill:#e8f5e9,stroke:#2e7d32,color:#2e7d32
func main() {
db := NewDatabaseModule(os.Getenv("DB_DSN"))
users := NewUsersModule(db)
orders := NewOrdersModule(db, users)
app := &AppModule{
db: db,
users: users,
orders: orders,
}
app, err := kernel.Bootstrap(app)
if err != nil {
log.Fatal(err)
}
// ...
}- Keep module names unique across the graph
- Prefer small modules with explicit exports
- Use constructor functions (
NewXxxModule) for modules with config - Export only what other modules need