Solidity Contracts: A Developer's Guide to Building on Ethereum

·

Solidity contracts form the backbone of decentralized applications (dApps) on Ethereum and other EVM-compatible blockchains. These self-executing programs manage state, define business logic, and enable trustless interactions between users and systems. Understanding how to structure, deploy, and interact with contracts is essential for any blockchain developer.

This guide dives deep into the core concepts of Solidity contracts, covering everything from basic syntax to advanced patterns like inheritance, libraries, and visibility controls. Whether you're building tokens, decentralized exchanges, or NFT marketplaces, mastering these fundamentals will empower you to write secure, efficient, and maintainable smart contract code.

What Are Smart Contracts?

In Solidity, a contract resembles a class in object-oriented programming languages. It encapsulates persistent data through state variables and defines functions that modify this data. Each contract instance exists independently on the blockchain, and calling a function on one contract triggers an EVM function call—switching execution context and isolating its state from others.

Unlike traditional server-based systems, there’s no built-in scheduler like "cron" to automatically execute contract functions at specific times. Contracts must be explicitly invoked via transactions or other contract calls to perform actions. This event-driven model ensures predictability and security but requires careful design for time-based operations.

👉 Discover how real-world dApps use smart contracts by exploring leading blockchain platforms.

Creating Contracts

Contracts can be deployed either externally—via Ethereum transactions—or internally from within another Solidity contract. Development environments like Remix provide UI tools to simplify deployment, while programmatic creation is often done using JavaScript libraries such as web3.js or ethers.js.

When a contract is created, its constructor function executes once. This optional function initializes state variables and runs setup logic. Only one constructor is allowed per contract—function overloading isn't supported for constructors.

After the constructor finishes, the final contract bytecode is stored on-chain. This includes all public and external functions, plus any internal functions reachable through them. However, the constructor code itself and any functions called exclusively during construction are excluded from the deployed runtime code.

Internally, constructor arguments are ABI-encoded and appended after the contract bytecode. While this detail is abstracted away when using high-level tools like web3.js, it's crucial for low-level deployment scripts.

A key limitation: if a contract wants to create another, it must know the source and compiled binary of the target contract. This prevents circular dependencies in contract creation.

Visibility and Getter Functions

Solidity provides fine-grained control over how state variables and functions can be accessed through visibility specifiers.

State Variable Visibility

⚠️ Note: Marking variables as private or internal only prevents other contracts from reading them—it does not hide data from off-chain observers. All blockchain data is public.

Function Visibility

Visibility keywords appear after parameter lists and before return types.

Automatically Generated Getters

The compiler automatically creates getter functions for public state variables. For example:

uint public data = 42;

Generates a function equivalent to:

function data() external view returns (uint) {
    return data;
}

For arrays, getters return individual elements (e.g., myArray(0)), avoiding expensive full-array returns. To return the entire array, define a custom function:

uint[] public myArray;
function getArray() public view returns (uint[] memory) {
    return myArray;
}

Function Modifiers

Modifiers offer a declarative way to alter function behavior—commonly used for access control or preconditions.

They are inheritable and can be overridden if marked virtual. The special _ symbol indicates where the modified function’s body should be inserted.

Example: restricting access to the contract owner:

modifier onlyOwner {
    require(msg.sender == owner, "Only owner can call this");
    _;
}

Multiple modifiers are evaluated in order, separated by spaces. Parameters can be passed just like functions.

⚠️ Warning: In early Solidity versions, return statements inside modifiers behaved differently. Always test modifier logic thoroughly.

Constant and Immutable State Variables

These optimize gas costs by eliminating storage writes.

Both avoid storage slots—their values are embedded directly into bytecode.

Supported types include value types and strings (for constants). Complex types like mappings aren't supported yet.

Constant Rules

Expressions for constant variables cannot:

Allowed: keccak256, sha256, addmod, etc.

Immutable Initialization

Immutable variables are initialized when the constructor runs—even if declared with a default value. You can assign them in the declaration or constructor, but not both unless using inheritance.

Functions in Solidity

Functions can be defined inside or outside contracts. Those outside—called free functions—are implicitly internal and their code is included directly in calling contracts.

Parameters and Return Values

Functions accept typed parameters and can return multiple values:

function arithmetic(uint a, uint b)
    public
    pure
    returns (uint sum, uint product)
{
    return (a + b, a * b);
}

Return variables are initialized to default values (0, false, empty strings). You can assign them explicitly or use a direct return statement.

⚠️ Note: Some types (mappings, storage references) cannot be returned from non-internal functions due to ABI limitations.

State Mutability

Controls whether a function reads or modifies state:

Calling non-view/non-pure functions invalidates pure.

Special Functions

Receive Ether Function

Declared as:

receive() external payable { ... }

Executed when sending ether with empty calldata (e.g., .send() or .transfer()). Limited to ~2300 gas—enough for logging but not storage writes.

Fallback Function

Declared as:

fallback() external [payable]

or

fallback(bytes calldata input) external [payable] returns (bytes memory)

Called when no other function matches. If marked payable, it handles plain ether transfers if no receive function exists.

Use cautiously—it can mask interface issues.

Events

Events provide a way to log data efficiently using Ethereum’s logging system. Clients can listen via RPC subscriptions.

Up to three parameters can be indexed, making them searchable as topics. Unindexed parameters are ABI-encoded in data.

Example:

event Deposit(address indexed from, bytes32 id, uint value);
emit Deposit(msg.sender, id, msg.value);

Anonymous events save gas but lose signature filtering capability.

Errors and Revert Statements

Custom errors improve user experience by explaining why an operation failed:

error InsufficientBalance(uint available, uint required);
if (amount > balance) revert InsufficientBalance(balance, amount);

Errors are ABI-encoded with a 4-byte selector (first 4 bytes of keccak256 hash of signature). They’re cheaper than string reasons in require.

Inheritance and Overriding

Solidity supports multiple inheritance with C3 linearization to resolve method resolution order.

Use virtual in base functions and override in derived ones. Visibility can change from external to public; mutability can become stricter (nonpayableviewpure).

Constructors of base contracts must have their arguments provided—either inline (is Base(7)) or via modifier-style syntax in derived constructors.

⚠️ Warning: Calling base functions directly (e.g., Base.f()) bypasses overrides. Use super.f() for proper polymorphism.

Abstract Contracts and Interfaces

Interfaces support inheritance and help standardize interactions across dApps.

Libraries

Libraries are reusable code modules deployed once and shared across contracts via DELEGATECALL. They:

Internal library functions are inlined; public ones use delegate calls.

Use using A for B; to attach library functions as member methods on types—enabling intuitive syntax like myArray.indexOf(x).

👉 See how top developers leverage libraries for scalable dApp architectures.

Frequently Asked Questions

What is the difference between constant and immutable variables?

constant values must be known at compile time and cannot depend on runtime data. immutable values can be set during construction but cannot change afterward. Both reduce gas costs compared to regular state variables.

Can a contract automatically execute a function at a specific time?

No. Ethereum has no built-in scheduler. To trigger time-based actions, external services (like keepers or oracles) must send transactions when conditions are met.

How do I return an entire array from a public state variable?

By default, Solidity only allows fetching individual elements via auto-generated getters. To return the full array, write a custom view function that returns the array in memory.

Why use custom errors instead of revert strings?

Custom errors use less gas than string-based reverts because they encode data more efficiently using 4-byte selectors and structured parameters rather than UTF-8 strings.

What happens if two base contracts define the same function?

You must explicitly override it in the derived contract using the override keyword with all conflicting base contracts listed: override(Base1, Base2).

How does using for improve code readability?

It allows library functions to be called as if they were native methods on a type—for example, data.insert(value) instead of Set.insert(data, value)—making code more intuitive and object-oriented in style.

👉 Learn how professional teams build secure and efficient smart contracts today.