Framework Architecture
What is Dubhe Framework?
The Move contract code generated by schemagen does not run in isolation — it requires Dubhe Framework, a package already deployed on the Sui network. The Framework is the shared on-chain infrastructure that provides every Dubhe-built DApp with a storage engine, event system, fee metering, access control, and version management.
The relationship between the two layers:
Your DubheConfig (TypeScript)
│
▼ dubhe schemagen
Your contract code (Move) ──calls──▶ Dubhe Framework (deployed on-chain)
│
▼
DappHub (shared object)Architecture layers
┌─────────────────────────────────────────────────────────────┐
│ Your DApp package │
│ ┌──────────────────────────┐ ┌──────────────────────────┐ │
│ │ sources/systems/ │ │ sources/scripts/ │ │
│ │ hand-written logic │ │ deploy_hook / migrate │ │
│ └────────────┬─────────────┘ └──────────────────────────┘ │
│ │ calls │
│ ┌────────────▼──────────────────────────────────────────┐ │
│ │ sources/codegen/resources/ (auto-generated) │ │
│ │ health.move / stats.move / player_score.move … │ │
│ └────────────┬──────────────────────────────────────────┘ │
└───────────────┼─────────────────────────────────────────────┘
│ calls dubhe::dapp_system / dubhe::dapp_service
▼
┌─────────────────────────────────────────────────────────────┐
│ Dubhe Framework (deployed dubhe package) │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ System layer dubhe::dapp_system │ │
│ │ · set_record / set_field / delete_record │ │
│ │ · get_record / get_field / has_record │ │
│ │ · create_dapp / upgrade_dapp │ │
│ │ · charge_fee (write-fee metering) │ │
│ └──────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼────────────────────────────────┐ │
│ │ Core layer dubhe::dapp_service │ │
│ │ · DappHub (global shared object — unified store) │ │
│ │ · ObjectTable<AccountKey, AccountData> │ │
│ │ · dynamic_field reads and writes │ │
│ │ · emits SetRecord / DeleteRecord events │ │
│ └──────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼────────────────────────────────┐ │
│ │ Built-in resources │ │
│ │ dubhe::dapp_metadata / dapp_fee_state / dapp_fee_config│ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Utilities dubhe::entity_id / bcs / type_info / math │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Off-chain indexer / SDK (@0xobelisk/sui-sdk) │
│ · subscribe to Dubhe_Store_SetRecord events │
│ · call get_record / get_field to read on-chain data │
└─────────────────────────────────────────────────────────────┘DappHub: unified storage center
When the Framework is deployed, its init() function publishes a single DappHub object as a globally shared object. There is only one DappHub on-chain; every DApp built with Dubhe writes its state into this same object.
DappHub (global shared object)
└── accounts: ObjectTable<AccountKey, AccountData>
├── AccountKey { account: "0xABCD...", dapp_key: "my_project::dapp_key::DappKey" }
│ └── AccountData (dynamic_field container)
│ ├── key=["health"] → value=[BCS(100u32)]
│ └── key=["stats"] → value=[BCS(50u32), BCS(200u32)]
├── AccountKey { account: "0xABCD...", dapp_key: "another_game::dapp_key::DappKey" }
│ └── AccountData
│ └── key=["level"] → value=[BCS(5u32)]
└── ...Storage key composition: Each record is located by the (resource_account, dapp_key_type_name) pair, which identifies a unique AccountData container. Within that container, a [TABLE_NAME, ...extra_key_bytes] vector identifies the specific field. This design naturally isolates data from different DApps even though they share the same DappHub.
DappKey: type-level DApp identity
The dapp_key.move generated by schemagen defines a single empty struct:
public struct DappKey has copy, drop {}This type is your DApp’s unique identity within the Framework. All write and read operations use the fully-qualified type name of DappKey (package_id::module::DappKey) as a namespace prefix, which ensures:
- Data from different DApps is completely isolated — no collisions are possible.
- The
package_id()method extracts the current package address directly from the type name, used to locate theresource_accountfor global resources. - Write functions are annotated
public(package), so external packages cannot forge aDappKeyinstance to write unauthorized data.
How a generated resource module calls the Framework
Using a simple health: 'u32' config as an example, schemagen generates health.move. The core write path is:
health::set(dapp_hub, resource_account, 100u32, ctx)
│
├─ 1. encode: encode(100u32) → vector<vector<u8>>
│
├─ 2. call dapp_system::set_record(dapp_hub, DappKey{}, ["health"], [[100u32_bcs]], account, false, ctx)
│ │
│ ├─ 2a. dapp_service::set_record(...) ← actually writes to DappHub dynamic_field
│ │ └─ emits Dubhe_Store_SetRecord event
│ │
│ └─ 2b. charge_fee(...) ← deducts storage credits by byte count
│
└─ doneThe read path calls dapp_service::get_record / get_field directly (bypassing dapp_system, incurring no fee), returns raw BCS bytes, and deserializes them into native Move types in the generated code.
The five capabilities of the Framework
1. Zero infrastructure: unified storage
Developers do not need to define Sui objects, manage UIDs, or handle ObjectTable setup. The Framework provides a ready-to-use key-value store. Generated resource modules only need &mut DappHub to read and write, dramatically lowering the Move development barrier.
2. Real-time off-chain sync: event-driven
Every call to set_record or delete_record automatically emits a structured event:
public struct Dubhe_Store_SetRecord has copy, drop {
dapp_key: String, // which DApp
account: String, // which account
key: vector<vector<u8>>, // which table
value: vector<vector<u8>>, // new value
}The SDK’s real-time subscription feature is built on these events, allowing off-chain applications (game frontends, dashboards, indexers) to detect on-chain state changes in near real time without polling.
3. Built-in DApp lifecycle management
Through the run() entry point in genesis.move, the contract automatically registers the DApp in DappHub on deployment, writing:
- DappMetadata — name, description, admin address, current version, and the list of authorized package IDs.
- DappFeeState — initial storage credits, plus cumulative bytes written and operation counts for analytics.
The version constant in migrate.move works with the Framework’s ensure_latest_version() check to provide a safe upgrade path: old package versions can no longer write, forcing users to migrate to the latest version.
4. Storage fee metering
dapp_system::set_record includes automatic fee metering. Each write deducts credits using this formula:
fee = (total_bytes_written × byte_fee + base_fee) × operation_countCredits are drawn first from the free allocation (free_credit, granted when a new DApp registers) and then from the paid balance once that is exhausted. This lets Dubhe sustainably maintain on-chain infrastructure while reducing early-developer costs through the free allocation.
5. Offchain mode: minimal-cost data publishing
When a resource is configured with offchain: true, the Framework skips the ObjectTable write and only emits an event. The data is received by an off-chain indexer and stored in a database. This is extremely valuable for high-frequency update scenarios (real-time game state, logs) — it avoids the high gas cost of on-chain storage while preserving blockchain auditability.
Deployment flow and Framework interaction
Developer runs dubhe publish
│
▼
1. Sui network deploys your Move package
│
▼
2. genesis::run(dapp_hub, clock, ctx) is called
│
├─ 2a. dapp_system::create_dapp(dapp_hub, DappKey{}, name, description, clock, ctx)
│ ├─ dapp_metadata::set(...) ← registers DApp metadata in DappHub
│ └─ dapp_fee_state::set(...) ← initializes storage credit account
│
└─ 2b. deploy_hook::run(dapp_hub, ctx)
└─ your custom initialization logic
(e.g., initialize global config, mint initial assets)
│
▼
3. DApp is live; resource modules are ready for reads and writesOn-chain storage layout example
Using player_stats (a keyed multi-field resource) to illustrate the complete on-chain storage layout:
// DubheConfig
resources: {
player_stats: {
fields: { player: 'address', attack: 'u32', hp: 'u32' },
keys: ['player']
}
}After calling set(dapp_hub, resource_account, player_addr, 50u32, 200u32, ctx), the internal DappHub structure is:
DappHub.accounts
└── AccountKey {
account: resource_account, // entity address passed by caller
dapp_key: "0xPKG::dapp_key::DappKey"
}
└── AccountData.dynamic_fields
└── key = [b"player_stats", BCS(player_addr)]
↑ ↑
TABLE_NAME BCS-encoded keys fields
value = [BCS(50u32), BCS(200u32)]
↑ ↑
attack hp (non-key value fields, in declaration order)get_field reads a single field by its index (field_index: u8), avoiding full-record deserialization for efficient partial updates.
Generated file structure
After running dubhe schemagen on a project named my_project, the output directory looks like:
src/my_project/
├── Move.toml
└── sources/
├── codegen/ # auto-generated — do not edit
│ ├── genesis.move
│ ├── init_test.move
│ ├── dapp_key.move
│ ├── errors.move
│ ├── resources/
│ │ ├── health.move
│ │ └── stats.move
│ └── enums/
│ └── direction.move
├── scripts/
│ ├── deploy_hook.move # editable: post-deploy initialization
│ └── migrate.move # editable: upgrade version tracking
├── systems/ # editable: game / app business logic
└── tests/ # editable: Move unit testsRegeneration rules: Only sources/codegen/resources/ is fully regenerated on every schemagen run. All other files are only created if they do not already exist — user-editable files are never overwritten.