All posts
MoveDev

Dubhe Engine 101 Intro

A comprehensive tutorial on building your first DApp with Dubhe Engine, covering project setup, schema definition, Move contract development, and frontend integration.

MMoven
12 minutes read

image

Getting Started with Dubhe Engine: A 101 Tutorial for Sui Move Developers

Dubhe Engine is an intent-driven Move application creation engine composed of toolchains and libraries, enabling developers to effortlessly build applications tailored to their needs.

It is designed to streamline the development of Move-based smart contracts on platforms like Sui and many other Move L1s (Yes, since we leverage the Move Standard Library) by providing an integrated framework of CLI tools, code generators, and client SDKs. Experienced Move developers will be surprised how Dubhe simplifies many common pain points, particularly when adding new fields, events, or error handling, making the entire process from setup to deployment seamless and efficient.

Dubhe introduces the concept of schemas (for on-chain data structures) and system modules (for business logic), which separates data definition from logic implementation. This pattern is akin to an ECS (Entity-Component-System) model, where schemas serve as components (data storage units) and system modules operate on them as systems. By leveraging dynamic fields under the hood, Dubhe enables flexible and upgradable contract storage without extra complexity for the developer.

Dubhe Engine not only enhances developer productivity, enforces good design patterns, and simplifies the end-to-end Move development experience, but it also aims to grow into a thriving developer ecosystem that incubates next-generation Move applications, attracts more users, and eventually expands the Move community.

Dubhe 101: Building a Sui DApp with Dubhe

In this tutorial, we’ll walk through the "Dubhe Engine 101" example – creating a simple Counter DApp on Sui – to show Dubhe’s workflow. This tutorial assumes familiarity with Move and the Sui CLI. If you’re new to these concepts, consider reviewing Your First Sui dApp - Sui, then return to see how Dubhe simplifies and enhances the development experience.

By the end, you’ll have a basic Move contract deployed and a React/Next.js frontend that interacts with it, all set up using Dubhe’s tools. If you want a step-by-step guidance, please check the Dubhe Docs - 101 Template on Sui

1. Project Setup and Initialization

Before magic happens, please download the prerequisites:

  • nvm: for NodeJS version management
  • NodeJS: We recommend installing v18 or v20 using nvm.
  • pnpm: fast package manager, we recommend installing through npm

To start a new Dubhe project, you use the Dubhe CLI initializer. Dubhe’s project template sets up both the Move contract and a frontend scaffold in one go. Open a terminal in your desired project directory and run:

pnpm create dubhe

This will launch an interactive prompt to configure your DApp. You’ll be asked for a project name, which blockchain to target (choose Sui), and the project template (select the default, named "101"). After confirming, Dubhe will generate a new project folder with the given name, containing a Move package and a starter web application. You should see a success message indicating the project was created and next steps like installing dependencies and starting a local network.

Inside the new project, the Move contract is initialized as a standard Sui Move package. Dubhe’s template creates a Move.toml manifest and a sources directory for Move code. Opening the Move.toml file, you’ll notice it includes the Sui framework as a dependency and also a local dependency on Dubhe framework libraries. For example:

[dependencies]
Sui = { git = "...sui-framework...", rev = "..." }
Dubhe = { local = "../dubhe-framework" }

With the scaffold in place, run pnpm install to install the project’s NPM dependencies (which include Dubhe CLI and SDK packages). You now have a foundation ready to build your Move application using Dubhe.

2. Defining the Data Schema (Dubhe Config and Code Generation)

One of Dubhe’s standout features is its schema-driven development. Instead of hand-writing your Move struct, event definitions, and error codes from scratch, you describe them in a Dubhe config file and let the tool generate the boilerplate Move code. The Dubhe template includes a TypeScript configuration (commonly dubhe.config.ts) where you define your project’s schemas.

For our Counter DApp, we’ll define a schema for a Counter object that holds a numerical value and emits an event when incremented. For example:

export const dubheConfig = {
  name: 'counter',
  description: 'counter contract',
  schemas: {
    counter: {
      structure: {
        value: 'StorageValue<u32>'
      },
      events: [
        {
          name: 'Increment',
          fields: { value: 'u32' }
        }
      ],
      errors: [
        {
          name: 'InvalidIncrement',
          message: "Number can't be incremented, must be more than 0"
        }
      ]
    }
  }
} as DubheConfig;

Once the schema is defined, you run:

pnpm run schema:gen

This invokes dubhe schemagen under the hood, which reads the config and produces Move modules under the contracts/ directory. After generation, your project’s Move source tree will include new files in a codegen folder, placeholders for your logic, and script templates in sources/scripts/ (like deploy_hook.move and migrate.move). A typical layout:

contracts/counter/
├── Move.toml
└── sources
    ├── codegen
    │   ├── errors/
    │   ├── events/
    │   └── schemas/
    ├── scripts/
    ├── systems/
    └── tests/

The generated modules handle storing your data (with dynamic fields for upgradable storage), emitting events, and referencing custom error codes. You then implement your custom logic in the systems directory.

3. Writing the Move Module (Business Logic)

Next, you create a system module (in sources/systems/) that imports your generated schema and provides the actual functionality. For example, a simple counter system might include an inc function to increment the value and emit an event, plus a get function to retrieve the counter’s value.

module counter::counter_system {
    use counter::counter_schema::Counter;
    use counter::counter_error_invalid_increment;
    use sui::error::abort;
 
    /// Increments the counter by `amount`. Fails if amount <= 0.
    public entry fun inc(counter: &mut Counter, amount: u32) {
        if (amount <= 0) {
            abort counter_error_invalid_increment::EInvalidIncrement();
        };
        counter.borrow_mut_value().mutate!(|val| *val = *val + amount);
        counter::counter_event_increment::emit_event(counter, amount);
    }
 
    public fun get(counter: &Counter): u32 {
        counter.borrow_value().get()
    }
}

Compile and build (pnpm run build) to ensure the contract compiles cleanly.

4. Publishing the Contract to Sui Local Testnet (Deploying via Dubhe CLI)

Start your local dev environment with just one command, and it will automatically start local nodes with sufficient testing SUI for you.

pnpm run start:localnet

Deploying a Move package on Sui typically involves key generation, faucets, and publishing steps. Dubhe bundles these into streamlined commands. For example:

pnpm run setup:testnet

This command:

  1. Builds your Move package.
  2. Generates a new account/keypair if you don’t have one.
  3. Funds that account using the Sui localnet faucet.
  4. Publishes your package.

You can also run each step separately (pnpm run account:gen, pnpm run faucet:local, pnpm run deploy:local), but setup:testnet chains them. After successful deployment, you’ll see your package ID.

5. Building a Client DApp with the Dubhe SDK

Dubhe includes a Next.js/React scaffold that uses its TypeScript SDK to interact with your contract. You can query data with dubhe.query.<schema>.<functionName>() (using Sui’s dev-inspect under the hood) and send transactions with dubhe.tx.<system>.<entryFunctionName>() followed by dubhe.signAndSendTxn(tx).

An example inc call in the frontend:

const dubhe = new Dubhe({
  networkType: 'testnet',
  packageId: PACKAGE_ID,
  secretKey
});
const tx = new Transaction();
dubhe.tx.counter_system.inc({
  tx,
  params: [tx.object(COUNTER_OBJECT_ID), tx.pure.u32(1)],
  isRaw: true
});
const response = await dubhe.signAndSendTxn(tx);
if (response.effects.status.status === 'success') {
  // successful transaction
}

Similarly, to query the counter’s value:

const dubhe = new Dubhe({ networkType: 'testnet', packageId: PACKAGE_ID });
const tx = new Transaction();
const queryValue = dubhe.query.counter_schema.get_value({
  tx,
  params: [tx.object(COUNTER_OBJECT_ID)]
});
const result = await dubhe.devInspect(tx);
const value = dubhe.view(result)[0];

6. Testing and Debugging the Move Contract

Dubhe integrates neatly with Sui’s standard test framework (#[test] functions). It generates a tests directory with an init_test module that simulates deploying your DApp in a local scenario. For example, you might see:

#[test_only]
module counter::counter_test {
    use sui::test_scenario;
    use counter::counter_system;
    use counter::init_test;
    use counter::counter_schema::Counter;
 
    #[test]
    public fun test_inc() {
        let (scenario, _dapp) = init_test::deploy_dapp_for_testing(@0xA);
        let mut counter = test_scenario::take_shared<Counter>(&scenario);
        assert!(counter.borrow_value().get() == 0);
        counter_system::inc(&mut counter, 10);
        assert!(counter.borrow_value().get() == 10);
        test_scenario::return_shared(counter);
        _dapp.destroy_dapp_for_testing();
        scenario.end();
    }
}

Run pnpm run test to compile and execute these tests. You can also debug or dev-inspect with Dubhe CLI or the standard Sui CLI.

Extension Examples: Managing Multiple Data Fields, Events, and Errors

Beyond a simple Counter, Dubhe easily supports more native data and multiple fields/events in your schemas. Below are two examples showing extended usage—counter_with_name and counter_as_data—showing how you can easily customize fields, multiple events, errors, and use them in your system logic.

Check the full code here.

A. counter_with_name

In this extension, we store two fieldsname and value—plus corresponding events and a custom error. Here’s the Dubhe config and relevant Move code.

dubhe.counter_with_name.config.ts
import { DubheConfig } from '@0xobelisk/sui-common';
 
export const dubheConfig = {
  name: 'counter_with_name',
  description: 'counter contract',
  schemas: {
    name: 'StorageValue<String>',
    value: 'StorageValue<u32>'
  },
  events: {
    SetName: { name: 'String' },
    Increment: { value: 'u32' }
  },
  errors: {
    InvalidIncrement: "Number can't be incremented, must be more than 0"
  }
} as DubheConfig;

When you run pnpm run schema:gen (or the equivalent command), Dubhe generates modules for storing the name and value, emitting the SetName and Increment events, and handling the InvalidIncrement error. Then, you implement the business logic in a system module:

contracts/counter_with_name/sources/systems/counter_with_name.move
module counter_with_name::counter_system {
    use counter_with_name::schema::Schema;
    use counter_with_name::events::increment_event;
    use counter_with_name::errors::invalid_increment_error;
    use std::ascii::String;
 
    public entry fun inc(schema: &mut Schema, number:u32) {
        // Check if the increment value is valid
        invalid_increment_error(number > 0 && number < 100);
        let value = schema.value()[];
        schema.value().set(value + number);
        increment_event(number);
    }
 
    public fun set_name(schema: &mut Schema, name: String) {
        schema.name().set(name);
    }
}

The deploy_hook.move pre-initializes the Schema with some default name and value:

contracts/counter_with_name/sources/scripts/deploy_hook.move
module counter_with_name::deploy_hook {
  use counter_with_name::schema::Schema;
  use std::ascii::string;
 
  public(package) fun run(_schema: &mut Schema, _ctx: &mut TxContext) {
    _schema.name().set(string(b"GOOD LUCK Counter"));
    _schema.value().set(0);
  }
}

And here’s a quick Move test verifying increment and set_name:

contracts/counter_with_name/sources/tests/counter_with_name.move
#[test_only]
module counter_with_name::counter_test {
    use sui::test_scenario;
    use counter_with_name::counter_system;
    use counter_with_name::init_test;
    use counter_with_name::schema::Schema;
    use std::ascii::string;
 
    #[test]
    public fun inc() {
        let (scenario, dapp)  = init_test::deploy_dapp_for_testing(@0xA);
 
        let mut schema = test_scenario::take_shared<Schema>(&scenario);
        assert!(schema.value().get() == 0);
 
        counter_system::inc(&mut schema, 10);
        assert!(schema.value().get() == 10);
 
        test_scenario::return_shared(schema);
        dapp.distroy_dapp_for_testing();
        scenario.end();
    }
 
    #[test]
    public fun set_name() {
        let (scenario, dapp)  = init_test::deploy_dapp_for_testing(@0xA);
 
        let mut schema = test_scenario::take_shared<Schema>(&scenario);
        assert!(schema.name().get() == string(b"GOOD LUCK Counter"));
 
        counter_system::set_name(&mut schema, string(b"AWESOME Counter"));
        assert!(schema.name().get() == string(b"AWESOME Counter"));
 
        test_scenario::return_shared(schema);
        dapp.distroy_dapp_for_testing();
        scenario.end();
    }
}

This demonstrates how easy it is to define and manage multiple fields (name, value), events for each field (SetName, Increment), and custom error checks in your Move code, all from the single config.


B. counter_as_data

Sometimes you want a map-like schema that stores many counter objects, each with a unique ID. Dubhe can handle this using StorageMap, multiple events, and a designated “data” struct. Below is a second extension illustrating how to define a more complex structure in your config.

dubhe.counter_as_data.config.ts
import { DubheConfig } from '@0xobelisk/sui-common';
 
export const dubheConfig = {
  name: 'counter_as_data',
  description: 'counter contract',
  data: {
    // Wrap fields into a data
    Counter: {
      id: 'u8',
      name: 'String',
      value: 'u32'
    }
  },
  schemas: {
    counter: 'StorageMap<u8, Counter>'
  },
  events: {
    CreateCounter: { id: 'u8', name: 'String', value: 'u32' },
    IncrementCounter: { id: 'u8', value: 'u32' },
    UpdateCounterName: { id: 'u8', name: 'String' }
  },
  errors: {
    InvalidIncrement: "Number can't be incremented, must be more than 0",
    CounterNotFound: 'Counter not found'
  }
} as DubheConfig;

Once generated, the main logic can be written in a single system module:

contracts/counter_as_data/sources/systems/counter_as_data.move
module counter_as_data::counter_system {
    use counter_as_data::schema::Schema;
    use counter_as_data::events::{create_counter_event, increment_counter_event, update_counter_name_event};
    use counter_as_data::errors::{invalid_increment_error, counter_not_found_error};
    use counter_as_data::counter;
    use std::ascii::String;
 
    public entry fun create_counter(schema: &mut Schema, name: String, value: u32, id: u8) {
        schema.counter().set(id, counter::new(id, name, value));
        create_counter_event(id, name, value);
    }
 
    public entry fun inc_with_id(schema: &mut Schema, id: u8, number:u32) {
        // Check if the increment value is valid
        invalid_increment_error(number > 0 && number < 100);
 
        let counter = schema.get_counter(id);
        counter_not_found_error(counter.get_id() == id);
 
        let value = counter.get_value();
        let name = counter.get_name();
        schema.counter().set(id, counter::new(id, name, value + number));
        increment_counter_event(id, number);
    }
 
    public fun update_counter_name(schema: &mut Schema, id: u8, new_name: String) {
        let counter = schema.get_counter(id);
        counter_not_found_error(counter.get_id() == id);
 
        let value = counter.get_value();
        schema.counter().set(id, counter::new(id, new_name, value));
        update_counter_name_event(id, new_name);
    }
}

As before, you can initialize some default data in the deploy_hook.move:

contracts/counter_as_data/sources/scripts/deploy_hook.move
module counter_as_data::deploy_hook {
  use counter_as_data::schema::Schema;
  use counter_as_data::counter;
  use std::ascii::string;
 
  public(package) fun run(_schema: &mut Schema, _ctx: &mut TxContext) {
    _schema.counter().set(0, counter::new(0, string(b"GOOD LUCK Counter"), 0));
  }
}

And validate everything with Move tests:

contracts/counter_as_data/sources/tests/counter_as_data.move
#[test_only]
module counter_as_data::counter_test {
    use sui::test_scenario;
    use counter_as_data::counter_system;
    use counter_as_data::init_test;
    use counter_as_data::schema::Schema;
    use std::ascii::string;
 
    #[test]
    public fun create_counter() {
        let (scenario, dapp)  = init_test::deploy_dapp_for_testing(@0xA);
 
        let mut schema = test_scenario::take_shared<Schema>(&scenario);
        assert!(schema.counter().get(0).get_value() == 0);
        assert!(schema.counter().get(0).get_name() == string(b"GOOD LUCK Counter"));
 
        // create counter with id 1
        counter_system::create_counter(&mut schema, string(b"AWESOME Counter"), 50, 1);
        assert!(schema.counter().get(1).get_value() == 50);
        assert!(schema.counter().get(1).get_name() == string(b"AWESOME Counter"));
 
        test_scenario::return_shared(schema);
        dapp.distroy_dapp_for_testing();
        scenario.end();
    }
 
    #[test]
    public fun inc_with_id() {
        let (scenario, dapp)  = init_test::deploy_dapp_for_testing(@0xA);
 
        let mut schema = test_scenario::take_shared<Schema>(&scenario);
        assert!(schema.counter().get(0).get_value() == 0);
 
        counter_system::inc_with_id(&mut schema, 0, 10);
        assert!(schema.counter().get(0).get_value() == 10);
 
        test_scenario::return_shared(schema);
        dapp.distroy_dapp_for_testing();
        scenario.end();
    }
 
    #[test]
    public fun update_counter_name() {
        let (scenario, dapp)  = init_test::deploy_dapp_for_testing(@0xA);
 
        let mut schema = test_scenario::take_shared<Schema>(&scenario);
        assert!(schema.counter().get(0).get_value() == 0);
 
        counter_system::update_counter_name(&mut schema, 0, string(b"EXCELLENT Counter"));
        assert!(schema.counter().get(0).get_name() == string(b"EXCELLENT Counter"));
 
        test_scenario::return_shared(schema);
        dapp.distroy_dapp_for_testing();
        scenario.end();
    }
}

This pattern is ideal for managing multiple objects (e.g., many counters, game items, or records) in a single schema, with each record identified by an id.

Comparison: Dubhe Engine vs. Only use Sui CLI Workflow

  1. Project Scaffolding & Structure:

    • Only use CLI: You’d start with sui move new <project_name>, then manually set up front-end scaffolding, network config, etc.
    • Dubhe: One command (pnpm create dubhe) gives you a fully integrated Move + React project with everything pre-configured.
  2. Defining Data and Boilerplate Code:

    • Only use CLI: Manually write Move structs, dynamic fields, event modules, and errors. Time-consuming and error-prone.
    • Dubhe: A single schema config + pnpm run schema:gen generates best-practice Move code automatically.
  3. CLI Tooling and Commands:

    • Only use CLI: Sui CLI covers compile, test, publish, but many tasks (key management, faucet requests) need manual steps or separate scripts.
    • Dubhe: Rich set of commands for devnets/testnets, local nodes, faucets, upgrading packages, and more, all in one place.
  4. Front-end Integration:

    • Only use CLI: You’d import low-level Sui JS SDKs, craft transactions by hand, parse returns.
    • Dubhe: Automatic TypeScript/React scaffolding and an SDK that surfaces your Move functions as typed methods (dubhe.tx.*, dubhe.query.*).
  5. Testing & Debugging:

    • Only use CLI: Rely on sui move test, possibly writing repeated boilerplate to set up scenario deployments.
    • Dubhe: Prebuilt test scaffolds (init_test modules) plus a CLI that can directly query or call functions on your contracts for quick checks.
  6. Upgradability and Maintenance:

    • Only use CLI: Manually handle dynamic fields, migrations, and upgrade paths.
    • Dubhe: Schema-based design with codegen that’s built for upgradability; a consistent structure for migrating and versioning.
  7. Multi-Chain Flexibility:

    • Only use CLI: Tools are often siloed (e.g., Sui CLI vs. Aptos CLI).
    • Dubhe: Designed for multiple Move-based chains, letting you reuse your approach on Sui, Aptos, Rooch, etc.

Practical Benefits

These are just some of the benefits of using Dubhe Engine as a development tool. But more than that, Dubhe Engine aims to evolve into a thriving developer ecosystem—one that supports all dApps built on it, incubates next-gen, high-quality Move applications, attracts more users, and grows the Move ecosystem together!

  • Faster Development Cycles: Schema-driven generation, integrated CLI, and front-end scaffolding let you spin up prototypes in minutes.
  • Reduced Errors and Boilerplate: Reliable codegen for dynamic fields, events, and errors, leaving you free to focus on the logic.
  • Seamless Full-Stack Development: One toolchain for both Move and React/Next.js front-ends, consistent type definitions, and easy transaction calls.
  • Better Developer Experience and Onboarding: Standardized structure, intuitive commands, less friction for new contributors.
  • Scaling to Complex Projects: The ECS-like approach (schemas + systems) scales nicely to large applications, making future upgrades and new features smoother.

Conclusion

Dubhe Engine amplifies Sui Move development by automating what can be automated, enforcing good design patterns, and providing an all-in-one toolkit for writing, deploying, and integrating on-chain logic. In this 101 guide, you’ve seen how quickly you can go from an empty folder to a functioning dApp—complete with a schema-driven Move contract, automated tests, and a web UI.

By extending the example to handle multiple fields, map-based data, or advanced event and error scenarios (as shown in the counter_with_name and counter_as_data extensions), you can appreciate Dubhe’s flexibility and power. Whether you’re a seasoned Move developer looking to shorten dev cycles or a newcomer wanting a more organized workflow, Dubhe Engine is a game-changer.

Happy building with Dubhe Engine!

For more details, check out the official Dubhe documentation and Dubhe's Github repos. If you have questions, the Dubhe Telegram Group is always ready to help you take your Sui or Aptos dApps to the next level.