Key Technical Decisions
The why behind the major architectural choices. Read this if you want to know whether a pattern is intentional, in flight, or just legacy that hasn't been cleaned up.
Each entry: what was decided · why · how it shows up in code.
Modular monolith
Decision: One Cloud Functions deployment with internal module
boundaries (15 modules under ../../functions/modules/) — not microservices.
Why: PIVOT has a small team and tightly-related domain entities (employees, schedules, attendance, payroll). A microservice split would have multiplied deploy surface, network hops, and auth plumbing without buying isolation we actually need. The module boundary lives in the directory structure + manual DI; if a future split is required, a single module can be peeled out.
How it shows up:
- Strict layer-import rules in ../../CLAUDE.md (Hard Rules section)
- No imports across modules — cross-module talk is async events only (Pub/Sub, RTDB triggers, Firestore triggers)
- Event payload types in
../../functions/shared/types/, not inside any module
Hono over Express
Decision: Use Hono for HTTP routing.
Why: Better TypeScript ergonomics, smaller bundle (matters for cold-start), modern middleware chain, future-proof for edge runtimes.
How it shows up:
- One Hono router per module at
endpoints/apis/router.ts - Sub-routers per entity composed onto the main router
../../functions/shared/infrastructure/hono-adapter.tswraps Hono into a Firebase Functions handler
esbuild + per-entry-point bundling
Decision: Build with esbuild; one bundle per Cloud Function entry point. Post-build path-alias rewrite step.
Why: Faster cold starts (each function only loads its own deps), fast local builds, predictable tree-shaking. Per-entry bundling lets us keep one repo while still deploying many small functions.
How it shows up:
../../functions/build.js— the build script- Entries: every
endpoints/apis/router.ts,endpoints/events/*.ts,endpoints/scheduled/*.ts - No DI framework / decorators (would conflict with tree-shaking)
Manual dependency injection
Decision: Each module wires its own dependencies in
container.ts. No inversify, no decorators, no auto-discovery.
Why: Explicit, type-safe, tree-shakeable. The two lines you save
with a DI framework are not worth the bundle size and reflection
indirection. New devs can Cmd+click from container to implementation.
How it shows up:
// functions/modules/<x>/container.ts
const repo = new RtdbXxxRepository();
const svc = new FcmNotificationService();
export const someEndpoint = someHandler({ repo, svc });
Three data stores, different roles
Decision: PIVOT splits persistence across three stores, each with a defined role:
- RTDB — real-time fan-out for things web + mobile subscribe to live (chat, schedule, attendance, presence, posts, requests). Source of truth for most domain data today; ~30
Rtdb*Repositoryclasses across the modules. - Cloud SQL (Postgres + Kysely) — typed schema for analytical / cross-cutting queries and the strategic destination for write paths over time. Populated today by one-way pg-sync listeners; one module already reads from it directly.
- Firestore — the integration-engine's state + observability layer. Stores integration configs, sync-job state, plugin-sync slices, provider health. Backend writes via admin SDK; web UI subscribes for live status.
Why RTDB for the live core: push-based real-time sync to web and mobile out of the box, which is the product's heartbeat.
Why Postgres on top: complex queries (joins, aggregations, sorted-index lookups) are painful in RTDB. Postgres carries a typed schema and a migration system — analytics and reporting want SQL.
Why Firestore for integration-engine: subcollections, transactional batches, and field-level FieldValue.increment map well to sync-job tracking. Per-document security rules made it the natural fit for an observability layer that the web UI also subscribes to.
How it shows up — Postgres:
- Client singleton at
../../functions/shared/infrastructure/pg-client.ts(Kysely +@google-cloud/cloud-sql-connectorwith IAM auth). - Typed schema in
../../functions/shared/infrastructure/pg-types/— companies, employees, shifts, positions, schedule-periods, users, company-relationships, company-settings, head-office-companies. - 6 migrations under
../../functions/migrations/pg/migrations/(core entities, schedule tables, updated-at triggers, head-office, plus backfills). - One-way sync listeners at
../../functions/db/pg-sync/—on-company-write,on-employee-write,on-schedule-write,on-schedule-settings-write,on-user-write, plussync-position-to-pg.ts. BasePgRepositorybase class at../../functions/shared/infrastructure/database.tsfor module repos to extend.- Schedule is the first module reading from Postgres in production:
../../functions/modules/schedule/repositories/schedule-period-recompute.pg-repository.tsqueriesschedule_periodsandshiftsdirectly. - A separate
../../functions/features/roles/tree (parallel tomodules/) has its own pg-sync listener — a newer organizational unit.
How it shows up — Firestore:
- Collections enumerated in
../../firestore.rules:IntegrationSettings,IntegrationPluginSyncs(with subcollectionsPluginSyncJobs→PluginSyncSlices),IntegrationProviderHealthStates. All writes are blocked from clients — backend-only via admin SDK. - Backend repositories at
../../functions/integration-engine/repositories/—base/base-firestore.repository.tsis the shared base;computed/{employee,sale,reservation}.repository.tswrite batched read-models;engines/sync-job.repository.tsuses transactions +FieldValue.incrementfor sync state. - Web subscribes via
../../src/modules/integrations/hooks/useIntegrationPluginSyncsListener.tsanduseIntegrationStatesListener.ts. - Mobile does not use Firestore.
For new code:
- Default to writing RTDB repositories — most domain data still lives there.
- Read from Postgres only when working in
schedule(or another module explicitly chosen for migration) and the data you need is in the schema. Don't sprinkle Postgres reads into modules that aren't migrated. - Don't expand Firestore use beyond the integration-engine surface — the collection-rules + base-repository setup is scoped to that one domain.
- Don't half-migrate a module: if you start moving a module's reads to Postgres, finish that read path or back it out.
Type-safe secrets enum
Decision: All secret names live in a single enum at
../../functions/get-secret.ts;
secrets are read once at cold start from GCP Secret Manager.
Why: Prevents string-typo runtime failures. One place to audit what credentials we hold. Cold-start fetch keeps the per-request hot path fast.
How it shows up:
- ~120 enum entries
- Legacy accessor:
secret('NAME') - New pattern:
defineSecret()fromfirebase-functions/params— see../../functions/integrations/givex/secrets.ts - Raw
process.envis forbidden (a few migration scripts excepted)
Frontend dual state — Redux + Zustand
Decision: Keep Redux for legacy code; new features use Zustand.
Why: Redux has the entire historical state graph (employees, companies, notifications, login). Zustand's smaller surface and persist middleware fit how new modules are scoped (auth session, requests, integrations — each owned by one feature).
How it shows up:
- Redux store:
../../src/store/, thunks in../../src/actions/ - Zustand stores:
../../src/auth/store/session.store.ts,../../src/modules/requests/store/,../../src/modules/integrations/store/ - Mobile follows the same dual-stack pattern
For new code: prefer Zustand. Don't add new Redux reducers.
React Router on mobile
Decision: Use react-router-native v6 on mobile, not React
Navigation.
Why: Web team built the mental model around React Router URLs.
Reusing that on mobile (with react-router-native) means devs who
work across surfaces don't context-switch the navigation primitives.
How it shows up:
PIVOT-Mobile/src/Router.tsxusesreact-router-native- Routes are defined as paths, not as named navigators
- Trade-off: gives up React Navigation's deep ecosystem of native
transitions, gesture handlers, and tab-bar primitives. Some custom
components in
PIVOT-Mobile/src/components/fill the gap.
Bare React Native, not Expo
Decision: Bare RN 0.77 with native iOS / Android projects.
Why: PIVOT mobile uses native modules that Expo's managed
workflow either restricts or wraps with friction:
react-native-vision-camera (QR for clock-in), @rnmapbox/maps,
fine-grained background geolocation, custom push handling, biometrics.
The build complexity (Fastlane, EAS-style lanes, multi-env signing)
is the cost of keeping that flexibility.
How it shows up:
- Native projects in
PIVOT-Mobile/ios/andPIVOT-Mobile/android/ - Build lanes in
PIVOT-Mobile/fastlane/Fastfile - Three Firebase project configs (dev / qa / prod) with separate bundle IDs
DTOs in common/, validation stays per-module
Decision: Plain types shared between web and functions/ live in
../../common/dtos/ (with a Dto suffix). zod
validation schemas stay with the route that uses them.
Why: Sharing types across surfaces is high-value (type-safe API contracts). Sharing zod schemas would either pull a backend dep into the bundle or constrain backend validation to whatever the frontend needs. Splitting them keeps each side free.
How it shows up:
- DTOs imported via
@common/dtosalias - zod schemas live in
functions/modules/<x>/types/or alongside the router - Exception: when the frontend genuinely needs the same validation
(e.g., form validation matching backend rules), the schema can move
to
common/
One Hono router per module → one Cloud Function
Decision: Each module's Hono router is its own deployed function. Sub-routers per entity are composed inside.
Why: Keeps deploy surface small (15 functions for HTTP, plus per-event/scheduled functions). A bug in one module doesn't redeploy everything. Mirrors the module boundary at the network edge.
How it shows up:
functions/modules/<x>/endpoints/apis/router.ts— main router per module- Sub-routers per entity (e.g.,
attendance.router.ts,shift.router.ts) composed onto it
Things explicitly not chosen
These come up often enough to warrant a note:
- No DI framework (
inversify,tsyringe) — see "Manual DI" above. - No GraphQL — REST + RTDB subscriptions cover the use cases without the schema-stitching overhead.
- No styled-system / Tailwind on web — styled-components + SCSS is the existing baseline. Tailwind is installed but minimally used; not the default for new code.
- No state library on backend — handlers are stateless functions composed via DI; state lives in RTDB / Postgres.
- No SSR — the web app is a CRA-style SPA; hosting via Firebase Hosting with a single rewrite to
/index.html.