DEV Community

Meriç Cintosun
Meriç Cintosun

Posted on • Originally published at mericcintosun.com

Sui Move Fundamentals for Developers: Object-Oriented Blockchain Architecture

Understanding Sui's Object Model

Ethereum pioneered the account-based model, where smart contracts maintain global state tied to addresses, and all state transitions flow through centralized account abstractions. This architecture has served the ecosystem well, but it forces a mental model where "everything is a balance" or "everything is a mapping." Sui departs fundamentally from this approach by adopting an object-centric paradigm where blockchain state consists of discrete, independently-owned objects rather than accounts holding collections of data.

In Sui, objects are first-class citizens. Each object has an immutable ID, a type, an owner (which can be an address, another object, or shared), and version metadata. When you transfer an object, you are not updating a central ledger; you are changing which address holds the reference to that object. This distinction matters because it enables parallel execution of transactions that touch different objects, whereas account-based systems must serialize all transactions that involve the same account.

The object model also changes how you think about data access control. In Ethereum, a smart contract controls access to data through function modifiers and permission checks. In Sui, ownership is enforced by the blockchain itself. If you own an object, you can include it as a mutable input to a transaction. If you do not own it, the network rejects the transaction at the VM level, before any code executes. This shift moves security enforcement from the contract layer to the consensus layer.

The Move Language: Linearity and Type Safety

Move is the language that powers Sui's smart contracts, but Move originated at Facebook (now Meta) as a language designed around linear types and resource semantics. Unlike Solidity, where a variable can be copied, referenced, or modified freely, Move enforces strict rules about how values move through code. Every value must be used exactly once or explicitly ignored. This is not a style choice; it is enforced by the compiler.

To understand Move's power, consider a common vulnerability in Solidity: double-spending within the same transaction. A Solidity contract might check a user's balance, transfer tokens to them, and then later in the same function use that balance again because the state update happens asynchronously. Move prevents this at compile time. Once you pass a value to a function or bind it to a new variable, you can no longer use the old binding. The compiler rejects the code before it ever runs.

Move's type system distinguishes between three categories of values: copy types, drop types, and resource types. Copy types (like booleans and integers) can be duplicated implicitly. Drop types can be discarded implicitly. Resource types must be explicitly handled; they cannot be copied or dropped. When you define a struct as a resource in Move, the compiler ensures that every instance is accounted for. You cannot accidentally lose a resource or create copies without explicit permission. This is why Move is often called a "resources first" language.

The linear type system has profound implications for security. Integer overflows, a plague in earlier smart contracts, are caught by Move's type checker in safe mode. Reentrancy exploits become structurally impossible because once you pass mutable access to a value into a function, you lose the ability to access it elsewhere in your current call stack. Notional problems like "what if this contract balance increases unexpectedly during a call" are eliminated at the language level.

Objects and Abilities in Move

Move objects are structs that can carry capabilities, called abilities. These abilities determine what the compiler allows you to do with instances of that struct. The four abilities are copy, drop, store, and key. A struct with the copy ability can be silently duplicated. A struct with drop can be silently discarded. The store ability determines whether a struct can be stored as a field inside another struct. The key ability signals that this struct is an on-chain object that can be owned and transferred.

Most on-chain objects in Sui are defined with the key ability and lack copy. This forces the compiler to track unique instances. When you create a resource struct, you are creating something that the blockchain will monitor. Only one address can hold it at a time. You cannot replicate it through normal assignment. If you want to transfer it, you must explicitly call a function that changes its ownership.

Consider a simple example: defining an asset that represents a claim or a digital item.

module example::asset {
    use sui::object::{Self, UID};
    use sui::tx_context::{Self, TxContext};

    struct Asset has key {
        id: UID,
        value: u64,
    }

    public fun create_asset(value: u64, ctx: &mut TxContext) -> Asset {
        Asset {
            id: object::new(ctx),
            value,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This struct has the key ability, which means instances live on the blockchain and have identity. The id field is required; every key struct must have a UID field named id. Without the copy ability, passing this struct into a function transfers ownership rather than duplicating it. If a function consumes the Asset (takes it by value), the caller loses access to that instance.

The ability system is more expressive than it might first appear. It directly maps to what the Move type system can enforce. By removing the copy ability from an Asset struct, we tell the compiler: "every instance of this struct matters, track it carefully." By including the key ability, we tell the blockchain: "this is something that someone owns, index it and make it transferable."

Ownership and Access Control

Ownership in Sui is literal and enforced by the protocol. When a transaction creates an object, that object becomes owned by the transaction sender or by an address specified in the transaction. Ownership is not a convention that the contract checks; it is a property that the network verifies. If you are not the owner of an object, you cannot include it as a mutable input to any transaction.

This creates a radically different access control model than Ethereum. In Ethereum, you call a function and the function checks permission. In Sui, you construct a transaction, and if you lack the necessary ownership, the transaction fails before execution. The blockchain rejects it at the networking layer, before consensus even considers it.

Shared objects are Sui's mechanism for public state. When you make an object shared, any address can include it in a transaction. Shared objects require consensus to coordinate access, so transactions affecting shared objects may be serialized. Owned objects, by contrast, can be modified in parallel because ownership prevents conflicts. This architectural choice means developers must think about which data should be shared (requiring consensus coordination) and which should be owned (enabling parallel execution).

An owned object can also be transferred to another address or wrapped inside another object. Wrapped objects are nested within their parent and cannot be accessed directly; any operation on them must go through the parent. This nesting captures complex ownership hierarchies and permission patterns without requiring runtime checks in contract code. The type system enforces it.

Transactions and Inputs in Sui

Sui transactions are explicitly structured. You specify which objects you want to read, which you want to mutate, and which are pure inputs (like numbers or strings). The transaction declares its needs upfront, and the blockchain verifies that you have the right to access what you declared.

A basic transaction might look like this in pseudocode:

Transaction {
    inputs: [
        object_id_1 (mutable),
        object_id_2 (immutable),
        0x123456... (immutable pure value)
    ],
    operations: [
        call module::contract::function
    ]
}
Enter fullscreen mode Exit fullscreen mode

Before execution, Sui's transaction validation layer checks: does the sender own object_id_1? Is object_id_2 shared or owned by the sender? The pure value (a static address) requires no permission. Only if all checks pass does the transaction enter execution.

This explicit input specification enables Sui to parallelize transaction execution aggressively. The network can run thousands of transactions in parallel if they touch disjoint sets of objects. Ethereum achieves sequentiality for safety; Sui achieves parallelism without sacrificing safety because ownership prevents conflicts by construction.

Within a transaction, you pass objects to functions by reference or by value. Passing by value transfers ownership. Passing by immutable reference (&T) allows reading but not mutation. Passing by mutable reference (&mut T) allows both reading and mutation, and this is how you modify objects within a transaction.

Resource Safety and Scarcity

Move's linear type system creates genuine scarcity on the blockchain. A resource cannot be duplicated, lost, or created out of thin air. Every token, every NFT, every key or certificate must flow through the system via explicit operations. This is enforced by the compiler, not by contract logic.

Consider a token type:

module example::token {
    use sui::object::{Self, UID};
    use sui::tx_context::TxContext;

    struct Token has key, store {
        id: UID,
        amount: u64,
    }

    public fun transfer(token: Token, to: address, ctx: &mut TxContext) {
        transfer::public_transfer(token, to);
    }

    public fun merge(t1: Token, t2: Token): Token {
        Token {
            id: t1.id,
            amount: t1.amount + t2.amount,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, if you want to merge two tokens, you must write explicit logic to handle the merge. Once merged, the first token's ID is kept and the second is discarded. The compiler accepts this because we explicitly destroyed t2. There is no hidden path where t2 persists. There is no "ghost balance" that could linger unnoticed.

If someone tries to call merge but then later use t2 again, the compiler rejects it with an error. The borrow checker catches this before the transaction is submitted. This guarantees at the language level that Token instances are accounted for.

This contrasts sharply with Solidity, where token contracts use a mapping to track balances. If a contract has a bug in that mapping logic, tokens can be created or destroyed silently. The system has no intrinsic guarantee that total supply is preserved. With Move tokens, scarcity is a language property, not a contract invariant.

Smart Contract Patterns and Best Practices

Building robust Sui contracts requires understanding how Move's features map to common patterns. The most important pattern is the separation between owned and shared state. Owned objects should be used for user-specific data: wallets, inventories, positions. Shared objects should be reserved for truly shared state: token reserves, global counters, orderbooks.

A practical example is a simple escrow contract. In Ethereum, you might have a single contract that holds all escrowed assets in a mapping. In Sui, each escrow agreement is a separate object:

module example::escrow {
    use sui::object::{Self, UID};
    use sui::tx_context::{Self, TxContext};
    use sui::transfer;

    struct EscrowAgreement<T: store> has key {
        id: UID,
        item: T,
        seller: address,
        buyer: address,
        price: u64,
    }

    public fun create_escrow<T: store>(
        item: T,
        seller: address,
        buyer: address,
        price: u64,
        ctx: &mut TxContext,
    ): EscrowAgreement<T> {
        EscrowAgreement {
            id: object::new(ctx),
            item,
            seller,
            buyer,
            price,
        }
    }

    public fun release<T: store>(
        escrow: EscrowAgreement<T>,
        _payment: Token,
        ctx: &mut TxContext,
    ): T {
        let EscrowAgreement { id, item, seller: _, buyer: _, price: _ } = escrow;
        object::delete(id);
        transfer::public_transfer(item, tx_context::sender(ctx));
        item
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, each escrow is a distinct object. The seller or a mediator can verify the agreement's terms before releasing it. Because the item is stored within the EscrowAgreement, it cannot be accessed or moved except through this contract's functions. The type system ensures that.

The use of generics (like <T: store>) is powerful in Sui. A single escrow contract can hold any type of asset, as long as that type has the store ability. This avoids the code duplication and bytecode bloat that comes from writing separate contracts for each asset type.

Comparing to Account-Based Models

The conceptual gap between object-based and account-based systems manifests clearly in token handling. An Ethereum ERC-20 token is a contract that maintains a mapping from addresses to balances. Transferring requires calling the token contract, which updates the mapping. Parallel execution is difficult because every transfer touches shared state.

A Sui token, by contrast, is a distributed collection of owned objects. Each Token object is owned by a specific address. Transferring a token means changing its owner field, which is an operation on that specific object. Thousands of different token transfers can happen in parallel if they involve different token objects.

From a developer experience perspective, this means Sui contracts are smaller and more focused. You do not need to build elaborate permission systems because ownership is enforced at the protocol level. You do not need to worry about reentrancy because once you pass an object to another function, you cannot access it in your current scope. You do not need to track "total supply" in a central location because the language guarantees that resources are conserved.

The learning curve is inverted compared to Ethereum. Beginners can write safer contracts faster because the language prevents whole categories of vulnerabilities. But developers must unlearn some habits: there is no centralized state to query, no permission modifiers to write, no balance checks that might be bypassed. Instead, developers work with object references and move values explicitly through call chains.

Practical Development Workflow

Setting up a Sui development environment requires the Sui CLI and a local test validator. The typical workflow involves writing Move modules, testing them with Sui's built-in testing framework, and then deploying to testnet or mainnet.

Testing in Sui is more explicit than in Ethereum. You construct test transactions, supply the objects they need, and verify the results. The Sui CLI provides a REPL where you can interact with live contracts on testnet, inspect object state, and simulate transactions before paying gas.

#[test]
fun test_escrow_creation() {
    let mut scenario = test_scenario::begin(@0x1);
    let ctx = test_scenario::ctx(&mut scenario);

    let item = MyItem { id: object::new(ctx), value: 100 };
    let escrow = create_escrow(
        item,
        @0x1,
        @0x2,
        1000,
        ctx,
    );

    assert!(escrow.price == 1000, 0);
    test_scenario::end(scenario);
}
Enter fullscreen mode Exit fullscreen mode

The test_scenario module simulates a blockchain environment where you control who is performing transactions. You begin a scenario with a specific sender address, obtain the transaction context, and execute contract functions. After each step, you can inspect the resulting objects and verify invariants.

Gas costs in Sui are predictable because the network computes them upfront based on transaction size and storage usage. Unlike Ethereum, where gas depends on the execution path taken, Sui charges based on the computational work declared in the transaction. This makes gas budgeting straightforward and eliminates the class of surprises where a transaction costs more than expected.

Leveraging Move's Strengths in Production

Production Sui contracts should follow patterns that leverage the language's safety guarantees. First, use owned objects for user-specific data. Do not create shared objects unless you need global coordination. Second, use generic types to write reusable contracts that work with multiple asset types. Third, use the module system to organize code logically; Move packages can contain multiple modules, and modules can depend on each other to form coherent abstractions.

One pattern that simplifies contract design is the "Capability Object." A capability is a token (a struct with the key ability) that grants permission to perform an action. For example, an admin capability object proves that the holder is an administrator. Only the initial deployer receives this object, and they can transfer it to others or destroy it. This pattern moves authorization from function-level checks to object-level proofs.

module example::admin {
    use sui::object::{Self, UID};
    use sui::tx_context::TxContext;

    struct AdminCap has key {
        id: UID,
    }

    public fun create_admin_cap(ctx: &mut TxContext): AdminCap {
        AdminCap {
            id: object::new(ctx),
        }
    }

    public fun verify_admin(_cap: &AdminCap) {
        // If we reach here, the caller passed a valid AdminCap
    }
}
Enter fullscreen mode Exit fullscreen mode

Functions that require admin access take a reference to AdminCap as a parameter. Only someone who owns the AdminCap object can call these functions. The blockchain enforces this at the transaction validation layer. No runtime permission check is needed.

The Security Implications of Structural Enforcement

The shift from runtime permission checks to compile-time and protocol-level enforcement has profound security implications. Many of the vulnerabilities that plagued early smart contracts are impossible in Move because the language prevents them at compile time. You cannot accidentally reuse a value after passing it to another function. You cannot overflow an integer in safe mode. You cannot create or destroy resources outside of explicit operations.

This does not mean Sui contracts are automatically secure. Logic errors are still possible. An escrow contract could have a bug in its release conditions. A token contract could mint more than intended if the minting logic is incorrect. But entire classes of vulnerabilities are eliminated by design. The developer can focus on business logic rather than fighting the language.

Testing practices should reflect this. Because the type system prevents many bugs, tests can focus on correctness of business logic rather than defensive coding. You do not need exhaustive tests for integer overflow scenarios because Move prevents overflow at compile time. You can write clearer tests that specify what should happen in happy-path and edge cases, knowing the language is watching for memory safety and resource leaks.

The object model also simplifies auditing. Because objects are self-contained and ownership is explicit, an auditor can trace value flows through the contract more directly. There is no hidden state scattered across multiple storage slots. No ambiguous permission logic buried in modifiers. Objects and functions declare their inputs and outputs explicitly, making the contract's surface area clear.

Professional Web3 documentation and full-stack Next.js development work are available through my Fiverr profile at https://fiverr.com/meric_cintosun, should your project require technical writing or application development expertise.

Top comments (0)