Architecture isn't only how the code is organised. It's the set of invariants that hold when you're tired, and the only ones that hold are the ones a machine enforces. A convention you have to remember is a bug with a delivery date.
The concept
Every project accumulates rules: this folder is generated, don't edit it · always run the sync script · main is production. Written down, they're documentation. Enforced by tooling, they're architecture.
The difference matters most for small teams, because there's nobody to catch you. Solo, at 1am, you will edit the generated file. The protection isn't discipline; it's that the next build overwrites it and something fails loudly.
The four kinds of file
Every file in a repo is one of these, and confusing them causes most structural bugs:
| Kind | Example | Rule |
|---|---|---|
| Source | your code, content, assets | Edit freely; this is truth |
| Generated | build output, mirrored dirs, native projects | Never edit. Regenerate. Ideally gitignore. |
| Config as code | scripts that apply settings | Tracked, idempotent, re-runnable |
| Secrets | keys, tokens, credentials | Never in the repo. Env vars or a manager. |
Generated directories should be hostile to editing, overwritten on every build, and gitignored unless you have a specific reason to track them.
Single source of truth
Any piece of information should live in exactly one place. When it lives in two, they drift, and the drift is silent.
Common duplications and their fixes:
| Duplication | Fix |
|---|---|
| Same content in web + mobile | One source, mirrored by a script at build |
| Version number in 4 files | One file; others read it |
| Config in code + dashboard | Pick one as authoritative; document which |
| Native settings edited in an IDE | A tracked script that applies them |
Config as code
Native mobile projects, cloud dashboards, and IDE settings all tempt you to click something once and forget. Then the folder is regenerated, or you get a new machine, and it's gone.
Anything that lives in a generated directory but must survive regeneration belongs in a tracked, idempotent script. Idempotent means safe to run a hundred times, each step checks first, reports "already present," and moves on.
This is the cheapest infrastructure-as-code there is, and it eliminates the "it works on my machine because I clicked something eight months ago" failure.
Environments and secrets
Minimum viable setup:
local .env.local real dev keys, gitignored
preview per-branch test keys, auto-deployed
production platform secrets real keys, restricted access
Rules that prevent the common disasters:
- Never commit secrets. Use
.env+.gitignore, and a secret scanner in CI. - Publishable vs secret keys are different things. Publishable keys (Stripe
pk_, Supabase anon) are designed to be public provided your server-side rules are correct. Secret keys never touch the client. - Annotate the confusing ones. When you commit an identifier that looks like a secret but isn't, write a comment saying why. Otherwise a future reviewer either panics or, worse, assumes all the others are safe too.
- Rotate anything that leaks, immediately. Git history is forever.
Branch and deploy strategy
Solo or small team, this is enough:
main → production. Deploys automatically.
development → integration. Where work lands.
feature/* → short-lived, merged quickly.
Write down which branch is production. Every "why isn't my change live?" incident is a branch mismatch, and it always costs an hour of confusion.
Scripts, and the rule that matters most
Anything you do more than twice becomes a script. But the critical property isn't automation, it's honesty:
📐 Any step that can partially fail must verify its own post-condition and fail loudly when it isn't met.
"The command ran and exited 0" proves it ran, not that it worked. A cleanup that silently matches nothing, a build that reports success over a failure, a sync that copies half the files, all exit 0. Silent success is worse than loud failure because you build on top of it for weeks.
In shell specifically: set -euo pipefail at the top of every script; never || true on something whose failure matters; and remember that a pipeline's exit status is the last command's.
Documentation that survives
Three documents earn their keep, and no others:
- README, what this is, how to run it, how to deploy.
- STATUS, where the project is now (Chapter 08).
- Decision records, what we chose, what we rejected, why, dated.
Date everything. Stale documentation is worse than none, because it's confidently wrong and people follow it.
📐 Best practice
Enforce invariants mechanically. If a rule matters, make a script fail when it's broken.
Make generated directories obviously derived, overwritten every build, gitignored by default.
Put anything that must survive regeneration into a tracked idempotent script.
Verify post-conditions in every destructive or transforming step. Count what you deleted; assert the success marker.
Name your production branch out loud, and verify the live site after every promotion.
Keep secrets out of the repo, and run a secret scanner in CI.
Date your docs. Re-read them at milestones.
One command from clean checkout to running app. If setup takes an afternoon, contributors (including future you) will avoid it.
Keep repos out of cloud-synced folders (iCloud, Dropbox, OneDrive). They create conflict copies, rewrite extended attributes, and race with your tools.
Avoid spaces in project paths. They break tools in ways you'd never predict.
💀 Common mistakes
Editing the generated copy. The change vanishes on the next build, and you lose an hour before realising.
Tracking a generated directory without a reason. Now junk that lands in it is one git add from permanent history.
Trusting exit codes. A script that can't fail loudly will eventually lie.
Config clicked into a dashboard or IDE, never recorded. Gone on regeneration or a new machine.
Secrets in the repo. Even removed, they're in history. Rotate.
Deploying the wrong branch. Extremely common; produces confident 404s on pages you know exist.
Documentation that goes stale. It actively teaches the wrong thing.
One giant file. Fine solo, hostile with a team. Plan the split before the second person arrives.
Repos in cloud-synced folders. Conflict copies, codesign failures, mysterious duplicates.
The professional workflow
1. DECIDE THE SOURCE OF TRUTH for every kind of artifact
2. MAKE GENERATED DIRS HOSTILE
overwritten each build · gitignored unless justified
3. WRITE THE SYNC/BUILD SCRIPT
set -euo pipefail · verify post-conditions · fail loudly
4. MOVE NATIVE/PLATFORM CONFIG INTO A TRACKED IDEMPOTENT SCRIPT
5. SET UP ENVIRONMENTS + SECRETS
.env.local gitignored · platform secrets for prod ·
secret scanning in CI · annotate public-but-scary identifiers
6. DEFINE BRANCHES. Write down which one is production.
7. WRITE README + STATUS + decision records. Date them.
8. VERIFY: clean checkout → one command → running app
Tools, websites & costs
| Need | Tool | Cost |
|---|---|---|
| Version control | git + GitHub/GitLab | $0 |
| Secret scanning | gitleaks, GitHub secret scanning | $0 |
| Secret management | Platform env vars, Doppler, 1Password | $0-20/mo |
| Monorepo tooling | Turborepo, Nx, pnpm workspaces | $0 |
| Task running | Make, npm scripts, just | $0 |
| CI | GitHub Actions | Free tier |
| Docs | Markdown in the repo | $0 |
| Decision records | Markdown ADRs in /docs/decisions | $0 |
| Diagrams | Mermaid, Excalidraw | $0 |
Everything here is free. This chapter is entirely discipline.
Alternatives & trade-offs
Monorepo vs polyrepo. Monorepo is simpler for a small team sharing code, one clone, one PR, atomic changes. Polyrepo suits independent deployables and separate teams. Start monorepo.
Trunk-based vs git-flow. Trunk-based (short-lived branches, merge daily, feature flags) suits continuous deployment. Git-flow suits versioned releases. Solo, trunk-based with a development integration branch is plenty.
Gitignore generated output vs track it. Tracking gives you a runnable artifact in the repo and makes diffs noisy and history heavy. Ignoring is cleaner and requires a build to run anything. Default to ignoring.
Scripts vs Makefiles vs npm scripts. Whatever your team reads without thinking. Consistency beats sophistication.
Documentation-heavy vs code-only. Solo, keep three documents and no more. The moment you have a second person, README quality becomes the highest-leverage writing you do.
Checklist
- Every artifact has exactly one source of truth
- Generated directories are overwritten each build and gitignored (or the exception is justified)
- Every transforming/destructive step verifies its post-condition
- Scripts start with
set -euo pipefail; no|| trueon anything that matters - Platform/native config lives in a tracked idempotent script
- No secrets in the repo; scanner in CI
- Public-but-scary identifiers are annotated as safe
- Production branch is documented, and I verify the site after promoting
- README, STATUS and decision records exist and are dated
- Clean checkout → one command → running app
- Repo is outside cloud-synced folders; no spaces in the path
📓 Case Study: a cleanup script that silently did nothing
Project: SOLIS. Three architecture decisions, one near-disaster.
The single-source-of-truth rule. The native wrapper requires a www/ directory. The tempting move is to develop there directly, and then you have two copies of your app, and they drift. The rule, stated in the build guide and again inside the sync script:
www/is generated, not source. The single source of truth is the project root.sync-www.shmirrors root →www/. Never hand-editwww/.
What made the rule hold wasn't the rule. It was that sync-www.sh runs inside every build script, so any hand-edit to www/ is silently overwritten on the next build. The system reasserts the invariant automatically.
💀 The 263 MB near-miss, three failures stacked.
- Environment. The repo lived in an iCloud-synced folder, which creates conflict copies (
index 2.html,assets 2/), hundreds of them. - Code. The sync script had a scrub step to delete them. It used
find -regexwith GNU+/?quantifiers, which mean something different in BSD (macOS) regex. The pattern matched nothing. No error, no output, exit code 0, "success." - Design.
www/was tracked in git. So every sync copied ~700 duplicate files into a tracked directory. They accumulated to 263 MB, onegit add .from being permanently in history, where you cannot remove them without rewriting history for everyone.
Found by accident, during unrelated work.
The fix addressed the class, not the instance:
find "$WWW" \( -name "* [0-9]" -o -name "* [0-9].*" \) -print0 | xargs -0 rm -rf || true
leftover=$(find "$WWW" \( -name "* [0-9]" -o -name "* [0-9].*" \) | wc -l | tr -d ' ')
if [ "$leftover" != "0" ]; then
echo "sync-www: ERROR — $leftover conflict copies still in www/" >&2
exit 1
fi
Two changes. Portable syntax instead of platform-dependent regex, and, more importantly, run the cleanup, then count what's left, and fail loudly if it isn't zero.
📐 The generalisable rule: a cleanup step must verify its own result. Exiting 0 proves the command ran, not that it worked. This exact failure shape recurred three more times in the same project, a build script that reported success over a failed build, an asset loader that silently substituted a placeholder, and a payment stub that silently granted free access. Same shape, four subsystems.
Config as code. The ios/ directory was gitignored (regenerable, large), but native settings normally live inside it, so they evaporate on regeneration. The fix was configure-ios.sh, tracked and idempotent, run automatically by the build. Its header records why it exists:
ios/ is gitignored and regenerable, so anything hand-edited in Xcode is untracked and silently disappears on the next regeneration, that is exactly how the app icon work was lost once already.
The script is scar tissue, and every step reports "already present" when re-run.
💀 The branch bite. The workflow was: work on development, promote to main only when told. main is what the host deploys.
Legal pages were built and committed, to development. The site went live with its privacy-policy URL returning 404, which is an app-store submission blocker. Fixed by merging to main.
Write down which branch is production, and check the live site after every promotion. Every "why isn't it live" incident is this.
One thing worth copying: the environment workarounds were all scripted with their reasons in comments, an iCloud quirk that broke code signing, a root-owned directory that broke a package manager's cache, a locale requirement caused by a space in the project path. Each is three lines of script and a comment explaining what breaks and why the fix works. On a new machine eight months later, those comments are the difference between a minute and an afternoon.
Lessons
- Mechanically enforce your invariants. A rule you must remember is a bug scheduled for a tired day.
- Cleanup must verify itself. A regex matching nothing exited 0 and nearly put 263 MB in history permanently.
- Silent success is the dominant failure shape. It recurred four times in one project across unrelated subsystems.
- Generated directories should be hostile to editing, overwritten, gitignored, obviously derived.
- Track the generated dir only with a reason. Tracking is what turned junk files into a near-incident.
- Config that lives in generated folders belongs in a tracked idempotent script. It cost an app icon to learn.
- Name your production branch. Every "why isn't it live" is this.
- Script every environment workaround with its reason. Comments are what save you on a new machine.
- Cloud-synced folders are hostile to development. Conflict copies, codesign failures, races.
- Portable shell or explicitly platform-pinned. GNU and BSD tools differ in ways that fail silently.