Larry the Bot, Part 2: The Architecture — Four Apps, Five Libs, One Truth
Part 2 of the Larry series. Part 1 follows the product journey. Part 3, scheduled for July 24, goes deep on caching and cross-process sync.
An admin changes Larry’s recruiting channel in a React form. The API authorizes the user and writes Firestore. A Discord bot running in a container on my homelab must use the new channel on its next command.
No process is shared. No deploy is shared. The browser, Firebase Functions, and bot are three different memory spaces connected by APIs and documents.
That one config change is the shortest useful tour of Larry’s architecture.
The constraint that shapes the system
The bot and the admin API are different processes with different lifecycles:
| Runtime | Process model | Can in-memory state be authoritative? |
|---|---|---|
apps/bot |
Long-lived Docker container on my homelab | Only as a cache; Firestore remains truth |
apps/api |
Firebase Functions with disposable cold and warm instances | No |
apps/web |
Static React app in the browser | No |
Functions and browsers can hold memory. The point is that Larry cannot depend on a particular Functions instance or browser tab remembering the canonical configuration. Those processes appear, serve, refresh, and disappear.
The bot can keep listeners and caches because it is long-lived. It still cannot treat them as truth because the API can write Firestore from somewhere else.
Four deployables, five libraries
larry-the-bot/
├── apps/
│ ├── bot/ # Discord bot — discord.js, CoC client, schedulers
│ ├── api/ # Firebase Cloud Functions (one Express onRequest)
│ ├── web/ # Vite + React admin SPA
│ └── site/ # Astro marketing site (getlarry.io)
├── libs/
│ ├── firestore-core/ # Document shapes, collection constants, path builders — zero deps
│ ├── firestore-admin/ # Admin SDK repositories, shared by bot + api
│ ├── validation/ # Zod schemas mirroring the document shapes
│ ├── discord/ # Permission bitfield helpers, invite URL builder — no discord.js
│ └── types/ # Cross-cutting API DTOs only
└── improvements/ # PLAN.md + canonical ARCHITECTURE-*.md docs
The applications deploy independently:
apps/botowns the Discord gateway connection, commands, schedulers, CoC client, caches, and image rendering.apps/apiowns Discord OAuth, sessions, authorization, and admin-panel endpoints.apps/webis the React operator interface.apps/siteis the Astro marketing site at getlarry.io.
The libraries keep vocabulary and behavior shared without pulling every runtime into every bundle:
firestore-core— document shapes, path builders, constants, and pure utilities; no Firebase Admin or Discord runtimefirestore-admin— Admin SDK repositories used by bot and APIvalidation— Zod schemas that mirror accepted document and API shapesdiscord— permission helpers and invite construction without the fulldiscord.jsgateway stacktypes— cross-cutting API DTOs, the data-transfer objects exchanged between surfaces
This is not abstraction for sport. The specific failure it avoids is the bot and API growing two slightly different ideas of BotConfig, cooldown state, or a linked player.
Follow one admin save
Suppose an operator changes the clan-recruiting channel.
- The browser sends a guild-scoped API request. It never talks to Firestore directly.
- The API resolves the session and checks Discord permissions. Guild membership and permission data come from Discord-backed lookups with a short cache, currently 30 seconds.
- Zod validates the payload. A channel ID does not become trusted because it arrived in JSON.
- The shared repository writes the recruiting module. In the same Firestore batch, it bumps metadata on the root config document.
- The bot watches the root document. That change event invalidates the bot’s merged config cache.
- The next command reads the merged v2 config. It combines root metadata with general, recruiting, and push module documents.
The listener does not receive the entire new config and write it through to memory. It receives the root signal, invalidates, and lets the normal repository read path rebuild the merged value.
That distinction is why every module write also touches the root. Editing a module document manually without bumping root metadata bypasses the signal and waits for normal cache expiry. The schema has a doorbell; you have to ring it.
Config is modular now
Larry’s first config document was becoming a junk drawer. The current v2 shape keeps root metadata separate from feature modules:
| Path | Purpose |
|---|---|
botconfig/{guildId} |
updatedAt, revision, schema version, and other root metadata |
botconfig/{guildId}/general/config |
language, timezone, shared asset channel |
botconfig/{guildId}/recruiting/config |
recruiting channels, roles, colors |
botconfig/{guildId}/push/config |
/me and leaderboard configuration |
The repository still reads older flat config fields during the migration window. Removing that compatibility is tracked separately; new writes use module documents.
Core recruiting data uses separate collections too:
players/{guildId}/linked/{playerTag}for verified account links and normalized CoC snapshotsclanposts/{guildId}/posts/{postId}andplayerposts/{guildId}/posts/{postId}for append-only history- dedicated template and schedule collections rather than growing arrays inside config
These are the core product paths, not an exhaustive Firestore map. Sessions, runtime heartbeat, and newer feature data have their own owners. Guild-owned documents and L1 cache keys include guildId; global CoC cache entries and cross-guild operational data are intentional exceptions.
Database ownership has one explicit exception
Commands and route handlers use typed repository methods instead of raw Firestore handles. The repositories own CRUD behavior, timestamps, module writes, and their Firestore L1 cache invalidation.
One deliberate exception lives in the bot: ConfigSyncService attaches the root snapshot listeners needed for cross-process invalidation. That is infrastructure wiring, not a second data-access model.
CoC cache invalidation is also not a Firestore repository concern. Link, unlink, and player-sync orchestration own the upstream game-data cache because they know which CoC entry became stale.
Clear ownership matters more than a slogan claiming one library touches every database object.
The security boundary sits in the API
Both server processes use the Firebase Admin SDK, which bypasses Firestore security rules. The access-control path therefore happens before a repository mutation:
- Discord OAuth identifies the user.
- An HTTP-only cookie carries a random session identifier; the server hashes that identifier for lookup and keeps Discord token material encrypted in Firestore.
- Guild-scoped requests check whether the user is the owner or has
ADMINISTRATOR/MANAGE_GUILD. - Mutations require a CSRF token—protection against another site submitting a request with the user’s cookie—and an allowed
Origin. - Zod validates the input before a typed repository call.
The public Firebase project configuration is not a secret. Firestore rules still deny unauthorized client-SDK access if somebody wires up a browser client. They are a backstop around the database; application authorization remains the mechanism used by Larry’s API.
Shipping follows the process boundaries
The monorepo uses pnpm, Turborepo, and Changesets, but the deployment units remain separate.
GitLab runs formatting, dependency hygiene, lint, type checks, tests, command-generation checks, and builds as separate jobs. Slash-command definitions are generated and committed; CI verifies that the generated file matches the command code.
Version finalization starts manually on develop: Changesets are consumed, package versions and changelogs update, and the finalize commit is merged to main. A separate manual release gate on main approves the production deployment.
Firebase Functions, the admin web app, Firestore assets, the bot image, and the marketing site do not need to ship as one process. That independence is useful right up to the moment two versions disagree about config.
What I’d tell the index.js version of me
Decide where truth lives. Then name every other copy for what it is: a cache, a browser view, a generated artifact, or a compatibility layer.
Larry’s package boundaries, modular config, repository layer, and root change signal all serve that rule. They do not make drift impossible. They make ownership visible enough to test.
Part 3 starts where this overview stops: two independent data paths, several caches, and a bot that has to be deliberate about how stale it is willing to be.