TechLead
Lesson 13 of 20
5 min read
Web3 & Blockchain

dApp Architecture Patterns

Design scalable decentralized application architectures combining on-chain smart contracts with off-chain frontends, indexing, and storage

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, ownershipOn-chain (smart contract)Must be trustless and verifiable
NFT images, mediaIPFS / ArweaveToo large for on-chain, needs decentralization
Transaction history queriesIndexer (The Graph)Complex queries are slow via RPC
User profiles, preferencesOff-chain databaseNot financially sensitive, needs fast reads
Access control, governance votesOn-chainMust 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.

Continue Learning