Creating a Module (WASM / Rust Path)¶
Two paths available
EERP supports two module implementation strategies:
- WASM / Rust (this guide) — Rust crate compiles to
.wasm; the core callsmigrate()at startup for sandboxed schema declaration. Business logic lives in a companion Go service. - Go monolith (Creating a Go Module) — schema declared in Go; all logic compiled directly into the core binary. This is the current approach for all shipped modules.
You can mix both: use the WASM path for schema isolation and the Go service for business logic in the same module.
This guide walks you through the WASM path using hr (Human Resources) as a running example.
Before You Start¶
The WASM component is a Rust crate that compiles to WebAssembly. You need:
- Rust stable toolchain
wasm32-unknown-unknowntarget:rustup target add wasm32-unknown-unknown- A directory inside one of the paths listed in
module_rootineerp-config.json
Step 1: Create the Module Directory¶
Step 2: Write module.json¶
{
"active": true,
"name": "hr",
"display_name": "Human Resources",
"version": "0.1.0",
"author": "Your Name",
"description": "Employee and contract management",
"depends": [],
"priority": 0,
"static_files": {},
"is_service": true,
"auto_install": true
}
Checklist:
-
nameis unique across all modules inmodule_root -
dependslists thenamevalues of any modules that must load before this one -
active: trueto enable on startup
Step 3: Initialize the Rust Crate¶
Edit Cargo.toml:
[package]
name = "hr"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # Required: produces a .wasm file
[profile.release]
opt-level = "s" # Optimize for size
lto = true
Step 4: Declare the Database Schema¶
Every module communicates its schema requirements via the migration protocol. Define the JSON as a static string:
// src/lib.rs
static MIGRATION: &str = r#"{
"entity": "employees",
"version": 1,
"operations": [
{
"type": "add_column",
"table": "employees",
"column": "department",
"sql_type": "VARCHAR(128)",
"nullable": false
},
{
"type": "add_column",
"table": "employees",
"column": "start_date",
"sql_type": "DATE",
"nullable": false
},
{
"type": "add_column",
"table": "employees",
"column": "salary_cents",
"sql_type": "BIGINT",
"nullable": false
},
{
"type": "add_column",
"table": "employees",
"column": "status",
"sql_type": "VARCHAR(32)",
"nullable": false
}
]
}"#;
#[no_mangle]
pub extern "C" fn migrate() -> *const u8 {
MIGRATION.as_ptr()
}
#[no_mangle]
pub extern "C" fn migrate_len() -> usize {
MIGRATION.len()
}
Tables are not auto-created
The migration protocol currently only supports add_column. The base table (employees) must exist or be created via a future create_table operation. For now, the entity's base table is created by the Go-side repository when MustRepo is first called (this behaviour is under active development).
Step 5: Implement Business Logic¶
Business logic runs in Go inside the module's service struct — compiled directly into the core binary. See Creating a Go Module for the full Go service guide. Below is the companion Go code that pairs with the WASM schema above.
modules/hr/
├── module.json
├── Cargo.toml
├── src/
│ └── lib.rs # Rust → WASM (schema declaration only)
└── internal/
├── employee.go # Entity definition
└── service.go # Business logic (compiled into core)
internal/employee.go:
package hr
import (
"eerp/core/orm/model"
"time"
)
type Employee struct {
model.BaseModel
Department string `db:"department"`
StartDate time.Time `db:"start_date"`
SalaryCents int64 `db:"salary_cents"`
Status string `db:"status"` // "active", "on_leave", "terminated"
}
func (Employee) TableName() string { return "employees" }
internal/service.go:
package hr
import (
"context"
"errors"
"eerp/core/orm"
"github.com/google/uuid"
)
var ErrEmployeeNotFound = errors.New("employee not found")
type Service struct {
employees *orm.Repository[Employee]
db *orm.DB
}
func New(db *orm.DB) *Service {
return &Service{
employees: orm.MustRepo[Employee](db),
db: db,
}
}
func (s *Service) Hire(ctx context.Context, e Employee) (Employee, error) {
e.Status = "active"
return s.employees.Create(ctx, e)
}
func (s *Service) Terminate(ctx context.Context, id uuid.UUID) (Employee, error) {
var result Employee
err := orm.Transact(ctx, s.db, func(tx *orm.Tx) error {
txEmp := s.employees.WithTx(tx)
emp, err := txEmp.FindByID(ctx, id)
if errors.Is(err, orm.ErrNotFound) {
return ErrEmployeeNotFound
}
if err != nil { return err }
emp.Status = "terminated"
result, err = txEmp.Update(ctx, emp, id)
return err
})
return result, err
}
func (s *Service) ListByDepartment(ctx context.Context, dept string) ([]Employee, error) {
return s.employees.Query().
Where(orm.Cond("department = $1", dept)).
Where(orm.Cond("status = $1", "active")).
OrderBy("start_date ASC").
All(ctx, s.db)
}
Step 6: Compile the WASM Binary¶
The output binary will be at:
Or use the top-level Makefile:
Step 7: Register with the Core¶
At startup, the module loader scans module_root and auto-discovers hr.wasm next to module.json. No explicit registration is needed.
To wire the Go service into the application, add to cmd/app/main.go (until a formal DI container exists):
hrService := hr.New(db)
_ = hrService // will be passed to HTTP handlers once the router is implemented
Step 8: Verify Module Loading¶
Run the backend and look for the module loader's log output:
Expected log output:
INFO module detected {"name": "hr", "version": "0.1.0", "priority": 0}
INFO module migrated {"name": "hr", "operations": 4}
INFO module loaded {"name": "hr"}
Dependency Ordering¶
If your module depends on another (e.g., hr needs auth to be loaded first):
The detector will assign hr a higher priority than auth, ensuring auth loads and migrates first.
graph LR
auth["auth\n(priority 0)"] --> hr["hr\n(priority 1)"]
auth --> crm["crm\n(priority 1)"] Module Checklist¶
WASM component (Rust):
-
module.jsonpresent with uniquename - Rust crate with
crate-type = ["cdylib"] -
migrate()andmigrate_len()exported from Rust - Migration JSON is valid (operations reference correct table names)
-
.wasmbinary placed in the module directory (auto-discovered by core)
Go service component:
- Go entity embeds
model.BaseModel - Go entity has
dbstruct tags or snake_case field names -
Service.New(db)constructor wires repositories - Service registered in
cmd/app/main.go - Module appears in startup logs without errors
See Creating a Go Module for the Go-only path without a WASM binary.