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
public
: Automatically generates a getter function, allowing external contracts to read the value. Direct internal access (e.g.,x
) reads from storage; external access (e.g.,this.x
) calls the generated getter.internal
: Accessible only within the defining contract and derived contracts. Default visibility for state variables.private
: Like internal, but not visible in derived contracts.
⚠️ 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
external
: Part of the contract interface. Can be called from other contracts or transactions. Not callable internally unless usingthis.f()
.public
: Callable both externally and internally.internal
: Only accessible within the current or derived contracts. Not exposed via ABI.private
: Internal access only, not visible to derived contracts.
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.
constant
: Value must be known at compile time. No runtime assignment allowed.immutable
: Assigned once during construction (in constructor or declaration), then fixed.
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:
- Access storage or blockchain data (
block.timestamp
,msg.value
) - Call external contracts
- Perform dynamic memory allocation with side effects
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:
view
: Promises not to modify state. Compiler usesSTATICCALL
.pure
: Promises not to read or modify state. Useful for utility functions.- Reading state includes accessing
block
,tx
, ormsg
members (exceptmsg.data
andmsg.sig
).
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 (nonpayable
→ view
→ pure
).
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
- Abstract contracts: Contain unimplemented functions or missing constructor args. Cannot be deployed directly.
- Interfaces: Cannot implement functions, have state vars, constructors, or modifiers. All functions are
external
.
Interfaces support inheritance and help standardize interactions across dApps.
Libraries
Libraries are reusable code modules deployed once and shared across contracts via DELEGATECALL
. They:
- Cannot hold ether
- Cannot have state variables
- Cannot self-destruct
- Execute in caller’s context (
this
refers to caller)
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.