← The Complete Playbook
Varritech
The Complete Digital Product Playbook

Full-Stack
Engineering.

Production-grade architecture from day one.

💡
Production-grade is not a stack, it is a set of habits about the decisions that cost the most to reverse and the failures that are hardest to see. This guide is the engineering system we run on every Varritech build: the irreversible decisions, the data-layer traps that produce no error, money paths, configuration, measuring before refactoring, and the gap between merged and live.

Inside this guide you'll find the reversibility test we apply before choosing anything, four real production bugs that threw no exception, the two-command diagnosis for a credential your own API rejects, the bundle-cost measurement that saved a doomed refactor, and the shipping checks that catch what CI structurally cannot.


Phase 1: The Decisions That Are Expensive to Reverse

Phase 1 · Architecture

Write the reversal cost, not the decision

four expensive, everything else cheap · loops ~16s
01Identitya field in some tables, a primary key in others
02Core entity shaperetrofitting tenancy is a rewrite
03Money source of truthyou, or the payment provider
04Public surfacea promise to systems you do not control
··Everything elseframework, ORM, styling, test runner
The one-line analysis
If this is wrong in nine months, what does the fix touch? One module, or every row in the database?
"We'll decide later" does not preserve optionality. The codebase accumulates assumptions about the temporary answer.
Make expensive decisions early and cheap decisions late. The instinct runs backwards — teams agonise over the test runner and default their way into the auth model.

Most engineering decisions are cheap. You picked the wrong date library, you swap it in an afternoon. A handful of decisions are not cheap, and the entire discipline of architecture is knowing which handful.

The test we apply: if this turns out to be wrong in nine months, what does the fix touch? If the answer is one module, decide fast and move on. If the answer is every row in the database, or every deployed URL, or every integration partner, slow down and write the reasoning somewhere durable.

The Four

In practice, four decisions account for nearly all the expensive reversals we have seen:

Everything else — framework, styling approach, ORM, hosting region, test runner — is closer to reversible than it feels in the moment.

⚠️
"We'll decide that later" is itself a decision, and it is usually the expensive one. Deferring the identity decision does not preserve optionality; it means the codebase accumulates assumptions about the temporary answer. By the time the real decision is made, the migration is proportional to how long you deferred.

Case Study: ConvergeFlow — The Cost of Deferring the Auth Decision

Case study · ConvergeFlow

"Fix the Facebook button" expands to this

one symptom, seven steps · loops ~16s
SymptomFacebook login fails
Causedev instance uses the vendor's shared OAuth app
Requiresa production instance
Requiresa domain you control, with DNS
Requiresre-issued creds for every provider
Requiresnew webhook endpoint + signing secret
Requiresmigrating every user id
5,233documents across 10 collections where the id is a field — rewrite it
4collections where the id is the document id — copy to new id, then delete. A field-only migration silently orphans all of these.
Separate "stop the button erroring" from "go live on our own domain." Disabling one provider is two minutes. The cutover is launch-scale work, and belongs on its own ticket.

ConvergeFlow ran on a hosted auth provider's shared development instance, on a .vercel.app domain, well past launch. The visible symptom was small: the Facebook login button did not work.

That symptom looked like a config fix. It was not one, and the chain is worth walking because it is the same chain every time:

Development instances use the auth provider's own shared OAuth application for every social provider. You cannot supply your own client id and secret on a development instance — only on a production one. A production instance requires a domain you control with real DNS records, and a *.vercel.app host cannot become one. So "fix the Facebook button" expands to: acquire the domain, put it on the host, create the production auth instance, wait up to 48 hours for domain verification, re-issue OAuth credentials for every provider (Google breaks too — it was also riding shared development credentials), re-create the webhook endpoint and capture its new signing secret, migrate every user id in the database, and redeploy.

The migration is the part that silently loses data. Development and production instances are separate user stores, so every user receives a brand-new id. In this database that id appeared in two distinct shapes:

A migration that only rewrites the field looks like it worked. It leaves every id-keyed document orphaned, and the user logs into an empty account. Nine Clerk users made this survivable. Nine hundred would not have.

🔑
Separate "make the broken button stop erroring" from "go live on our own domain." They are not the same size of task. If the actual need is just to stop showing a button that fails for everyone, disabling that provider is a two-minute change with no domain and no migration. The full cutover is launch-scale work — roughly a day of effort spread across three because of DNS propagation. Scope and ticket them separately, and say so before anyone estimates.

How to Make a Reversibility Call

  1. Write the reversal cost, not the decision. One line: "if wrong, this touches X." That line is the whole analysis and it takes a minute.
  2. For anything touching identity or tenancy, write an ADR. Two paragraphs: what we chose, what we rejected, what would make us revisit. The value is not the record, it is that writing it surfaces the assumption you had not noticed.
  3. Make expensive decisions early and cheap decisions late. The instinct is backwards — teams agonise over the test runner and default their way into the auth model.
  4. Prefer a decision that fails loudly to one that fails silently. Between two options of equal quality, take the one whose failure mode is an exception rather than an empty result.

Phase 2: The Data Layer — Where the Silent Bugs Live

Phase 2 · Data layer

null sorts below every string

the range sweeps left · loops ~14s
null boolean number timestamp string
Ships a replay.where('nextAttemptAt', '<=', nowIso)also matches every delivered row, because their value is null
Correct.where('nextAttemptAt', '>', '').where('nextAttemptAt', '<=', nowIso)an explicit lower bound excludes null
Shipped, the first query would have re-POSTed every historical webhook to a live partner. No error, no exception, a perfectly successful drain. Ask what the field is for the rows you were not thinking about.

Application bugs throw. Data-layer bugs return the wrong rows and let everything downstream succeed. That asymmetry is why data-layer review deserves disproportionate attention: the failure mode is not a stack trace, it is a partner receiving three thousand duplicate webhooks.

Case Study: The Query That Would Have Re-Sent Every Webhook

On Portable Tenant's backend we built a retry drain for outbound partner webhooks — find events whose next attempt is due, re-deliver them. The obvious query:

.where('nextAttemptAt', '<=', nowIso)

Delivered records have nextAttemptAt: null. Firestore orders values by type first: null, then booleans, then numbers, then timestamps, then strings. A null sorts below every ISO string, so <= is trivially true for it. That query matches every already-delivered event in the collection.

Shipped, it would have re-POSTed every historical webhook to the partner — a live integration, with a real partner on the other end, receiving a replay of everything we had ever sent them. No error, no exception, a perfectly successful drain.

The fix is a lower bound that excludes null:

.where('nextAttemptAt', '>', '').where('nextAttemptAt', '<=', nowIso)
🔥
The general rule: any range comparison on a nullable field needs an explicit null exclusion. This is not Firestore-specific in spirit — SQL's three-valued logic produces the opposite surprise, where WHERE x <= now silently drops nulls you wanted. Both are the same class of bug: the developer reasons about the values they were thinking of, and the database reasons about all the values that exist. Before shipping any range query, ask what this field is for the rows you were not thinking about.

There is a second lesson buried in that fix, about how the test lied. A mutation-testing run reported the null guard as covered. It was not: the delivered-record fixture had no payload, so removing the guard made the delivery function throw, and the surrounding error-isolation try/catch swallowed it. The guard-removal mutant "passed" for entirely the wrong reason. A test that passes because a different error was swallowed is worse than no test, because it consumes the attention that would have found the gap.

Case Study: An Access Rule Nothing Could Satisfy

Case study · access rules

Telling "never created" from "unreadable"

one inference settles it · loops ~15s
404 on the parent rowambiguous: absent, or forbidden?
↓ look for a dependent row carrying the parent's id
A child row exists with parent_idthe parent was created — you cannot get an id for a row that was never made→ permissions
No child row anywherenothing ever referenced it→ absence
The tell A retry-with-backoff-then-stub around a read means someone already hit this and papered over it. That app rendered a fabricated subscription_status: 'active' and looked healthy.
Two compounding faults, one visible failure. The app wrote a flat payload where every reader looked in a nested object, and the platform's rule did not resolve even when the data was right.

Testing checkout on two apps built on a low-code platform, we found both could take zero payments. Neither failure was visible from the UI.

The second app's failure was a row-level access rule: a club could be read only if its id was in {{user.data.member_club_ids}}. Two compounding faults sat behind it.

On the app side, four call sites wrote the array with a flat payload — updateMe({ member_club_ids: [...] }). That method PUTs its argument straight onto the user record, so a flat payload lands as top-level fields while user.data stays empty. Every reader looked in user.data. The correct shape nests it: updateMe({ data: { ...me.data, member_club_ids } }). The code read from one place and wrote to another, with a comment claiming it existed specifically to make the access rule work.

On the platform side, and this is the part no app change could fix: with the array holding exactly the right id, on a brand-new account with a fresh session, fetching the club still returned 404 and listing returned 0. The rule simply did not resolve. Only the platform console could change it.

The tell that something was already wrong here is worth memorising. The layout component retried four times with backoff and then substituted a stub object — a placeholder name and a fabricated subscription_status: 'active'. A retry-with-backoff-then-stub around a data read is a strong signal that someone hit an access-control problem and papered over it. It also meant the app looked healthy while displaying a fabricated subscription status.

🧭
How to tell "never created" from "unreadable." A 404 is ambiguous — the row might not exist, or you might not be allowed to see it. Resolve it by looking for a dependent row carrying the parent's id. If a membership record exists with a club_id, the club was created, because you cannot obtain an id for a row that was never made. So the 404 is permissions, not absence. This one inference turns a day of guessing into a five-minute query.

How to Review a Data-Layer Change

  1. For every filter, state what it does to rows you were not thinking about — nulls, empty strings, zero, historical records, soft-deleted rows.
  2. For every read, ask what an unauthorised caller receives. Empty list, 404, or error — and whether the caller distinguishes them.
  3. Grep for retry-then-fallback wrappers around reads. Each one is a marker where someone met a failure and hid it.
  4. Check that any concurrency guard is actually passed by its callers. A parameter declared in a stored procedure and never supplied by any call site defaults to null and becomes a permanent no-op. We have seen an optimistic-locking guard built, migrated, and never once switched on — a read-modify-write across a network round trip where two concurrent transitions silently overwrote each other.
  5. Verify erasure and cleanup paths against the schema, not against the code's intent. More on this in Guide 4.

Phase 3: Money Paths Are Not Normal Code

Phase 3 · Money paths

A signing secret is bound to one endpoint

the copied secret fails silently · loops ~14s
Per-site endpoint
provider→site A · whsec_a→verified
provider→site B · whsec_b→verified
Secret copied across
provider→site A · whsec_a→verified
provider→site B · whsec_a→signature fails
Payment succeeds. Nothing is provisioned. Nobody is told.
Two more Know whether the key is live before you wire it in. And a 200 with livemode true proves some live account acted — never which one.
Creating a checkout session charges nobody. A card is only taken when someone submits the hosted page — so the whole money path can be covered at zero cost, against the real host.

Code that moves money has a property no other code has: its failure mode is a customer who has paid and received nothing, and the discovery channel is an angry email rather than an alert.

Case Study: One Parameter, Zero Payments

The first of the two zero-payment apps failed on a single unsupported parameter. The checkout session creation passed statement_descriptor at the top level. Stripe does not accept it there — it belongs inside payment_intent_data as statement_descriptor_suffix. The result was Received unknown parameter, HTTP 500, on every payment attempt, on the live domain.

The bug had been live long enough that "we haven't had many sales yet" had become the accepted explanation.

Two things made it findable in minutes rather than days:

Reproduce against the real host with one request. Not a local copy, not a staging clone — the live domain, calling the function the pay button calls, with a real session token. Assumptions about "it's probably only the test environment" are how a revenue outage survives a week.

Prove checkout without spending anything. Creating a checkout session charges nobody; a card is only taken when someone submits the hosted page. So a test can create the session, assert the URL and currency, even load the hosted page, and then abandon. That covers the entire part of the flow holding the money logic, at zero cost. When the pay button itself is gated behind app state you cannot easily reach — one of these apps required ten profile views first — call the underlying function directly rather than trying to satisfy the gate.

💳
Pin a server-side bug you cannot fix with a test that expects failure. Both of these apps had defects only the platform vendor could resolve. Marking those tests as expected-to-fail rather than skipping them keeps the suite green while it is broken, and the runner reports "expected to fail but passed" the moment the vendor ships the fix. A skipped test tells you nothing, forever.

The Webhook Secret Rule

A webhook signing secret is bound to one endpoint URL. Copying one site's secret to another site produces the worst available failure: payments succeed in the payment provider, the new site's webhook fails signature verification, and money is taken while the thing the customer bought is never provisioned. Silently.

Create a per-site endpoint and use the secret from that response. It is one API call, and it is the difference between a broken integration and a quiet revenue leak.

Two related rules that have each cost us something:


Phase 4: Configuration — The Layer With No Type System

Phase 4 · Configuration

Secret older than the variable

two commands, no cloud access · loops ~14s
Blob secret last edited2026-06-29
PARTNER_KEY lands in code2026-08-07
Never deployed39 days
The confirming probe from outside
correct key401
garbage key401
no header at all401
All three identical means the server has nothing to compare against. It is not a wrong key.
The fix that shipped was a design rule. Configured-but-wrong returns 401. Not-configured returns 503 and logs the missing variable by name. Never let those two share a status code.

Application code has a compiler, a linter, and tests. Configuration has none of these, which is why the most confusing production incidents are almost always configuration.

Case Study: A Correct Credential, Rejected by Our Own API

A partner escalated: their API key was being rejected by our staging environment. The key was correct. It had been correct for nine days.

The cause was structural. The deploy pipeline writes the entire runtime environment from a single stored secret — one blob, written to a .env file at deploy time. Which means every environment variable introduced into the codebase after that secret was last edited is silently undefined in production. No error. No boot warning. Code doing const key = process.env.PARTNER_KEY ?? '' simply takes the empty branch, forever.

The diagnosis is two commands and needs no cloud access at all:

gh secret list --repo <owner/repo>
# -> ENV_STAGING   updated 2026-06-29

git log --format="%h %ad %s" --date=short -S "PARTNER_KEY" origin/staging | tail -1
# -> 2026-08-07

Secret older than the variable means the variable has never been deployed. Conclusive. That pair of commands settled a nine-day-old partner escalation in one call, after a cloud CLI path was blocked by an expired token.

Two follow-ons that generalise:

Check every sibling variable added in the same change. Here the partner key, a callback URL, and a callback secret all landed on the same day, so outbound webhooks were dead too and nobody had noticed.

Know where the values live, separately from where the code runs. This service runs on one cloud provider and its environment values live in a source-control secret on another. "Aren't we using GCP?" has the answer "GCP is where it runs, GitHub is where the values live" — and the way to be sure is to read the deploy workflow, not to reason from the hosting bill.

Never Let Missing and Wrong Share a Status Code

The design lesson that shipped as the actual fix is the most portable thing in this guide.

A missing credential on your side and an incorrect credential on the caller's side must not return the same response. They had both returned 401, which is why nine days went by: from outside, a correct key, a garbage key, and no key at all all produced an identical 401. That identical response is itself the diagnosis — if all three match, the server has nothing to compare against, and the problem is yours.

Configured-but-wrong returns 401. Not-configured returns 503, and logs an error naming the missing variable. The caller learns something true, and your own logs tell you the answer without an investigation.

🧨
Config surfaces with no version history are where deploys go to die. Migrations have a version table. Function code has a git hash. Dashboard-applied settings — auth email templates, redirect allow-lists, provider webhook configuration, feature flags — have neither. We audited a repo whose only evidence that a critical auth template had been deployed was a single sentence in a session log. The template file in the repo was correct; the running project served a different one; signup had been broken for every new user and nothing anywhere could detect the drift. For any config surface without its own versioning, verify against the live system directly. A written claim that it was deployed is not evidence that it was.

Phase 5: Performance — Measure Before You Refactor

Phase 5 · Performance

The scary file was 6% of the weight

measured before refactoring · loops ~15s
The 2,249-line page's own code96 KB
Shared framework, every page1,605 KB
Where the weight actually was
Analytics library~304 KBtwo chunks, root layout critical path
Animation library118 KBroot layout critical path
The planned islands refactor would have saved ~4% of total JS, for the risk of restructuring the single riskiest file in the repo.
Diff production chunk manifests between two pages. Shared set versus unique set, no local build required — and the ratio between them is the entire decision.

The most expensive performance work is the work aimed at the wrong thing, and the wrong thing is usually the file that looks scariest.

Case Study: The 2,249-Line File That Was Not the Problem

A marketing site shipped roughly 1.7 MB of client JavaScript. The obvious culprit was a 2,249-line landing page component marked 'use client', with nine useState and nine useEffect hooks. The brief was an islands refactor: split it up, stop hydrating everything.

We mapped every hook, planned the split, and then measured before touching anything.

The page's own code was 96 KB — 88 KB of that the page chunk itself. The other 1,605 KB was shared framework loaded on every page, including pages with no interactivity at all. The proposed refactor would have saved around 4% of total JavaScript, in exchange for restructuring the single riskiest file in the repository.

The actual weight was two dependencies sitting in the root layout's critical path: an analytics library at roughly 304 KB across two chunks, and an animation library at 118 KB. Deferring those is a low-risk change to two import statements. The file-size scare had hidden it completely.

How to Diagnose Client-Bundle Cost

Separate delivery cost from execution cost first. They are different axes and people conflate them constantly:

curl -s "$SITE/<page>" -o /tmp/a.html -D /tmp/a.head \
  -w "html %{size_download}B  ttfb %{time_starttransfer}s\n"
grep -i "x-vercel-cache\|age:" /tmp/a.head

A cache hit with sub-second time-to-first-byte means delivery is fine and the complaint is about JavaScript execution, not the network.

Then diff production chunk manifests between two pages. This needs no local build at all — take the page under investigation and any unrelated page on the same site, ideally one with little interactivity:

curl -s "$SITE/<target>" | grep -oE '/_next/static/chunks/[^"]+\.js' | sort -u > /tmp/t.chunks
curl -s "$SITE/<other>"  | grep -oE '/_next/static/chunks/[^"]+\.js' | sort -u > /tmp/o.chunks
comm -12 /tmp/t.chunks /tmp/o.chunks   # shared: the framework cost
comm -23 /tmp/t.chunks /tmp/o.chunks   # unique: the page's own cost

Sum the sizes of each set. The ratio between them is the entire decision. If the shared set dominates, no amount of refactoring the page will help.

📉
A local build failure in a fresh worktree is often not a real build break. We have hit a native-module resolution error from an unrelated transitive dependency in a fresh worktree that did not exist on a clean checkout of the same branch. Before treating a local build failure as blocking, confirm it reproduces on a clean checkout of the base branch. If it does not, it is an environment artifact — use the production-manifest method above instead of fighting it.

There is a companion reliability bug worth naming here, because it hides inside every "make this faster" job. A <button onClick> that navigates is dead until React hydrates. On a heavy page that is a real window — often a second or more on mobile — during which the primary call to action does nothing when clicked. The fix is a real <Link href> anchor, which works before any JavaScript runs. Be aware that this change breaks tests asserting on window.location.href, which is the small price.


Phase 6: Shipping — The Gap Between Merged and Live

Phase 6 · Shipping

Five places between merged and live that report success

each one actually happened · loops ~16s
CI is greenbut the pipeline never builds — type-check, lint, test only
Deploy exits 0missing subcommand printed a help blob and exited zero
New build is liveat a URL nobody has — the alias never moved
Page loads for youaccess protection on by default; only logged-out viewers see the login wall
Poll says not readystatus cells carry terminal colour codes, so the grep matched nothing, forever
Verify the artifact curl the live URL: status, title, byte size, and one string you know is new. Four assertions, five seconds. A command's exit code only proves the command ran.
A merged pull request is not a deploy, and a deploy is not a working page. Every item above was reported as success by the tool that produced it.

A merged pull request is not a deploy, and a deploy is not a working page. The steps between them are where a surprising share of production incidents live.

What CI Does Not Cover

Read your pipeline definition before trusting it. On one of our client repositories, npm run ci expands to type-check, lint, and test with coverage. Nothing in it builds the application. A build break is therefore invisible to CI and surfaces only on the hosting provider, after merge.

The inverse trap exists too: a local build can fail while the deployed build passes, because they use different bundlers. On the same repository, a plain webpack build fails on a generated-type constraint in a file untouched for seven months, while the real deploy script uses a different bundler that does not enforce that constraint — and builds the same commit fine. Confirm against a preview deployment before reporting a build break as a blocker.

Two more from the same repository, both worth checking on yours:

Deploy Commands That Lie

A collection of things that reported success and had not happened:

✅
The rule underneath all of these: verify the artifact, not the command. After any deploy, curl the live URL and check the status code, the title, the byte size, and one string you know is new. Four assertions, five seconds, and they are the only thing that actually proves the deploy landed. A command's exit code proves the command ran.

Phase 7: Shipping to the App Stores

Phase 7 · App stores

Five builds, and a wall that was not a build at all

no two failed the same way · loops ~17s
45
Green on the old imagecompiles, submits, everything looks fine
shipped
46
Image left on the auto aliaspicked Xcode 16; Apple has required 26+ since 28 Apr 2026. Build green, upload green, submit queued, never appeared in TestFlight.
silently rejected
47
Pinned Xcode 26 → compiler define, scopedthe vendored fmt header has no #ifndef guard, so it discards any -D flag
2-min hard fail
48
Same define, every targetstructurally impossible, not mis-scoped. Three guesses before anyone opened the header.
identical fail
49
Patch the vendored header in post-installinstalled read-only, so make it writable first or CI dies on permissions
compiled
—
Submission 403s anywayREQUIRED_AGREEMENTS_MISSING_OR_EXPIRED. An unsigned Paid Applications Agreement. Account Holder only, web console only, no API, no rebuild helps.
blocked on legal
Three states, not one A green build is not a submission, and a queued submission is not a release. The tooling reports the first far more loudly than the other two, and the CLI at that version cannot list submissions at all.
Budget store submission as its own phase with its own failure modes, not as the last ten minutes of the release. A built artifact expires after about thirty days, so a long enough block forces a rebuild.

Everything in Phase 6 assumed you control the deploy. On iOS and Android you do not. A store release is not a deploy, it is a submission, and the difference is the entire difficulty: your artifact is handed to a third party who can reject it silently, for reasons that have nothing to do with your code, on a clock you do not set.

Two rules before any of the mechanics:

Case Study: PilotsLogAI — Four Builds to Get One Submission Through

The clearest illustration we have of how store releases actually fail. Build 45 was green. Getting a build into TestFlight took until build 49, and every failure was a different category.

Build 46 — the silent rejection. Apple has required Xcode 26 or newer for App Store submissions since 28 April 2026. Our build profile pinned no image, so the build service's auto alias picked whatever it defaults to, which was an Xcode 16 image. The build compiled. It uploaded. The auto-submit queued successfully. And it never appeared in TestFlight, with no CLI-visible error at any point — the CLI at that version has no command to list submissions at all, so a rejected submission is invisible from the terminal. The only signal was Apple's email.

Build 47 and 48 — the fix that could not work. Pinning an Xcode 26 image traded a silent rejection for a hard failure. Xcode 26 ships a compiler that enforces stricter C++20 consteval rules, which the fmt library vendored by React Native 0.79 violates. The build now died in about two minutes with call to consteval function is not a constant expression.

Two escalating attempts at a compiler define both failed identically: first scoped to the offending pods, then broadened to every pod target plus an explicit flag. Reading the actual vendored header explained why. fmt decides that macro itself in an #if/#elif chain with no #ifndef guard, so it unconditionally redefines it and silently discards anything passed on the command line. The approach was structurally impossible, not mis-scoped — and three guesses were spent before anyone opened the header.

Build 49 — the fix that worked. Patch the vendored header directly in a post-install hook, after the dependency manager has written it. One gotcha that costs a build: the header is installed read-only, so the hook must make it writable first or the build dies with a permissions error on the CI machine.

And then the submission 403'd anyway. Build 49 compiled, uploaded, and every submission failed at the store handoff with FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED. That is not a build defect. The app has in-app purchases, so it was the Paid Applications Agreement, which Apple re-issues periodically and which silently rejects every upload until re-signed. No rebuild, no manifest key, and no build setting affects it. Only the Account Holder can accept it, only in the web console, and there is no API.

🍎
The pattern across all four failures: none of them were caught by the build, and none of them were visible from the CLI. A silent image mismatch, a compiler change in a vendored dependency, and an unsigned legal agreement. Budget for store submission as its own phase with its own failure modes, not as the last ten minutes of the release. And note the clock: a built artifact expires after about thirty days, so a submission blocked long enough forces a rebuild.

What Each Store Actually Requires

The parts that are genuinely different, rather than the parts every tutorial covers:

iOS App StoreGoogle Play
IdentityApple Developer Program, $99/yr, and the Account Holder is a distinct role from AdminPlay Console, $25 once
SigningCertificates and provisioning profiles, or let the build service manage themAn upload key you must never lose; Play re-signs with its own app signing key
Machine gateMinimum Xcode version, enforced at submission, changes without warningTarget API level, enforced yearly, with a hard cutoff date
Test trackTestFlight, internal is instant, external needs reviewInternal, closed, open, production, each with its own promotion step
ReviewDays, human, and rejections cite guideline numbersUsually faster, more automated, with a staged rollout percentage
The trapLegal agreements silently 403 uploadsStaged rollout means "released" can mean 5% of users

The Submission Checklist

⚠️
The most expensive mobile-specific bug we have shipped was invisible to every test. On a bare-workflow app with native directories committed, the build service builds from those files directly — so the app manifest and the native configuration are two separate sources of truth. A dark-mode retrofit touching 145 colour literals across 44 files would have compiled clean, passed every test, and shipped as a complete no-op, because the native config independently forced light appearance. Check all three places before declaring an OS-level feature done. Neither the type-checker nor the test suite renders against a real OS setting.

Phase 8: Repository Structure — The Layout Is a Load-Bearing Decision

Phase 8 · Structure

Boring on purpose, and every deviation is a decision

six real failures, marked · loops ~16s
src/by feature, not by type
tests/mirrors src/ exactly, so gaps are visible
scripts/anything a human or CI runs by name
config/environment and tool config
docs/adr/one file per irreversible decision
.github/CI — its absence is itself a finding
CONTEXT.mdthe domain glossary the whole team uses
What we actually find
Two sources of truth, and the stale one told you to point production at the staging database
44 compiled .js beside their .ts sources, one drifted and missing a security fix
A guard globbing only .ts/.tsx, so a violation in a .js twin passes it
15 failures in root scripts npm test never invokes
Zero .github/ in 1,208 commits, beside 140 migrations
1,195 of 1,208 commits by one author, one branch, no pull requests
Two seconds git log --format='%an' | sort | uniq -c | sort -rn gives you the last one, and it belongs in every takeover assessment.
Layout looks like taste and behaves like architecture. Feature folders make the deletion test physical, a mirrored test tree makes coverage gaps visible, and both give a writing agent a boundary you can state in one line.

Repository layout looks like taste and behaves like architecture. A good structure makes the right thing easy to find and the wrong thing hard to write. A bad one produces the failures below, all of which we have found in real audits, and none of which are about code quality.

The Baseline Layout

Boring on purpose. Every deviation should be a decision someone can defend:

repo/
  src/            application code, organised by feature not by type
  tests/          mirrors src/ exactly, so a missing test is visible
  scripts/        anything a human or CI runs by name
  config/         environment and tool configuration
  docs/
    adr/          one file per irreversible decision
    runbooks/     what to do when it breaks
  .github/        CI. Its absence is itself a finding
  CONTEXT.md      the domain glossary the whole team uses
  README.md       how to run it, in under five commands

Four rules that do most of the work:

Six Structural Failures We Have Actually Found

These are all from real audits of handed-over codebases, and every one of them was invisible to the test suite:

The Two Files That Pay for Themselves

CONTEXT.md — the domain glossary. Ten to thirty terms, each one sentence, defining what a "project", a "run", a "tenant", or an "application" is in this product. It is the cheapest possible fix for the most expensive recurring problem, which is two engineers using the same word for different things. It also gives coding agents the vocabulary to name things consistently, which matters more every month.

docs/adr/ — one short file per irreversible decision, in the Phase 1 sense. What we chose, what we rejected, and what would make us revisit. Two paragraphs. The value is not the archive, it is that writing it surfaces the assumption you had not noticed, and it stops the same decision being relitigated every six months by whoever is newest.

🧭
Structure is also how you work safely alongside AI agents. Feature folders give each writing agent a non-overlapping ownership boundary you can state in one line. A mirrored test tree makes "did it write tests" checkable mechanically. CONTEXT.md and the ADRs are the two files worth loading into every agent's context, because they carry the decisions the code cannot tell you. And name worktree directories so they cannot collide with a test or feature name, or your single-file test run will quietly execute the entire suite.

Bonus: Depth — The Refactor That Is Actually Worth Doing

Bonus · Depth

The deletion test decides the refactor

imagine inlining it everywhere · loops ~13s
Delete it → complexity vanishes
It was a pass-through. Deleting it is the refactor.
A wrapper that only forwards its arguments.
Delete it → complexity reappears in five call sites
It was earning its keep. Make it deeper, not smaller.
A module absorbing retries, ordering, and error modes behind one call.
The interface is the test surface. Hard to test usually means the interface is wrong, not that you need more mocks.
One adapter is a hypothetical seam. Two is a real one. Build the abstraction when the second implementation arrives, and let it shape the seam.
Interface means everything a caller must know — types, invariants, error modes, ordering, configuration. Not just the signature. Depth is behaviour absorbed behind a small one.

Most refactors move code without reducing complexity. The ones worth doing make a module deeper: more behaviour absorbed behind a smaller interface.

The vocabulary, borrowed from Ousterhout and used deliberately because consistent language is most of the value:

The deletion test is the working tool. Imagine deleting the module and inlining it everywhere. If complexity vanishes, it was a pass-through and deleting it is the refactor. If complexity reappears in five call sites at once, it was earning its keep.

Two corollaries we use constantly:


Bonus: Working Alongside AI Agents

Every Varritech build now involves multiple agents writing code in parallel, and that changes the operational rules more than it changes the code.


Bonus: The Production Readiness Checklist


Bonus: Tools and Resources Library

Framework and runtime

Data

Payments and identity

Observability

Working method


Start here

Deeper

Worth following


What's Next?

Guide 4 covers security and compliance — the threat model, secrets, fail-open failures, and the erasure audit that finds the tables everyone forgets. Guide 5 covers testing, and specifically the ways a green suite lies. Guide 2 covers the design system that sits on top of this architecture.

If you would rather have this installed than read about it, that is what we do.

Flagship · by application · 3 installations per quarter Scalewright Installation$15,000 Done-with-you. A six-week production build with 100% of the IP yours from day one, the four growth systems installed with it, and two working sessions a week inside your repo. We don't hand you a codebase and walk, we stay on it until you hit $10K MRR. Guaranteed. Book a consultation →
Claude Code · monthly The Skillsmith Club $47/mo · cancel in one click The 213 production skills behind this guide, plus a few new ones and one autonomous agent dropped every month. Ship $10,000 of agency-grade software by Sunday or we refund every dollar. Application-only Scalewright Circle™ Free · no card, ever The room where founders ship every week. Priority answers from our engineers, weekly office hours, blueprint pods, and vetted investor intros. Access worth $650+/mo.
Varritech

Seven guides in the playbook.
Product strategy, design systems, production engineering, security, testing, go-to-market, and AI scaling.