Skip to content

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 calls migrate() 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-unknown target: rustup target add wasm32-unknown-unknown
  • A directory inside one of the paths listed in module_root in eerp-config.json

Step 1: Create the Module Directory

mkdir -p modules/hr/src

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:

  • name is unique across all modules in module_root
  • depends lists the name values of any modules that must load before this one
  • active: true to enable on startup

Step 3: Initialize the Rust Crate

cd modules/hr
cargo init --lib

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

cd modules/hr
cargo build --target wasm32-unknown-unknown --release

The output binary will be at:

target/wasm32-unknown-unknown/release/hr.wasm

Or use the top-level Makefile:

make build

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:

make run-back

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):

{
    "name": "hr",
    "depends": ["auth"]
}

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.json present with unique name
  • Rust crate with crate-type = ["cdylib"]
  • migrate() and migrate_len() exported from Rust
  • Migration JSON is valid (operations reference correct table names)
  • .wasm binary placed in the module directory (auto-discovered by core)

Go service component:

  • Go entity embeds model.BaseModel
  • Go entity has db struct 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.