Module System¶
The module system is the core extension mechanism of EERP. It allows independent business domains (CRM, Inventory, Accounting) to be developed, compiled, and deployed without modifying the core runtime.
Purpose¶
In a traditional monolithic ERP, adding a new feature means modifying the core. Over time, this creates a tightly coupled system where business logic and infrastructure concerns are intertwined.
EERP solves this by treating the core as a stable runtime and business features as pluggable WASM binaries. The core provides infrastructure (database, HTTP, auth); modules provide business logic.
Responsibilities¶
- Discover modules by scanning the filesystem
- Validate module metadata and resolve dependencies
- Load and instantiate WASM binaries in a sandboxed environment
- Apply per-module database schema migrations
- Define the contract (ABI) between modules and the core
Module Anatomy¶
A module is a directory containing two required files:
modules/crm/
├── module.json # Module metadata and dependency declaration
├── crm.wasm # Compiled Rust binary (auto-discovered)
└── src/ # Rust source (not read by core)
└── lib.rs
module.json¶
{
"active": true,
"name": "crm",
"display_name": "CRM",
"version": "0.0.1",
"author": "Edwin Lecomte",
"description": "CRM core module",
"depends": [],
"priority": 0,
"static_files": {},
"is_service": true,
"auto_install": true
}
| Field | Type | Description |
|---|---|---|
active | bool | Whether the module should be loaded |
name | string | Unique identifier (used in dependency resolution) |
display_name | string | Human-readable name |
version | semver | Module version |
depends | []string | Names of modules that must be loaded first |
priority | int | Manual priority hint (rarely needed; dependency resolver assigns priorities) |
is_service | bool | Whether the module registers HTTP handlers |
auto_install | bool | Whether to apply migrations automatically on load |
Lifecycle¶
stateDiagram-v2
[*] --> Discovered: Filesystem scan finds module.json
Discovered --> Validated: Metadata parsed and validated
Validated --> Ordered: Dependency graph resolved, priority assigned
Ordered --> Instantiated: WASM binary loaded into Wasmtime
Instantiated --> Migrated: migrate() called, schema changes applied
Migrated --> Active: Module ready to serve requests
Active --> [*]: Shutdown Detection Flow¶
core/internal/module/detector.go
flowchart TD
A[Read module_root from config] --> B[Walk directories for module.json files]
B --> C[Parse each module.json into Module struct]
C --> D{All dependencies present?}
D -- No --> E[Log missing deps, skip affected modules]
D -- Yes --> F[Topological sort by depends array]
F --> G[Assign numeric priority to each module]
G --> H[Snapshot: detect changed modules]
H --> I[Auto-discover .wasm binary if wasm_path unset]
I --> J[Return ordered Module list] Snapshot-based Change Detection¶
The detector maintains a cache of the previous scan in ./cache/modules. On each startup, it compares the current filesystem state with the cached state:
- New module: load and migrate
- Changed module (different hash): reload
- Unchanged module: skip migration (idempotent operations still safe)
- Removed module: mark inactive
Priority Assignment¶
Modules in the same dependency tier get the same priority number. The algorithm:
- Assign priority 0 to all modules with no dependencies.
- For each remaining module, assign
max(dependencies' priorities) + 1. - Repeat until all modules have a priority.
Modules at priority N load concurrently; priority N+1 waits for all of N to complete.
Loading Flow¶
core/internal/module/load.go
sequenceDiagram
participant Loader
participant Wasmtime
participant Module as WASM Module
participant DB as PostgreSQL
Loader->>Loader: Group modules by priority
loop Priority level (sequential)
par Each module in group (concurrent)
Loader->>Wasmtime: Instantiate(wasm_bytes, linker)
Wasmtime-->>Loader: Instance
Loader->>Module: Call migrate_len()
Module-->>Loader: byte length
Loader->>Module: Call migrate()
Module-->>Loader: memory pointer
Loader->>Loader: Read memory[ptr : ptr+len]
Loader->>Loader: JSON.Unmarshal → Migration struct
loop Per operation
Loader->>DB: ALTER TABLE (add_column etc.)
end
end
end Migration Protocol¶
Modules communicate schema requirements through a simple memory-based protocol. This avoids a direct Go/Rust dependency; the only shared contract is the JSON shape.
Module side (Rust)¶
// The migration definition, returned as JSON in linear memory
static MIGRATION: &str = r#"{
"entity": "contacts",
"version": 1,
"operations": [
{
"type": "add_column",
"table": "contacts",
"column": "crm_segment",
"sql_type": "VARCHAR(64)",
"nullable": true
}
]
}"#;
#[no_mangle]
pub extern "C" fn migrate() -> *const u8 {
MIGRATION.as_ptr()
}
#[no_mangle]
pub extern "C" fn migrate_len() -> usize {
MIGRATION.len()
}
Core side (Go)¶
// core/internal/types/modules.go
type Migration struct {
Entity string `json:"entity"`
Version int `json:"version"`
Operations []Operation `json:"operations"`
}
type Operation struct {
Type string `json:"type"` // currently: "add_column"
Table string `json:"table"`
Column string `json:"column"`
SQLType string `json:"sql_type"`
Nullable bool `json:"nullable"`
}
The core reads the WASM linear memory region indicated by the pointer and length, deserializes the JSON, and applies each operation using parameterized DDL statements.
Supported Operations¶
| Operation | DDL Generated |
|---|---|
add_column | ALTER TABLE t ADD COLUMN IF NOT EXISTS col TYPE |
Future operations planned: drop_column, add_index, create_table, rename_column.
Dependency Resolution¶
core/internal/common/filemeta.go
graph LR
subgraph "Priority 0"
Core
end
subgraph "Priority 1"
CRM
Accounting
HR
end
subgraph "Priority 2"
Invoicing
Payroll
end
CRM --> Core
Accounting --> Core
HR --> Core
Invoicing --> CRM
Invoicing --> Accounting
Payroll --> HR
Payroll --> Accounting The resolver also validates that all declared dependencies are present:
Missing dependencies cause the dependent module to be skipped, not the entire system.
Interactions¶
graph LR
main["main.go"] -->|calls| loader["module/load.go"]
loader -->|calls| detector["module/detector.go"]
detector -->|reads| fs["Filesystem (module.json, .wasm)"]
detector -->|uses| resolver["common/filemeta.go (dep resolution)"]
loader -->|uses| wasmtime["Wasmtime engine"]
loader -->|executes DDL via| db["orm.DB"]
loader -->|reads| snapshot["cache/modules (change detection)"] Extension Points¶
| Extension | Mechanism |
|---|---|
| New operation type | Add case to Operation.Type handler in load.go |
| New WASM export | Export function from Rust, call from load.go |
| Hot reload | Future: watch module_root with fsnotify, re-instantiate on change |
| Module isolation | Future: per-module Wasmtime store with resource limits |
| Module API | Future: Go functions linked into WASM linker, called from Rust via imports |
Security Considerations¶
WASM modules run inside a Wasmtime sandbox. They cannot:
- Access the host filesystem directly
- Open network connections
- Crash the Go process on panic
They can only communicate with the core through the defined ABI (exported functions + linear memory) and through imported host functions that the core explicitly exposes via the Wasmtime linker.