All chapters Part IV · Building
Chapter 16

Project Architecture and Working Practices

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:

KindExampleRule
Sourceyour code, content, assetsEdit freely; this is truth
Generatedbuild output, mirrored dirs, native projectsNever edit. Regenerate. Ideally gitignore.
Config as codescripts that apply settingsTracked, idempotent, re-runnable
Secretskeys, tokens, credentialsNever 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:

DuplicationFix
Same content in web + mobileOne source, mirrored by a script at build
Version number in 4 filesOne file; others read it
Config in code + dashboardPick one as authoritative; document which
Native settings edited in an IDEA 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:

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:

  1. README, what this is, how to run it, how to deploy.
  2. STATUS, where the project is now (Chapter 08).
  3. 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

NeedToolCost
Version controlgit + GitHub/GitLab$0
Secret scanninggitleaks, GitHub secret scanning$0
Secret managementPlatform env vars, Doppler, 1Password$0-20/mo
Monorepo toolingTurborepo, Nx, pnpm workspaces$0
Task runningMake, npm scripts, just$0
CIGitHub ActionsFree tier
DocsMarkdown in the repo$0
Decision recordsMarkdown ADRs in /docs/decisions$0
DiagramsMermaid, 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


📓 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.sh mirrors root → www/. Never hand-edit www/.

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.

  1. Environment. The repo lived in an iCloud-synced folder, which creates conflict copies (index 2.html, assets 2/), hundreds of them.
  2. Code. The sync script had a scrub step to delete them. It used find -regex with GNU +/? quantifiers, which mean something different in BSD (macOS) regex. The pattern matched nothing. No error, no output, exit code 0, "success."
  3. Design. www/ was tracked in git. So every sync copied ~700 duplicate files into a tracked directory. They accumulated to 263 MB, one git 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

  1. Mechanically enforce your invariants. A rule you must remember is a bug scheduled for a tired day.
  2. Cleanup must verify itself. A regex matching nothing exited 0 and nearly put 263 MB in history permanently.
  3. Silent success is the dominant failure shape. It recurred four times in one project across unrelated subsystems.
  4. Generated directories should be hostile to editing, overwritten, gitignored, obviously derived.
  5. Track the generated dir only with a reason. Tracking is what turned junk files into a near-incident.
  6. Config that lives in generated folders belongs in a tracked idempotent script. It cost an app icon to learn.
  7. Name your production branch. Every "why isn't it live" is this.
  8. Script every environment workaround with its reason. Comments are what save you on a new machine.
  9. Cloud-synced folders are hostile to development. Conflict copies, codesign failures, races.
  10. Portable shell or explicitly platform-pinned. GNU and BSD tools differ in ways that fail silently.

Next: Chapter 17: Frontend and Web Development →

Useful? Share this chapter