A comprehensive tutorial on building your first DApp with Dubhe Engine, covering project setup, schema definition, Move contract development, and frontend integration.
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.
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
Before magic happens, please download the prerequisites:
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.
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.
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.
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:
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.
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];
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.
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.
counter_with_name
In this extension, we store two fields—name
and value
—plus corresponding events and a custom error. Here’s the Dubhe config and relevant Move code.
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:
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:
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:
#[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.
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.
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:
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
:
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:
#[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
.
Project Scaffolding & Structure:
sui move new <project_name>
, then manually set up front-end scaffolding, network config, etc.pnpm create dubhe
) gives you a fully integrated Move + React project with everything pre-configured.Defining Data and Boilerplate Code:
pnpm run schema:gen
generates best-practice Move code automatically.CLI Tooling and Commands:
Front-end Integration:
dubhe.tx.*
, dubhe.query.*
).Testing & Debugging:
sui move test
, possibly writing repeated boilerplate to set up scenario deployments.init_test
modules) plus a CLI that can directly query or call functions on your contracts for quick checks.Upgradability and Maintenance:
Multi-Chain Flexibility:
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!
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.