The thing that trips everyone up first
When I started building on Solana, I came from an Ethereum background. My mental model was: deploy a contract, it has state, users call functions on it. Clean. Simple.
Solana is completely different, and if you don't internalize this early, you'll spend hours confused. Programs on Solana are stateless. They don't own data. They own nothing. All data lives in separate accounts, and programs are just code that operates on whatever accounts you pass in.
That sounds minor. It's not. It changes everything about how you architect applications.
The Account Model
Every piece of data on Solana — a token balance, your app's state, a user's profile — lives in an account. An account is a blob of bytes with an owner (a program) and a lamport balance (to pay rent). Your wallet is an account. A token balance is an account. Your program's stored data is an account.
Here's how the pieces fit together for a typical app:
The key insight: when a user calls your program, you pass in all the accounts it needs to read or write. The runtime validates that the program is allowed to touch those accounts. This is why Solana transactions can be parallelized — the runtime knows upfront which accounts each transaction touches, and non-overlapping transactions run simultaneously.
PDAs — the concept that broke my brain, then fixed it
Program-Derived Addresses (PDAs) are deterministic account addresses derived from a program ID and some seeds. They have no private key — no one can sign for them except the program that derived them.
Here's why they're useful: imagine you're building a staking contract. Every user has their own staking account that only your program should modify. You could let users create any account and pass it in — but then how do you enforce it's their account? With a PDA, you derive the address from ["stake", user_pubkey] + your program ID. It's deterministic, unique per user, and only your program can write to it.
// Anchor syntax
#[derive(Accounts)]
pub struct Stake<'info> {
#[account(
init_if_needed,
seeds = [b"stake", user.key().as_ref()],
bump,
payer = user,
space = 8 + StakeAccount::INIT_SPACE,
)]
pub stake_account: Account<'info, StakeAccount>,
#[account(mut)]
pub user: Signer<'info>,
pub system_program: Program<'info, System>,
}The first time I read this, I thought it was boilerplate. It's not. Every field is a security constraint the runtime enforces. The seeds make the account derivable and unique. The mut constraint ensures you're declaring intent to write. If you pass the wrong account, the transaction fails. This is the runtime doing your input validation for free.
Why Rust is actually fine
I was dreading Rust. I'd read enough about the borrow checker to be scared. Turns out, Anchor (the most popular Solana framework) handles the gnarly parts. The Rust you write for a Solana program is pretty approachable — you're mostly writing struct definitions and business logic.
The borrow checker does catch real bugs that you'd miss in JavaScript. I had a case where I tried to use a value after passing it into a function, and Rust simply refused to compile. In a JS program, that would have been a subtle runtime bug. Rust made it a compiler error. Once you stop fighting it and start listening to it, the compiler becomes your best reviewer.
// Rust prevents you from making this mistake let data = account.data.borrow_mut(); process(&data); // Can't use data here anymore — you moved it // JS would let you try and fail at runtime
The footguns nobody warned me about
Rent. Every account on Solana needs to maintain a minimum lamport balance or it gets garbage collected. When you init an account, you pay this rent upfront. When you close an account, you get it back. Forgetting to handle this properly means accounts silently disappearing. Always use init_if_needed carefully and close accounts when you're done with them.
Account size is fixed at creation. Unlike Ethereum storage that grows dynamically, Solana accounts have a fixed size you declare when you create them. If you need more space later, you have to migrate. Plan your data structures before deploying to mainnet — adding a field means a migration, not just updating your struct.
CPI (Cross-Program Invocations) can fail silently. When your program calls another program, errors don't automatically bubble up. You need to explicitly handle the return value. Unchecked CPIs are a real footgun in production programs.
The deploy workflow
Anchor makes this pretty smooth once you have the toolchain set up, but the first time is a maze of CLI tools. Here's the actual flow:
A few things that tripped me up during deploy:
anchor buildcompiles to a.sofile. This is the program bytecode. It's larger than you expect — expect 100-500KB for anything real.- You need to fund the deployer keypair with enough SOL to cover the program account rent. On devnet,
solana airdrop 2handles this. On mainnet, budget for it. - Program upgrades work by sending new bytecode to the same program ID. But if you add new account types, existing clients using the old IDL will break. Versioning your IDL matters in production.
Things I'd do differently
I'd spend a day just reading Solana account examples before writing any code. The mental model shift is the bottleneck — not the Rust, not the tooling. Once you truly get that "everything is accounts," the rest starts making sense fast.
I'd also start on devnet with a very simple program — like a counter — before trying to build anything real. The feedback loop is slow enough that fighting two problems at once (program logic + Solana concepts) makes progress painful.
The Anchor docs and the Solana Cookbook are genuinely good. Use them.