A Motoko library designed to reduce boilerplate when instantiating and managing class-like objects within actor classes. ClassPlus enables developers to create modular, upgrade-friendly classes that leverage stable variables for persistence across upgrades.
- DFX Version: Requires DFX 0.24.0 or later.
- Motoko Version: Requires Motoko 1.1.0 or later for enhanced orthogonal persistence and migration support.
mops add class-plus
ClassPlus simplifies the process of defining and managing objects in actor classes by:
- Reducing Boilerplate: It minimizes repetitive code for constructing and maintaining objects.
- Supporting Upgrades: Ensures objects can be reconstituted from stable variables after an upgrade.
- Encapsulating Complexity: Provides a unified interface for initialization, state management, and environment configuration.
- Migration Support: Works seamlessly with Motoko's new explicit migration pattern for state evolution.
ClassPlus objects are instantiated with a predefined structure and integrate seamlessly into actor classes.
- State: The shape of the class's state, stored in stable variables, must be composed of stable-compatible types.
- Environment: Optional environment variables passed to the class for contextual operations.
- Initialization: Initialization logic, including setup and configuration, can be provided during class creation.
To define a class compatible with ClassPlus, follow this structure:
public class AClass(stored: ?State, caller: Principal, canister: Principal, args: ?InitArgs, _environment: ?Environment, onStateChange: (State) -> ()) {
// Define the initial state.
public let state = switch(stored) {
case (?val) val;
case (null) initialState();
};
// Notify about state changes.
onStateChange(state);
// Capture environment settings.
let environment: Environment = switch(_environment) {
case (?val) val;
case (null) D.trap("No Environment Set");
};
// Apply initial arguments, if provided.
switch (args) {
case (?val) {
if (state.message == "Uninitialized") {
state.message := val.messageModifier;
}
};
case (null) {};
};
// Define class methods.
public func message(): Text {
state.message # " from canister " # Principal.toText(canister) # " created by " # Principal.toText(caller);
};
public func setMessage(x: Text): () {
state.message := x;
};
}-
State: Define the structure of the class's state.public type State = { var message: Text; };
-
Environment: Define any environment variables (optional).public type Environment = { thisActor: actor { auto_init: () -> async (); }; };
-
initialState: Define default state values.public func initialState(): State = { var message = "Uninitialized"; };
-
InitArgs: Define any arguments required for initialization (optional).public type InitArgs = { messageModifier: Text; };
Use the ClassPlus library to simplify instantiation and initialization within an actor.
import AClassLib "aclass";
import ClassPlus "../";
shared ({ caller = _owner }) actor class Token () = this {
type AClass = AClassLib.AClass;
type State = AClassLib.State;
type InitArgs = AClassLib.InitArgs;
type Environment = AClassLib.Environment;
let initManager = ClassPlus.ClassPlusInitializationManager(_owner, Principal.fromActor(this), true);
stable var aClass_state: State = AClassLib.initialState();
let aClass = AClassLib.Init<system>({
org_icdevs_class_plus_manager = initManager;
initialState = aClass_state;
args = ?({ messageModifier = "Hello World" });
pullEnvironment = ?(func() : Environment {
{
thisActor = actor(Principal.toText(Principal.fromActor(this)));
};
});
onInitialize = ?(func(newClass: AClassLib.AClass): async* () {
D.print("Initializing AClass");
});
onStorageChange = func(new_state: State) {
aClass_state := new_state;
}
});
public shared func getMessage(): async Text {
aClass().message();
};
public shared func SetMessage(x: Text): async () {
aClass().setMessage(x);
};
private shared func initStuff(): async* (){
//add init logic here
}
initManager.calls.add(initStuff);
};Motoko's enhanced orthogonal persistence (available in Motoko 1.1.0+) provides a powerful migration pattern for evolving your class state across upgrades. This section explains how to use ClassPlus with the new migration syntax.
When you need to change the structure of your State type (adding fields, changing types, or reorganizing data), you need to tell Motoko how to transform the old state into the new state. Without migration, incompatible state changes will cause upgrades to fail.
The migration pattern uses a migration function that:
- Consumes specific fields from the old actor state
- Produces specific fields for the new actor state
- Is selective - fields not mentioned are preserved automatically
First, define both the old state type (what you're migrating FROM) and the new state type (what you're migrating TO) in a migration module:
// Migration.mo
import Time "mo:core/Time";
module Migration {
// Old state type from v1
public type OldState = {
var message: Text;
var counter: Nat;
};
// New state type for v2 - adds new fields
public type NewState = {
var message: Text;
var counter: Nat;
var lastUpdated: Int; // NEW field
var version: Text; // NEW field
};
// Migration function
public func migration(old : { var myClass_state : OldState }) : { var myClass_state : NewState } {
{
var myClass_state : NewState = {
var message = old.myClass_state.message; // Preserve message
var counter = old.myClass_state.counter; // Preserve counter
var lastUpdated = Time.now(); // Initialize new field
var version = "v2-migrated"; // Initialize new field
};
};
};
};Create the v2 version of your class module with the new State type:
// MyClass_v2.mo
module {
public type State = {
var message: Text;
var counter: Nat;
var lastUpdated: Int; // NEW
var version: Text; // NEW
};
public func initialState() : State = {
var message = "Uninitialized";
var counter = 0;
var lastUpdated = 0;
var version = "v2";
};
// ... rest of ClassPlus boilerplate and class implementation
};Use the (with migration) syntax before your actor declaration:
// MyActor_v2.mo
import MyClassLib "MyClass_v2";
import { migration } "Migration";
import ClassPlus "mo:class-plus";
import Principal "mo:core/Principal";
(with migration) // <-- This tells Motoko to run the migration function on upgrade
shared ({ caller = _owner }) persistent actor class MyActor() = this {
type MyClass = MyClassLib.MyClass;
type State = MyClassLib.State;
transient let initManager = ClassPlus.ClassPlusInitializationManager<system>(
_owner, Principal.fromActor(this), true
);
// State variable - populated by migration on upgrade from v1
var myClass_state : State = MyClassLib.initialState();
transient let myClass = MyClassLib.Init({
org_icdevs_class_plus_manager = initManager;
initialState = myClass_state;
args = ?({ messageModifier = "Hello World v2" });
pullEnvironment = ?(func() : Environment { /* ... */ });
onInitialize = null;
onStorageChange = func(new_state: State) {
myClass_state := new_state;
};
});
// ... public methods
};The migration function only needs to specify fields that are being transformed. Fields not mentioned are handled automatically:
// If you have multiple stable variables:
persistent actor {
var myClass_state : State = ...; // Needs migration (type changed)
var otherData : Nat = 0; // No migration needed (preserved automatically)
}
// Migration only mentions myClass_state:
func migration(old : { var myClass_state : OldState }) : { var myClass_state : NewState } {
// otherData is preserved automatically!
{ var myClass_state = ... }
};After the initial migration (v1 → v2), subsequent upgrades (v2 → v2) should NOT use the migration function because:
- The migration function expects the OLD state format
- v2 state is already in the NEW format
Solution: Create a post-migration version without the (with migration) declaration:
// MyActor_v2_post.mo - For upgrades AFTER migration is complete
shared ({ caller = _owner }) persistent actor class MyActor() = this {
// Same code as v2, but WITHOUT (with migration)
var myClass_state : State = MyClassLib.initialState();
// ...
};- v1 deployed - Original version running in production
- v1 → v2 (with migration) - Deploy v2 with migration function
- v2 → v2_post - Deploy post-migration version (removes migration code)
- v2_post → v2_post - Future upgrades use the same version
See the src/canisters/ directory for a complete working example:
migratableClass_v1.mo- Initial class with basic statemigratableClass_v2.mo- Updated class with new fieldsmigratableExampleMigration.mo- Migration functionmigratableExample_v1.mo- v1 actormigratableExample_v2.mo- v2 actor with migrationmigratableExample_v2_post.mo- v2 actor for post-migration upgrades
The pic/ directory contains PocketIC tests that verify:
- State persistence across same-version upgrades
- Proper state migration from v1 to v2
- New field initialization during migration
- Post-migration upgrade compatibility
Run tests with:
cd pic
npm install
npm testHandles initialization and tracking of ClassPlus objects.
-
Constructor:
ClassPlusInitializationManager(_owner: Principal, _canister: Principal, autoTimer: Bool)_owner: The principal of the actor owner._canister: The principal of the canister where the object resides.autoTimer: Automatically initialize objects on a timer.
-
Methods:
initialize(): async* ()- Executes initialization logic for all registered classes.
-
Members
- calls: Buffer.Buffer(() ->async*())
- queue up functions to call during initialization by adding them to the calls buffer. They will be executed in the order you add them.
- calls: Buffer.Buffer(() ->async*())
Encapsulates logic for creating and managing a class instance.
public class AClass<system>(stored: ?State, caller: Principal, canister: Principal, args: ?InitArgs, _environment: ?Environment, onStateChange: (State) -> ())
-
Constructor:
ClassPlus<system, T, S, A, E>(config: {...})manager: Instance ofClassPlusInitializationManager.initialState: Initial state of the class.constructor: Constructor function for the class.args: Optional initialization arguments.pullEnvironment: Function to retrieve environment variables.onInitialize: Optional initialization logic.onStorageChange: Callback for state updates.
-
Methods:
get(): T- Retrieves the class instance, creating it if necessary.
initialize(): async* ()- Performs any setup logic for the class.
getState(): S- Retrieves the current state.
getEnvironment(): ?E- Retrieves the environment, initializing it if necessary.
Same as ClassPlus but with a system constructor
Simplifies retrieval of a class instance.
public func ClassPlusGetter<T, S, A, E>(x: ?ClassPlus<T, S, A, E>): () -> T;Simplifies retrieval of a class instance that has a system constructor.
public func ClassPlusSystemGetter<T, S, A, E>(x: ?ClassPlus<T, S, A, E>): <system>() -> T;Constructs initialization logic for a class.
public func BuildInit<system, T, S, A, E>(Constructor: (...)): (...) -> ();- Reduced Boilerplate: Eliminates repetitive code in actor classes.
- Upgrade-Safe: Ensures class objects can be reconstituted from stable variables.
- Modular and Organized: Provides a clear structure for defining and managing classes.
- Automatic Initialization: Built-in timer management simplifies initialization.
- Migration-Friendly: Works seamlessly with Motoko's explicit migration pattern.
ClassPlus includes comprehensive PocketIC tests covering:
- Basic initialization and state management
- State persistence across upgrades
- State migration between versions
# Build canisters
dfx build --check
# Run PIC tests
cd pic
npm install
npm testThis library is ideal for projects requiring modular, upgrade-friendly object management in Motoko. By leveraging ClassPlus, developers can focus more on functionality and less on boilerplate code.