Anatomy of a dApp
A decentralized application (dApp) is more than just a smart contract — it is a full-stack application that combines on-chain logic with off-chain infrastructure. A well-architected dApp separates concerns between the blockchain layer (consensus, state, execution), the data layer (indexing, caching), and the presentation layer (frontend UI). Understanding this architecture is essential for building production-grade Web3 applications.
dApp Architecture Layers
- Smart Contracts: On-chain business logic, state management, and token operations
- RPC Provider: JSON-RPC gateway to read/write blockchain data (Alchemy, Infura, QuickNode)
- Indexer / Subgraph: Off-chain service that indexes blockchain events for fast querying (The Graph, Ponder)
- Frontend: React/Next.js application with wallet connection and contract interaction
- Off-chain Storage: IPFS/Arweave for media, traditional databases for user preferences
The Full Stack
// Typical dApp project structure
// my-dapp/
// packages/
// contracts/ — Hardhat project with Solidity contracts
// contracts/
// test/
// scripts/
// hardhat.config.ts
// frontend/ — Next.js application
// app/
// components/
// hooks/
// lib/
// wagmi.config.ts
// subgraph/ — The Graph subgraph for indexing
// schema.graphql
// subgraph.yaml
// src/mappings.ts
// package.json — Monorepo root (turborepo/pnpm workspaces)
Indexing with The Graph
Reading blockchain data directly via RPC calls is slow and expensive for complex queries. The Graph solves this by indexing blockchain events into a GraphQL API. You define a subgraph schema, write mapping functions that process events, and The Graph indexes all matching events from the blockchain.
// schema.graphql — Define your data model
// type Transfer @entity {
// id: ID!
// from: Bytes!
// to: Bytes!
// amount: BigInt!
// timestamp: BigInt!
// blockNumber: BigInt!
// }
//
// type Account @entity {
// id: ID!
// balance: BigInt!
// transferCount: BigInt!
// }
// src/mappings.ts — Process events
import { Transfer as TransferEvent } from '../generated/MyToken/MyToken';
import { Transfer, Account } from '../generated/schema';
import { BigInt } from '@graphprotocol/graph-ts';
export function handleTransfer(event: TransferEvent): void {
// Create Transfer entity
const transfer = new Transfer(event.transaction.hash.toHex() + '-' + event.logIndex.toString());
transfer.from = event.params.from;
transfer.to = event.params.to;
transfer.amount = event.params.value;
transfer.timestamp = event.block.timestamp;
transfer.blockNumber = event.block.number;
transfer.save();
// Update sender account
let sender = Account.load(event.params.from.toHex());
if (!sender) {
sender = new Account(event.params.from.toHex());
sender.balance = BigInt.zero();
sender.transferCount = BigInt.zero();
}
sender.balance = sender.balance.minus(event.params.value);
sender.transferCount = sender.transferCount.plus(BigInt.fromI32(1));
sender.save();
}
// Querying the subgraph from your frontend
const SUBGRAPH_URL = 'https://api.thegraph.com/subgraphs/name/your-subgraph';
async function getRecentTransfers(account: string) {
const query = `
query GetTransfers($account: Bytes!, $first: Int!) {
transfers(
where: { from: $account }
orderBy: timestamp
orderDirection: desc
first: $first
) {
id
from
to
amount
timestamp
blockNumber
}
}
`;
const response = await fetch(SUBGRAPH_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
variables: { account: account.toLowerCase(), first: 20 },
}),
});
const { data } = await response.json();
return data.transfers;
}
Authentication with SIWE (Sign-In with Ethereum)
// Sign-In with Ethereum — wallet-based authentication
// Install: npm install siwe
// Backend — verify the signature
import { SiweMessage } from 'siwe';
async function verifySignIn(message: string, signature: string) {
const siweMessage = new SiweMessage(message);
const result = await siweMessage.verify({ signature });
if (result.success) {
// Create a session for the verified address
return {
address: siweMessage.address,
chainId: siweMessage.chainId,
};
}
throw new Error('Invalid signature');
}
// Frontend — request signature
async function signIn(signer: ethers.Signer) {
const address = await signer.getAddress();
const message = new SiweMessage({
domain: window.location.host,
address,
statement: 'Sign in to My dApp',
uri: window.location.origin,
version: '1',
chainId: 1,
nonce: await fetchNonceFromServer(), // Prevent replay attacks
});
const messageString = message.prepareMessage();
const signature = await signer.signMessage(messageString);
// Send to backend for verification
const response = await fetch('/api/auth/verify', {
method: 'POST',
body: JSON.stringify({ message: messageString, signature }),
});
return response.json();
}
Design Decisions: On-chain vs Off-chain
Where to Put Your Data
| Data Type | Location | Reason |
|---|---|---|
| Token balances, ownership | On-chain (smart contract) | Must be trustless and verifiable |
| NFT images, media | IPFS / Arweave | Too large for on-chain, needs decentralization |
| Transaction history queries | Indexer (The Graph) | Complex queries are slow via RPC |
| User profiles, preferences | Off-chain database | Not financially sensitive, needs fast reads |
| Access control, governance votes | On-chain | Must be transparent and tamper-proof |
Architecture Best Practices
- Minimize on-chain data: Store only essential state on-chain. Use events for data that only needs to be indexed.
- Use events liberally: Events are cheap to emit and enable off-chain indexing of all important actions.
- Separate read and write paths: Use indexers for reads, direct RPC for writes. This gives fast reads without compromising write security.
- Plan for upgrades: Use proxy patterns or modular architecture if your contracts may need updates.
- Handle transaction lifecycles: Show pending, confirming, and confirmed states in the UI for every transaction.