[{"data":1,"prerenderedAt":467},["ShallowReactive",2],{"blog-\u002Fblog\u002Fpredictable-codebase":3},{"id":4,"title":5,"body":6,"canonical":456,"date":457,"description":458,"draft":459,"duration":456,"extension":460,"lang":456,"meta":461,"navigation":462,"path":463,"place":456,"placeLink":456,"readingTime":456,"seo":464,"stem":465,"__hash__":466},"blog\u002Fblog\u002Fpredictable-codebase.md","Predictable codebase",{"type":7,"value":8,"toc":448},"minimark",[9,14,26,37,45,50,59,62,96,108,141,144,148,151,186,189,192,199,203,206,216,219,252,259,322,352,360,373,376,379,383,386,398,406,428,432,441,444],[10,11,13],"h3",{"id":12},"why-constraints-scale-code-for-the-humans-on-the-team-and-for-the-agents","Why constraints scale code. For the humans on the team, and for the agents.",[15,16,17],"p",{},[18,19],"img",{"alt":20,"fetchPriority":21,"loading":22,"src":23,"width":24,"height":25},"Four feature folders side by side, each holding the same four files: page, loading, error and queries. The last folder is dashed and still being written, and it already matches the others.","high","eager","\u002Fimages\u002Fblog\u002Fpredictable-codebase\u002Fcover.svg",1200,520,[15,27,28,29,36],{},"When we started building ",[30,31,35],"a",{"href":32,"rel":33},"https:\u002F\u002Fwww.bethelflow.com\u002F",[34],"nofollow","BethelFlow",", I had a lot of opinions about how I wanted the codebase to look. What I didn't have was any experience setting one up.",[15,38,39,40,44],{},"Up to that point I'd been a feature person. Someone else had already picked the linter, wired the formatter, decided where things lived and what a component was allowed to be. I'd show up, read the room, and ship inside it. That's a real skill, but it's a different one. On BethelFlow there was no room to read. The frontend was mine to set up, from the first ",[41,42,43],"code",{},"package.json"," down, and every decision I didn't make on purpose was going to get made by accident.",[46,47,49],"h2",{"id":48},"where-i-learned-what-good-looked-like","Where I learned what good looked like",[15,51,52,53,58],{},"The lesson came from somewhere else first. At ",[30,54,57],{"href":55,"rel":56},"https:\u002F\u002Fiq.wiki",[34],"IQ.wiki"," we did a full revamp: a new look, and a move off Chakra UI v2 onto Tailwind. It would have been easy to treat it as a reskin. We didn't. We started with the most structural thing we could do, which was making sure the new codebase was future proof, easy to read, and strongly typed from day one.",[15,60,61],{},"A few things from that rewrite stuck with me for good.",[15,63,64,68,69,74,75,80,81,85,86,89,90,95],{},[65,66,67],"strong",{},"Types you don't write by hand."," We used ",[30,70,73],{"href":71,"rel":72},"https:\u002F\u002Fgql-tada.0no.co\u002F",[34],"gql.tada",", which infers the TypeScript type of a GraphQL query straight from the schema. You write the query, and the result already has its shape. No hand-maintained interface drifting away from what the API actually returns. Where data came from somewhere GraphQL couldn't vouch for, ",[30,76,79],{"href":77,"rel":78},"https:\u002F\u002Fzod.dev",[34],"Zod"," did the same job at runtime. We spent our effort getting more types ",[82,83,84],"em",{},"inferred"," instead of ",[82,87,88],{},"written",", and I loved how clean that felt. If you want the idea behind it in one essay, it's Alexis King's ",[30,91,94],{"href":92,"rel":93},"https:\u002F\u002Flexi-lambda.github.io\u002Fblog\u002F2019\u002F11\u002F05\u002Fparse-don-t-validate\u002F",[34],"Parse, don't validate",".",[15,97,98,101,102,107],{},[65,99,100],{},"A component does one thing."," Small files. Not many lines. If a component was fetching, formatting, deciding, and rendering all at once, it got split. The old name for this is ",[30,103,106],{"href":104,"rel":105},"https:\u002F\u002Fen.wikipedia.org\u002Fwiki\u002FSeparation_of_concerns",[34],"separation of concerns",", and it's less a rule than a habit of asking \"what is this file actually for?\"",[15,109,110,113,114,117,118,121,122,117,127,134,135,140],{},[65,111,112],{},"Composition over one big component."," No single component juggling ",[41,115,116],{},"isLoading",", ",[41,119,120],{},"isError",", and the happy path in a tangle of ternaries. The error state rendered its own thing. The loading state rendered its own thing. The data rendered its own thing. React already gives you the pieces for this: ",[30,123,126],{"href":124,"rel":125},"https:\u002F\u002Freact.dev\u002Flearn\u002Fthinking-in-react",[34],"composing components",[30,128,131],{"href":129,"rel":130},"https:\u002F\u002Freact.dev\u002Freference\u002Freact\u002FSuspense",[34],[41,132,133],{},"\u003CSuspense>"," for loading, and in Next.js, ",[30,136,139],{"href":137,"rel":138},"https:\u002F\u002Fnextjs.org\u002Fdocs\u002Fapp\u002Fapi-reference\u002Ffile-conventions\u002Ferror",[34],"error boundaries"," for failure. Very clean principle.",[15,142,143],{},"None of it was clever. That was the point. It was simple, and simple stayed simple as the codebase grew.",[46,145,147],{"id":146},"bringing-it-home","Bringing it home",[15,149,150],{},"So I made BethelFlow the same. And it wasn't easy to start.",[15,152,153,154,159,160,165,166,171,172,175,176,181,182,185],{},"The tooling part was the quick bit. ",[30,155,158],{"href":156,"rel":157},"https:\u002F\u002Fbiomejs.dev",[34],"Biome"," for linting and formatting. A ",[30,161,164],{"href":162,"rel":163},"https:\u002F\u002Ftypicode.github.io\u002Fhusky\u002F",[34],"husky"," pre-commit hook running ",[30,167,170],{"href":168,"rel":169},"https:\u002F\u002Fgithub.com\u002Flint-staged\u002Flint-staged",[34],"lint-staged",", so nothing unformatted gets committed in the first place. A pre-push hook that runs a full build. CI that runs ",[41,173,174],{},"biome ci",", lints our i18n keys, and runs ",[30,177,180],{"href":178,"rel":179},"https:\u002F\u002Fknip.dev",[34],"knip"," to fail the build on unused files. TypeScript in ",[41,183,184],{},"strict"," mode. Boring, and exactly what I wanted.",[15,187,188],{},"The people part took longer. Getting everyone to follow the same rules meant long days of review. 🙃 Sending a PR back because a component did three things. Because a type was declared twice. Because a fetch was typed by hand when the schema was sitting right there. It felt very harsh back then. I could have let it slide. It worked, after all.",[15,190,191],{},"But every time I thought about overlooking it, I pictured myself a year later, opening a file I couldn't read, not knowing what was going on, in a codebase I was supposed to own. Short-term strictness felt expensive. Long-term unmaintainability was going to be more expensive, and it doesn't send an invoice. It just slows everything down until one day you notice.",[15,193,194,195,198],{},"So I made the choice: make it predictable enough that ",[82,196,197],{},"I"," could always understand it. There was no AI in the codebase then. This wasn't about agents. I just wanted type safety and good principles. Segregation of concerns. Declare a type once, and let every function call infer from it.",[46,200,202],{"id":201},"what-that-looks-like-in-practice","What that looks like in practice",[15,204,205],{},"Every feature in the dashboard has the same shape. Take prayer requests:",[207,208,213],"pre",{"className":209,"code":211,"language":212},[210],"language-text","prayer-request\u002F\n├── page.tsx\n├── _schema.ts\n├── _actions.ts\n├── _hooks\u002F\n└── _components\u002F\n    ├── prayer-requests-view.tsx\n    ├── prayer-requests-table.tsx\n    ├── update-status-modal.tsx\n    └── ...\n","text",[41,214,211],{"__ignoreMap":215},"",[15,217,218],{},"Open any other feature, services, departments, records, and you'll find the same files in the same places. You don't learn the codebase feature by feature. You learn it once.",[15,220,221,222,225,226,229,230,233,234,239,240,244,245,249,250,95],{},"The types live in ",[41,223,224],{},"_schema.ts",", declared exactly once as Zod schemas, and the TypeScript types fall out of them with ",[41,227,228],{},"z.infer",". The server actions parse what the API sends back with the same schema, with ",[41,231,232],{},"safeParse",", so a surprise shape from the backend gets stopped at the edge instead of leaking into a component three files away. Nothing downstream ever has to wonder what it was handed. Mutations go through ",[30,235,238],{"href":236,"rel":237},"https:\u002F\u002Fnext-safe-action.dev",[34],"next-safe-action",", with an auth client every action builds on, which I wrote about in ",[30,241,243],{"href":242},"\u002Fblog\u002Fstop-yapping-lock-in","Stop yapping, Lock in",". Even the URL is typed, with ",[30,246,248],{"href":247},"\u002Fblog\u002Fnuqs-because-urls-should-do-more","nuqs"," parsers living in that same ",[41,251,224],{},[15,253,254,255,258],{},"Here's the part I'm most fond of. Once a function returns typed data, nobody downstream declares that type again. They ask the function for it. The table that renders prayer requests never imports a ",[41,256,257],{},"PrayerRequest[]"," interface. It borrows the shape straight from the thing that fetched the data:",[207,260,264],{"className":261,"code":262,"language":263,"meta":215,"style":215},"language-ts shiki shiki-themes github-light github-dark","type PrayerRequestRows = NonNullable\u003C\n  Awaited\u003CReturnType\u003Ctypeof usePrayerRequests>>['prayerRequests']\n>\n","ts",[41,265,266,289,316],{"__ignoreMap":215},[267,268,271,275,279,282,285],"span",{"class":269,"line":270},"line",1,[267,272,274],{"class":273},"szBVR","type",[267,276,278],{"class":277},"sScJk"," PrayerRequestRows",[267,280,281],{"class":273}," =",[267,283,284],{"class":277}," NonNullable",[267,286,288],{"class":287},"sVt8B","\u003C\n",[267,290,292,295,298,301,303,306,309,313],{"class":269,"line":291},2,[267,293,294],{"class":277},"  Awaited",[267,296,297],{"class":287},"\u003C",[267,299,300],{"class":277},"ReturnType",[267,302,297],{"class":287},[267,304,305],{"class":273},"typeof",[267,307,308],{"class":287}," usePrayerRequests>>[",[267,310,312],{"class":311},"sZZnC","'prayerRequests'",[267,314,315],{"class":287},"]\n",[267,317,319],{"class":269,"line":318},3,[267,320,321],{"class":287},">\n",[15,323,324,325,331,332,339,340,343,344,347,348,351],{},"Read it inside out. ",[30,326,329],{"href":327,"rel":328},"https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002Futility-types.html#returntypetype",[34],[41,330,300],{}," gets what the function returns. It's async, so that's a promise, and ",[30,333,336],{"href":334,"rel":335},"https:\u002F\u002Fwww.typescriptlang.org\u002Fdocs\u002Fhandbook\u002Futility-types.html#awaitedtype",[34],[41,337,338],{},"Awaited"," unwraps it. Index into ",[41,341,342],{},"prayerRequests",", strip the ",[41,345,346],{},"null"," with ",[41,349,350],{},"NonNullable",", and you have the exact rows the table is going to receive. Change the schema, and the action, the hook, the table, the column headers and the bulk-update modal all update with it. Or they stop compiling and tell you where to look. Some version of that line lives in almost 90 files, borrowing types from 57 different functions. Declare the type once, and let every call site infer it.",[15,353,354,355,357,358,95],{},"And the components compose. The prayer-request view is a few lines long. It owns the layout, wraps the list in ",[41,356,133],{}," with a table skeleton as the fallback, and lets the list worry about data. The skeleton renders the skeleton. The table renders the table. Nobody checks ",[41,359,116],{},[15,361,362,363,366,367,372],{},"The same idea goes one level deeper. Every dashboard table is one shared ",[41,364,365],{},"DashboardTable"," that knows nothing about prayer requests, members or services. A feature doesn't fork it to add a bulk action. It passes in a small action object with a ",[30,368,371],{"href":369,"rel":370},"https:\u002F\u002Freact.dev\u002Freference\u002Freact\u002FChildren#calling-a-render-prop-to-customize-rendering",[34],"render prop"," for the modal. The table owns selection and when the modal opens. The feature owns what the modal is. And because the action is generic over the row, the selected rows arrive already typed with that inferred shape from above. One table, lots of features, and none of them reach inside it.",[15,374,375],{},"Across the dashboard there are close to five hundred component and page files now. The median one is about a hundred lines. That number is the whole philosophy, measured.",[15,377,378],{},"It's one of the best things I did for the BethelFlow codebase, btw. 😅",[46,380,382],{"id":381},"then-claude-joined-the-team","Then Claude joined the team",[15,384,385],{},"Now we have Claude contributing, working the way I work. And the payoff is almost funny to watch.",[15,387,388,389,117,391,117,394,397],{},"Give it a new page to build and it doesn't invent anything. It reads a neighbouring feature, sees ",[41,390,224],{},[41,392,393],{},"_actions.ts",[41,395,396],{},"_components\u002F",", and produces the same shape. Schemas first, types inferred, a view that composes, a skeleton for the loading state. It uses the design system the same way, because there's only one way to use it.",[15,399,400,401,405],{},"This isn't magic, and it isn't really about the model. An LLM is a very good pattern-matcher, and it will match whatever patterns you have. A codebase with five ways to fetch data gets a sixth. A codebase with one way gets that one way, again. Strict types give it a compiler that says no before I have to. Consistent structure gives it an example to copy. DRY code means there's one place to change and one place to learn from. The guardrails I put up for humans turned out to be the exact same guardrails an agent needs. I wrote more about that overlap in ",[30,402,404],{"href":403},"\u002Fblog\u002Fcontext-is-everything","Context is everything",": briefing an agent well and briefing a person well are mostly the same job.",[15,407,408,409,347,414,419,420,423,424,427],{},"The design system went through the same cleanup. We used to have hardcoded colour values scattered around, and at some point I'd had enough of that. They moved into tokens. Today it's ",[30,410,413],{"href":411,"rel":412},"https:\u002F\u002Ftailwindcss.com",[34],"Tailwind CSS",[30,415,418],{"href":416,"rel":417},"https:\u002F\u002Fui.shadcn.com",[34],"shadcn\u002Fui",", and every colour is a CSS variable behind a semantic name like ",[41,421,422],{},"bg-card"," or ",[41,425,426],{},"text-muted-foreground",". That change helped people, but it helped Claude even more. There is no hex code to guess. There's a name, and the name is right.",[46,429,431],{"id":430},"whats-next","What's next",[15,433,434,435,440],{},"The foundation holds, so now it's about building on it. Keeping up with the latest Next.js releases for speed, which is a lot less scary when the type checker and the build both have your back. And eventually a move to ",[30,436,439],{"href":437,"rel":438},"https:\u002F\u002Fpanda-css.com",[34],"Panda CSS",", once I have the time to do the switch properly. Given everything above, I expect most of that migration to be mechanical. Which is exactly how a migration should feel.",[15,442,443],{},"But mostly I'm just proud of how this codebase has grown. Humans and agents can both walk into it and contribute without asking where things go, because the code already tells them. That was the whole goal. Not clever. Predictable.",[445,446,447],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":215,"searchDepth":291,"depth":291,"links":449},[450,451,452,453,454,455],{"id":12,"depth":318,"text":13},{"id":48,"depth":291,"text":49},{"id":146,"depth":291,"text":147},{"id":201,"depth":291,"text":202},{"id":381,"depth":291,"text":382},{"id":430,"depth":291,"text":431},null,"2026-09-26","The first codebase I ever set up myself, the IQ.wiki rewrite that taught me what I wanted it to look like, the weeks of harsh reviews it took, and why the same rules now let Claude ship pages that look like I wrote them.",false,"md",{},true,"\u002Fblog\u002Fpredictable-codebase",{"title":5,"description":458},"blog\u002Fpredictable-codebase","VA7bOCtO_kNmoA7YtIFhSd-mfKt-2-PdCt7i6EJ51Tg",1790719921055]