Layer contracts
Every feature in ImtehanHub flows through the same six layers, in the same order. The discipline matters because the same shape repeats hundreds of times: any drift produces a maintenance tax across the codebase.
TL;DR — types → repositories → services → stores → hooks → components → pages. Skipping a layer is a smell. The example below walks "submit a test" across all six.
The flow at a glance
┌─────────────────┐
│ Pages │ Route entries (e.g. /app/test/take)
└──────┬──────────┘
│ render
↓
┌─────────────────┐
│ Components │ Radix-based UI, layout
└──────┬──────────┘
│ uses
↓
┌─────────────────┐
│ Hooks │ TanStack Query + custom (use-content.ts, use-tests.ts)
└──────┬──────────┘
│ delegates
↓
┌─────────────────┐
│ Services │ Business logic (test-service.ts, billing-service.ts)
└──────┬──────────┘
│ calls
↓
┌─────────────────┐
│ Repositories │ BaseRepository<T> wrappers (test-repository.ts)
└──────┬──────────┘
│ runs
↓
┌─────────────────┐
│ Firestore SDK │
└─────────────────┘
Client-only state (theme, modals, in-flight wizards) skips the right half and lives in Zustand stores persisted by the storage adapter.
Layer 1 — types
Every entity has a TypeScript type file at src/types/{domain}.ts. The type is the contract — repos and services both depend on it, and a change here forces every consumer to react.
// src/types/content.ts
export interface Class {
id: ClassId; // 'class-9' | 'class-10' | '1st-year' | ...
name: string; // 'Class 9'
urduName: string; // 'نویں جماعت'
level: string; // 'Matric (SSC-I)'
active: boolean;
createdAt: Timestamp;
updatedAt: Timestamp;
}
Rules:
- Bilingual entities carry both
nameandurduName. - IDs are typed string literals (
ClassId = 'class-5' | ...) when the set is finite. - Timestamps use
firebase/firestore'sTimestamptype, notDate, so serialisation round-trips cleanly. - No
any. No exceptions.
Layer 2 — repositories
Every Firestore collection has a repository at src/repositories/{name}-repository.ts extending BaseRepository<T>. The base class encapsulates the four CRUD operations, pagination, and toEntity mapping:
// src/repositories/base-repository.ts (abridged)
export class BaseRepository<T extends { id: DocumentId }> {
constructor(protected collectionName: string) {}
async getById(id: string): Promise<T | null> { /* ... */ }
async create(data: Omit<T, 'id'>): Promise<T> { /* ... */ }
async update(id: string, patch: Partial<T>): Promise<void> { /* ... */ }
async delete(id: string): Promise<void> { /* ... */ }
async list(constraints?: QueryConstraint[]): Promise<T[]> { /* ... */ }
async paginate(opts: PageOpts): Promise<PaginatedResult<T>> { /* ... */ }
}
Per-collection repositories add only what is collection-specific:
// src/repositories/test-repository.ts
class TestRepository extends BaseRepository<TestResult> {
constructor() { super('tests'); }
async getByUser(uid: string, limit = 50) {
return this.list([where('userId', '==', uid), orderBy('submittedAt', 'desc'), limitFn(limit)]);
}
}
export const testRepository = new TestRepository();
Rules:
- One repository per collection. No combined repos.
- Repositories never know about services, stores, hooks, or React. They are pure.
- Pagination uses
paginate({ cursor, pageSize }), not rawstartAfter. - Security rules duplicate the access logic — repositories don't enforce auth.
Layer 3 — services
Services hold business logic. They consume repositories, never raw SDK calls. They are still pure (no React, no DOM), but they know about the product, not just the database.
// src/services/test-service.ts (abridged)
import { testRepository } from '@/repositories/test-repository';
import { userRepository } from '@/repositories/user-repository';
import { leaderboardService } from './leaderboard-service';
export const testService = {
async submit(uid: string, draft: TestDraft): Promise<TestResult> {
const result = await scoreTest(draft);
await testRepository.create({ ...result, userId: uid });
await userRepository.update(uid, { 'stats.totalTests': increment(1), /* ... */ });
await leaderboardService.updateScore(uid);
return result;
},
};
Rules:
- Services orchestrate across multiple repositories.
- Services own derived/computed business rules (scoring, trial counters, plan limits, anti-abuse).
- Services compose other services (the example above calls
leaderboardService.updateScore). - Services do not call
useState,useEffect, or any React API.
Layer 4 — stores (client state)
State that is not server-owned lives in Zustand stores at src/stores/*-store.ts. Each store is create()(persist(...)) with the project's storage adapter so persistence works on web (localStorage) and native (@capacitor/preferences).
// src/stores/auth-store.ts (excerpt)
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
trialTestsRemaining: 3,
signIn: async () => { /* ... */ },
signOut: async () => { /* ... */ },
decrementTrialTests: () => set((s) => ({ trialTestsRemaining: Math.max(0, s.trialTestsRemaining - 1) })),
}),
{ name: 'auth-store', storage: createJSONStorage(() => zustandStorage) }
)
);
Rules:
- One store per domain:
auth-store,theme-store,test-store,toast-store,ui-store,ai-store. - Persist what should survive a reload; skip transient flags (
isLoadingetc.). - No server data in stores — that is TanStack Query's job.
- Selective subscriptions (
useAuthStore(s => s.user)) prevent unnecessary re-renders.
Layer 5 — hooks
Server data and side effects surface as hooks at src/hooks/use-*.ts. The convention is TanStack Query for reads, a thin wrapper around services for writes.
// src/hooks/use-tests.ts (excerpt)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { testService } from '@/services/test-service';
export const testQueryKeys = {
all: ['tests'] as const,
byUser: (uid: string) => [...testQueryKeys.all, 'user', uid] as const,
};
export function useUserTests(uid: string) {
return useQuery({
queryKey: testQueryKeys.byUser(uid),
queryFn: () => testService.getByUser(uid),
staleTime: 60 * 1000, // 1 min — tests don't change behind your back
enabled: !!uid,
});
}
export function useSubmitTest() {
const qc = useQueryClient();
return useMutation({
mutationFn: (draft: TestDraft) => testService.submit(getCurrentUid(), draft),
onSuccess: () => qc.invalidateQueries({ queryKey: testQueryKeys.all }),
});
}
Rules:
- One query-keys factory per domain.
staleTimematches business reality (theme prefs:Infinity, leaderboard: 60s, tests: 60s).- Mutations invalidate specific keys, not the whole query cache.
- React Hook Form forms read
defaultValuesviauseQuerywithenabled: !!idfor edits.
Layer 6 — components and pages
Components consume hooks and stores; pages compose components. No fetching in components — that is the hook layer's job.
// src/pages/app/test/TakeTestPage.tsx (sketch)
function TakeTestPage() {
const user = useAuthStore((s) => s.user);
const { data: pool, isLoading } = useChapterQuestionPool(chapterId);
const { mutate: submit } = useSubmitTest();
if (!user) return <Navigate to="/sign-in" />;
if (isLoading || !pool) return <Skeleton />;
return <TestSurface pool={pool} onSubmit={submit} />;
}
Rules:
- No
<button>,<input>,<dialog>— Radix UI primitives orsrc/components/form-fields/*only. - No business logic — delegate to services via hooks.
- URL state via the
useUrlStatehooks, not rawuseState. - Accessibility: every interactive element has visible focus rings and ARIA labels.
The full example — submitting a test
Tracking one user action across all six layers:
- Page (
TakeTestPage.tsx) readspoolviauseChapterQuestionPool, rendersTestSurface. - Component (
TestSurface.tsx) callsonSubmit(draft). - Hook (
useSubmitTest) callstestService.submit(uid, draft). - Service (
test-service.ts) callstestRepository.create, updatesuserRepository, callsleaderboardService.updateScore. - Repository (
test-repository.ts) runssetDocviaBaseRepository.create. - Firestore writes the doc, security rules verify.
- Back up: hook's
onSuccessinvalidatestestQueryKeys.all, the next render pulls the updated history.
No layer is skipped, no inline getDoc/setDoc calls anywhere in pages or components.
Why this rigidity?
- One pattern to learn. A new feature is a 30-minute scaffold: add type, repo, service, hook, component, page.
- Testability. Repositories are mockable; services have no React; hooks are isolatable.
- Refactor safety. Renaming a Firestore field is a search across exactly one file (the repository). Everything else uses the typed name.
- AI assist. A coding agent can be told the rules and shipped against them.
The cost is a little upfront boilerplate. Worth it.
Next
- Routing — every route in one config file.
- State management — Zustand stores + persist + storage adapter.
- Forms — RHF + Zod + shared field components.