Solidity contracts serve as the building blocks of decentralized applications on Ethereum and other EVM-compatible blockchains. Similar to classes in object-oriented programming languages, these contracts encapsulate persistent data through state variables and define functions that modify this data. When one contract calls a function in another, an EVM-level function call occurs, switching execution context and making the caller’s state variables inaccessible. Every action within the Ethereum ecosystem must be initiated by a transaction or contract call—there is no automated scheduling mechanism like "cron" jobs.
Creating and Deploying Solidity Contracts
Contracts can be instantiated either externally via Ethereum transactions or programmatically from within other Solidity contracts. Development environments such as Remix streamline contract creation using intuitive UI tools. For programmatic deployment, developers often use JavaScript libraries like web3.js, which provide helper methods such as web3.eth.Contract to simplify the deployment process.
When a contract is deployed, its constructor—defined with the constructor keyword—executes exactly once. Constructors are optional, and only one can exist per contract (overloading is not supported). After execution, the final compiled bytecode is stored on the blockchain. This includes all public and external functions, along with any internal functions reachable from them. Notably, the constructor code itself and purely internal functions not called elsewhere are excluded from the deployed bytecode.
Internally, constructor arguments are ABI-encoded and appended to the contract’s bytecode, though developers using high-level tools like web3.js typically don’t need to manage this manually. If a contract creates another contract, the source and binary of the new contract must already be known, preventing cyclic dependencies between creators.
Visibility and Access Control in Contracts
Solidity provides fine-grained control over how state variables and functions can be accessed, using visibility specifiers.
State Variable Visibility
public: Automatically generates a getter function, allowing external contracts to read the value. Internal access uses direct storage lookup (x), while external access (this.x) invokes the generated getter.internal: Default for state variables; accessible only within the defining contract and its derived contracts.private: Restricted to the defining contract only, even derived contracts cannot access it.
Warning: Evenprivateorinternaldata is visible on the blockchain to anyone with access to node data. These modifiers only restrict on-chain execution access, not off-chain visibility.
Function Visibility
external: Part of the contract interface; callable from other contracts or transactions. Cannot be called internally usingf(), butthis.f()works.public: Callable both internally and externally.internal: Accessible only within the current or derived contracts; not exposed via ABI.private: Only accessible within the defining contract.
These visibility rules help enforce security boundaries while enabling modular design patterns across contract systems.
Getter Functions for Public State Variables
The Solidity compiler automatically generates getter functions for public state variables. For example, a public variable uint public data = 42; results in a generated function data() that returns 42. These getters have external visibility.
For array-type public variables, only individual elements can be retrieved via the auto-generated getter (e.g., myArray(0)), due to gas efficiency concerns. To return the entire array, a custom function must be written:
uint[] public myArray;
function getArray() public view returns (uint[] memory) {
return myArray;
}This approach prevents expensive full-array reads by default while still allowing them when explicitly needed.
Function Modifiers: Reusable Behavior Patterns
Modifiers offer a declarative way to alter function behavior—commonly used for access control or precondition checks.
For example:
modifier onlyOwner {
require(msg.sender == owner, "Only owner can call this");
_;
}
function changePrice(uint newPrice) public onlyOwner {
price = newPrice;
}The _ symbol acts as a placeholder where the modified function’s body is inserted. Multiple modifiers are applied left-to-right and evaluated sequentially.
Modifiers can accept parameters, support inheritance (if marked virtual), and even appear multiple times within a single modifier definition—each _ replaced by the full function body.
Advanced State Management: Transient Storage
Introduced via EIP-1153, transient storage offers a temporary key-value store scoped to a single transaction. Unlike regular storage, values reset after each transaction, making it ideal for reentrancy guards or temporary flags without permanent gas costs.
Key characteristics:
- Requires Cancun-hardfork-enabled EVM.
- Lower gas cost than persistent storage.
- Not initialized at declaration (value cleared post-transaction).
- Private to the owning contract.
- Subject to revert semantics: writes rollback if the calling frame reverts.
However, misuse can break composability. For instance, storing transient state between external calls in a transaction may lead to unexpected behavior if those calls are split across transactions.
Best Practices for Transient Storage
- Always clear transient variables at the end of execution.
- Avoid replacing memory mappings unless lifecycle is tightly controlled.
- Use primarily for reentrancy protection with proper cleanup.
Constant and Immutable State Variables
These optimize gas usage by avoiding runtime storage writes.
constant
- Value fixed at compile time.
- No storage slot assigned.
- Supports strings and value types.
- Expression must not depend on blockchain state (
block.timestamp,msg.value, etc.).
Example:
uint constant X = 32**22 + 8;
string constant TEXT = "abc";immutable
- Assigned once during construction.
- Value copied into runtime bytecode.
- Can reference blockchain context during initialization (e.g.,
address owner = msg.sender;). - More flexible than
constant, but slightly more costly for small values due to 32-byte allocation.
Use cases include setting initial parameters like decimals, owner addresses, or max supply that should never change post-deployment.
Custom Storage Layout
Developers can define custom base slots for contract storage using the layout at <slot> syntax:
contract C layout at 0xAAAA + 0x11 {
uint[3] x; // Occupies slots 0xAABB..0xAABD
}This feature allows precise control over storage placement—useful for upgradeable contracts or interoperability with low-level systems. However:
- Only allowed in top-level contracts.
- Cannot cause storage overflow ("wrap around").
- Does not affect transient variables.
- Base slot must be a compile-time constant.
Functions: Structure and Mutability
Functions can be defined inside or outside contracts. Free functions (outside contracts) have implicit internal visibility and their code is inlined wherever called.
Function Parameters and Returns
Functions accept typed inputs 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 and can be reassigned before return.
Certain types (mappings, storage references) cannot be returned from non-internal functions due to ABI limitations.
State Mutability: view and pure
view: Guarantees no state modifications. Enforced at EVM level usingSTATICCALL.pure: Promises no state reads or writes. Also usesSTATICCALL, though reading cannot be fully enforced on-chain.
Operations considered state-modifying include:
- Writing to storage/transient storage
- Emitting events
- Creating contracts
- Sending Ether
- Calling non-view/pure functions
While pure prevents state modification, it cannot fully prevent state reading at EVM level—this remains a compile-time check.
Special Functions: Fallbacks and Receivers
Receive Ether Function
Defined as receive() external payable, it triggers on empty calldata transfers (e.g., .send() or .transfer()). Limited to ~2300 gas—only enough for logging or simple operations.
Fallback Function
Declared as fallback() external [payable], it handles unmatched function calls or empty data. With parameters: fallback(bytes calldata input) external [payable] returns (bytes memory) allows decoding raw input using abi.decode(input[4:], (...)).
Prefer defining both receive and payable fallback to distinguish Ether transfers from interface mismatches.
Events and Custom Errors
Events
Abstractions over EVM logs, events allow off-chain apps to monitor on-chain activity via RPC subscriptions. Up to three parameters can be indexed, turning them into searchable topics.
Example:
event Deposit(address indexed from, bytes32 indexed id, uint value);
emit Deposit(msg.sender, id, msg.value);Non-anonymous events include a topic with their signature hash (keccak256("Deposit(...)")). Anonymous events save deployment cost but allow four indexed fields instead of three.
Custom Errors
Gas-efficient error signaling introduced in Solidity 0.8.4:
error InsufficientBalance(uint available, uint required);
if (amount > balance) revert InsufficientBalance(balance, amount);Or with require:
require(amount <= balance, InsufficientBalance(balance, amount));Custom errors are ABI-encoded like function calls (4-byte selector + encoded args) and can be caught in try-catch blocks—but only from external calls.
Inheritance and Polymorphism
Solidity supports multiple inheritance with C3 linearization. The most-derived override executes by default when calling virtual functions.
Use:
virtual: To allow overriding in child contracts.override: To explicitly override a parent function.super.functionName(): To call the next function in the inheritance hierarchy (recommended over explicit base calls to ensure correct order).
Avoid patterns like Base1.function() in multi-inheritance scenarios—they bypass intermediate overrides. Use super.emitEvent() instead for predictable execution flow across complex hierarchies.
Frequently Asked Questions
Q: What happens if I try to call a function on a contract with no matching signature?
A: The fallback function will execute if defined. If there's no fallback or it's not payable, plain Ether transfers will fail unless a receive function exists.
Q: Can I return dynamic arrays from public state variables?
A: No—the auto-generated getter only allows element-by-element access (e.g., arr(0)). To return the full array, write a custom function that returns memory copy.
Q: How does transient storage differ from memory?
A: Memory is per-call and erased after execution; transient storage persists across internal calls within a single transaction but resets afterward. It's cheaper than storage but not composable across transactions.
Q: Why use super.f() instead of Base.f() in inherited contracts?
A: super.f() follows the linearized inheritance path and ensures all overrides are properly chained. Using explicit base names can skip intermediate logic in diamond or complex inheritance patterns.
Q: Are private variables truly secure?
A: No—while other contracts cannot read or write them directly, all blockchain data is public. Private variables are readable by anyone inspecting node data or transaction traces.
Q: When should I use immutable vs constant variables?
A: Use constant when the value is known at compile time (e.g., mathematical constants). Use immutable when the value depends on deployment-time inputs (e.g., owner address, initial supply).