The first real choice was the contract layout. There were three reasonable shapes and I almost went with the wrong one.
Shape A: one big contract. A single AsentumSocial contract with profiles, posts, follows, votes, all the methods, all the state. Every interaction goes through the same address. The on-chain explorer is one entity. Easiest to reason about for a user.
Shape B: many small contracts that call each other. Profile, Posts, Follow each as separate contracts, with cross-contract calls (Posts.post() calls Profile.requireProfile(sender) to check the user has a profile, etc). Composition, encapsulation, all that.
Shape C: many small contracts with no cross-contract calls. Each contract knows nothing about the others. The frontend reads each one independently and stitches the data together at render time.
I went with C. I think it's the right default for JS-native dapps and most people's instinct will be wrong about why.
A note on the maths before we go further: the project started with three contracts — Profile, Posts, Follow — and that's where the architectural argument got made. Two more (Gallery, Votes) shipped later under the same rule, and you'll meet all five in chapter 3. So when I say "three contracts" in this chapter I mean the original three the rule was forged on. The number isn't load-bearing; the rule is.
the case for one big contract
There's a cargo-culted instinct from the EVM world that one big contract is bad — too much state, too much surface area, too many ways to break. That instinct is mostly about Solidity-specific failure modes: storage layout collisions, proxy upgrade hazards, accidental state corruption between unrelated features, the time bomb of "we put 14 features in one contract and now we can't migrate any of them independently."
In a JavaScript-native VM the storage-layout-collision problem doesn't really exist — every storage key is a string you choose, prefixed however you like. There's no slot-packing. The "too much surface area" concern is about audit difficulty, which goes away the moment your contract is small and self-contained.
So shape A — one big contract — isn't actually a bad idea on a chain like this. I rejected it for one reason: upgradeability without ceremony. If profile fields change, I want to be able to redeploy Profile without losing the post history. If I want to swap the votes contract for a stake-weighted version, I shouldn't need to migrate posts. With one big contract every change is a "redeploy and migrate everything" affair, which means in practice you ship slowly and break less, when what I actually want for a reference dapp is to ship fast and iterate.
the case against cross-contract calls
Shape B is the one most engineers' instincts pull toward — it looks composable. Profile owns profile state, Posts owns post state, but Posts can ask Profile "does this address have a profile?" before letting them post. That sounds clean.
Then you actually do it and it falls apart in three ways.
One, you've coupled the contracts at deploy time. If you redeploy Profile to a new address, every contract that calls into it has a stale address baked in. You either need a registry, or proxy, or hardcoded address that you commit to never changing. The simplicity is gone.
Two, every cross-contract call is real chain work — gas, storage reads from another contract's space, the chance that the called contract is paused or broken. A "does this user have a profile" check in the post path turns one storage read into a multi-step VM-to-VM call. For a check the frontend already wants to do anyway (you don't show a post-button to a user without a profile), it's pure overhead.
Three — and this is the one that decided it for me — the frontend is going to read both contracts anyway. When I render a profile page I need the profile (Profile contract), the post list (Posts contract), and the follower count (Follow contract). Three reads, parallelized. Putting cross-contract calls into the contracts doesn't reduce the number of reads the frontend does — it just adds an extra one inside the chain.
So the frontend is the integration layer. The contracts don't know about each other. Each one is small enough to audit in one sitting, replace independently, and reason about without holding the others in your head.
what the rule actually looks like
── original three ── ── added later ──
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Profile │ │ Posts │ │ Follow │ │ Gallery │ │ Votes │
│ setProfile │ │ post │ │ follow │ │ addImage │ │ upvote │
│ getProfile │ │ getPost… │ │ unfollow │ │ getImage… │ │ downvote │
│ hasProfile │ │ getUserPo… │ │ getFollow… │ │ getUserGa… │ │ getScore │
└──────▲───────┘ └──────▲───────┘ └──────▲───────┘ └──────▲───────┘ └──────▲───────┘
│ │ │ │ │
└─────────────────┴─────────────────┼─────────────────┴─────────────────┘
│
┌────────┴────────┐
│ Frontend │
│ Reads each. │
│ Composes UI. │
└─────────────────┘
None of these five know anything about the others. The frontend hits each contract's view methods independently and assembles the page. Adding Gallery and Votes later didn't require touching Profile, Posts, or Follow — they just appeared as new tiles on the same grid, which is exactly the property the rule was meant to protect.
If we later want a "user card" that bundles the three reads, that's a frontend concern — a single React component or a tiny GraphQL-style aggregator on our indexer. It is not a contract concern.
The contracts in this project total about 350 lines of source. Each one is between 50 and 130 lines. Audit time per contract: maybe 20 minutes if you've never seen it before. That tractability is the thing the architecture buys you.
what about events between contracts?
The honest counter-argument to all this is "okay but what if Posts wants to know when a user is created so it can show some welcome state?" Or "what if Votes wants to drop a user's score when they delete their account?"
The answer in this stack is: don't do that on-chain. Let the indexer do it.
The indexer (Chapter 4 covers it in detail) is a small server that tails every block, decodes events from every contract address it cares about, and writes them into SQLite. It's the integration layer for cross-contract semantics. If Votes wants to know about Profile events, the indexer joins them in a SQL query — instantly, off-chain, with no gas cost. The chain stays simple.
This is, by the way, the same pattern The Graph and similar projects discovered for EVM dapps about five years ago, just at a smaller and saner scale. We don't need The Graph for a dapp this size; we need a 200-line Node.js script.
a small concession: contract id starts at 1
There's exactly one place I cheated on the "no coordination" rule, and it's worth flagging. The Posts contract assigns post ids monotonically (1, 2, 3...). The Votes contract takes a postId and trusts that it refers to a real post. There's no on-chain check that the post exists. If you call upvote("99999999") on a post that doesn't exist, the votes contract dutifully creates a score record for it and the frontend has no way to render a "post not found" link.
This is fine because the only client of the votes contract is the frontend, and the frontend only ever passes ids it just read from the posts contract. If you wanted to harden it you'd add a postsContract reference, an on-chain getPost call before recording the vote, and you'd be back to cross-contract-call land. I left it un-hardened on purpose to keep the votes contract pure. We can always bolt validation on later via the indexer ("flag any vote on a non-existent post id"); the on-chain code stays clean.
That's the architecture. Five tiny contracts, no cross-contract calls, the frontend stitches them together.
In Chapter 3 we'll go through each one. The code for all five fits in this case study itself — we'll quote the interesting parts inline and link to the rest on GitHub.