firebase-architecture
Skill0 starsChanged 5 months agoMITView on GitHub
Applies SOLID principles to Firebase + React Native / web apps — separating data access from UI, designing testable service layers, decoupling hooks from the Firebase SDK, and choosing the right level of abstraction. Use whenever the user asks how to structure a Firebase app, how to make Firestore calls testable, how to scale beyond a single screen of glue code, or when reviewing existing Firebase code for coupling.
In this repository15 other files it gives its agents.
- firebase-ai-logicSkill
- firebase-analyticsSkill
- firebase-app-checkSkill
- firebase-authSkill
- firebase-cloud-functionsSkill
- firebase-cost-optimizationSkill
- firebase-crashlyticsSkill
- firebase-emulatorsSkill
- firebase-expoSkill
- firebase-firestoreSkill
- firebase-hostingSkill
- firebase-messagingSkill
- firebase-realtime-databaseSkill
- firebase-remote-configSkill
- firebase-storageSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 2 are distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 148
When schemaVersion becomes 3, you change one file.
- Distinctive: line 172
Don't make hooks depend on more of the Firebase SDK than they need.
- Distinctive: line 238
Use Level 1 by default. Promote to Level 2 when the tests-against-emulator pain exceeds the indirection pain.
- Very specific: line 271
Exporting raw Firebase types from your service: If TodosService.list() returns a FirebaseFirestoreTypes.CollectionReference, every consumer …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
SOLID for Firebase Apps
The default Firebase tutorial puts every read, write, and rule check directly in a screen component. That works until your second screen needs the same query, or until you want to test a payment flow without spending real money. This skill is what to do then — applied specifically to Firebase + React Native (the web SDK story is similar).
Minimum viable example
// lib/services/todos.ts — owns Firestore for one collection. SRP.
import firestore from "@react-native-firebase/firestore";
export const TodosService = {
list(uid: string) {
return firestore().collection("users").doc(uid).collection("todos");
},
add(uid: string, title: string) {
return TodosService.list(uid).add({
title, done: false,
createdAt: firestore.FieldValue.serverTimestamp(),
});
},
toggle(uid: string, id: string, done: boolean) {
return TodosService.list(uid).doc(id).update({ done });
},
};// lib/hooks/useTodos.ts — the only thing components import. DIP.
import { useEffect, useState } from "react";
import { TodosService } from "../services/todos";
export function useTodos(uid: string) {
const [todos, setTodos] = useState<unknown[]>([]);
useEffect(() => {
const unsub = TodosService.list(uid)
.orderBy("createdAt", "desc")
.onSnapshot((snap: any) =>
setTodos(snap.docs.map((d: any) => ({ id: d.id, ...d.data() })))
);
return unsub;
}, [uid]);
return todos;
}The component uses const todos = useTodos(uid) — that's all it needs to know. Firebase is one import away from being swapped.
When SOLID earns its keep in a Firebase app
Honest evaluation before the patterns:
| Scale | Reach for SOLID? |
| One screen, one collection, no tests | No — direct SDK calls in the component are fine |
| 3+ screens reading the same data | Yes — extract a hook (SRP) |
| You want to test a payment / mutation flow | Yes — wrap the SDK so tests inject a fake |
| You're migrating from Firebase to Supabase / vice versa | Yes — the wrapper is the entire savings |
| You're building a simple internal tool with one user | No — abstraction debt outpaces benefit |
The patterns below are progressive — apply them in order as the app grows. Don't start with a Repository pattern on day 1.
1. SRP — Single Responsibility
Bad — one component owns auth, Firestore, UI, navigation, validation:
function TodosScreen() {
const uid = auth().currentUser!.uid;
const [todos, setTodos] = useState([]);
const [draft, setDraft] = useState("");
useEffect(() => {
return firestore().collection("users").doc(uid).collection("todos")
.orderBy("createdAt", "desc")
.onSnapshot((snap) => setTodos(snap.docs.map(d => ({ id: d.id, ...d.data() }))));
}, [uid]);
async function add() {
if (!draft.trim()) return;
await firestore().collection("users").doc(uid).collection("todos").add({
title: draft.trim(),
done: false,
createdAt: firestore.FieldValue.serverTimestamp(),
});
setDraft("");
}
return <View>{/* ... */}</View>;
}Good — three files, each with one job:
// app/(app)/todos.tsx — presentation only
function TodosScreen() {
const uid = useUid(); // auth concern
const todos = useTodos(uid); // data concern
const { add, toggle } = useTodoActions(uid);
return <TodosList todos={todos} onAdd={add} onToggle={toggle} />;
}Why it matters in Firebase apps: the SDK is stateful (snapshot listeners, auth state, persistence). Mixing state-having code with stateless UI logic makes the component hard to reason about and impossible to memoize.
2. OCP — Open/Closed
You should be able to add behavior to your Firestore writes (validation, audit logs, optimistic updates) without modifying every call site.
Bad — every screen that creates a post duplicates the same fields:
await firestore().collection("posts").add({
...data,
createdAt: firestore.FieldValue.serverTimestamp(),
authorId: auth().currentUser!.uid,
schemaVersion: 2,
});Good — a factory the service owns, screens are closed to schema changes:
const PostsService = {
async create(data: { title: string; body: string }) {
return firestore().collection("posts").add({
...data,
createdAt: firestore.FieldValue.serverTimestamp(),
authorId: auth().currentUser!.uid,
schemaVersion: 2,
});
},
};When schemaVersion becomes 3, you change one file. The same pattern works for Cloud Functions middleware (auth check + app check + validation as composable wrappers).
3. LSP — Liskov Substitution
Firestore's strength here is typed converters — they let you treat a DocumentSnapshot<Post> interchangeably with any other typed snapshot.
import { type FirestoreDataConverter } from "firebase/firestore";
type Post = { authorId: string; title: string; body: string; createdAt: number };
export const postConverter: FirestoreDataConverter<Post> = {
toFirestore: (post) => post,
fromFirestore: (snap) => snap.data() as Post,
};
const postsRef = collection(db, "posts").withConverter(postConverter);
// snap.data() is now typed as Post throughout the appSubstitutability matters for swapping storage implementations later. If a Post always has the same shape coming out of fromFirestore regardless of where you query it from, downstream code never has to type-check.
4. ISP — Interface Segregation
Don't make hooks depend on more of the Firebase SDK than they need.
Bad:
import { auth, firestore, storage, messaging } from "../lib/firebase";
// Hook only needs the current uid but transitively pulls in everythingGood:
// lib/auth.ts — only exposes what most code needs
export function useUid(): string | null {
const [uid, setUid] = useState<string | null>(null);
useEffect(() => auth().onAuthStateChanged((u) => setUid(u?.uid ?? null)), []);
return uid;
}Now hooks import useUid from lib/auth, not auth itself. The web bundle is smaller (better tree-shaking) and the dependency surface is narrower.
Same for Cloud Functions — split a giant index.ts into per-feature files re-exported from index.ts. Firebase deploys only redeploy changed functions when bundles are distinct.
5. DIP — Dependency Inversion
The big one. Hooks and components should depend on abstractions, not on Firebase directly. This is what makes tests possible without an emulator AND what lets you migrate backends.
Two levels depending on scale:
Level 1 (small app) — a service module as the seam
// lib/services/todos.ts — the only file that imports firestore
import firestore from "@react-native-firebase/firestore";
export const TodosService = {
list: (uid: string) => firestore().collection("users").doc(uid).collection("todos"),
add: (uid: string, title: string) => /* ... */,
toggle: (uid: string, id: string, done: boolean) => /* ... */,
};Hooks and components import TodosService, never firestore directly. For tests, mock the module. For backend migration, rewrite this one file.
Level 2 (real testability needed) — a Repository interface
// lib/repositories/todos.ts
export interface TodosRepository {
watchByUser(uid: string, cb: (todos: Todo[]) => void): () => void;
add(uid: string, title: string): Promise<string>;
toggle(uid: string, id: string, done: boolean): Promise<void>;
}
// lib/repositories/firestoreTodos.ts
export const firestoreTodos: TodosRepository = { /* ... */ };
// lib/repositories/inMemoryTodos.ts
export function createInMemoryTodos(): TodosRepository { /* ... */ }Hooks accept the repo via context (a TodosProvider) — production wires firestoreTodos, tests wire createInMemoryTodos(). Now you can run E2E tests in milliseconds with no Firebase emulator at all.
The cost: more indirection. The benefit: tests run 100× faster, you can swap Firebase for Supabase in one file, and your domain logic stops depending on an external SDK's release cadence.
Use Level 1 by default. Promote to Level 2 when the tests-against-emulator pain exceeds the indirection pain.
Cross-cutting: Cloud Functions
The same principles apply, with one Firebase-specific twist: avoid initializing the Admin SDK in every file.
// functions/src/lib/admin.ts — initialized once
import { initializeApp, getApps } from "firebase-admin/app";
if (getApps().length === 0) initializeApp();
export { getFirestore } from "firebase-admin/firestore";
export { getAuth } from "firebase-admin/auth";// functions/src/posts/archive.ts — depends on the abstraction
import { onCall, HttpsError } from "firebase-functions/v2/https";
import { getFirestore } from "../lib/admin";
export const archivePosts = onCall(async (request) => {
if (!request.auth) throw new HttpsError("unauthenticated", "");
await getFirestore().collection("posts")/* ... */;
});Each function file is a single responsibility (one operation, one route). The admin SDK is initialized in one place.
Common mistakes
- Premature Repository pattern. Wrapping every Firestore call in a
IUserRepositoryinterface for a 3-screen app is overhead the project can't repay. Start with direct SDK calls in a service module; promote to interfaces when you need tests. - God hooks. A
useApp()that returns user, todos, posts, notifications, and feature flags. Split per concern; let consumers choose what to subscribe to. - Service classes with state.
class TodosService { private cache = ... }is an anti-pattern — Firestore already manages cache. Use module-level functions, not classes-with-state. - Wrapping
onSnapshotlisteners in promises. They're streams, not promises. The repository abstraction returns anUnsubscribefunction, notPromise<Todo[]>. - Exporting raw Firebase types from your service. If
TodosService.list()returns aFirebaseFirestoreTypes.CollectionReference, every consumer is coupled to Firebase. Map to your ownTodotype at the service boundary. - DIP all the way down to security rules. Rules are not application logic; they're a backend security layer. Don't try to abstract them into a generic permission system — express the security model directly in
firestore.rulesas thefirebase-firestoreskill recommends. - Class hierarchies for Firebase wrappers. A
BaseRepository<T>parent class withUsersRepository extends BaseRepository<User>is a Java reflex. In TypeScript, prefer composition with generic factory functions:function createRepository<T>(collectionName: string): Repository<T>. - Skipping SOLID for "just an MVP". Trap: the MVP becomes the codebase. Adopt SRP and ISP from day 1 (they're free). Defer OCP/LSP/DIP until they pay for themselves.
See references/testing-with-repositories.md for a complete worked example with Vitest + inMemoryTodos.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

