ADR 005: Frontend Service Pattern
Metadata
- Status: Accepted & Implemented
- Date: 2025-12-30
- Type: Architecture Decision Record (ADR)
This document outlines the code structure for implementing the PWA + React Query strategy, specifically designed to support both the immediate "Authenticated Offline" goal and the future "Guest Mode" requirement without rewriting the frontend.
1. The Architecture
Instead of useMeals calling client.graphql directly, we introduce a Service Layer.
Visual Flow:
graph LR
UI[React Component] --> Hook[useMeals Hook]
Hook --> RQ[React Query]
RQ --> Switch{Service Switcher}
Switch -- Auth User --> API[ApiMealService]
Switch -- Guest --> Local[LocalMealService]
API --> AppSync((AWS AppSync))
Local --> Dexie((IndexedDB))
2. The Code Structure
Step 1: The Service Interface
We define a standard contract that both implementations must satisfy. (In JS, this is implicit, but let's define the "shape").
// services/types.js (Conceptual)
interface MealService {
getMeals(dateRange: {
from: string,
to: string
}): Promise < Meal[] > ;
addMeal(meal: MealData): Promise < Meal > ;
deleteMeal(sk: string): Promise < void > ;
}
Step 2: The API Implementation (Current Goal)
This handles the Authenticated User. It uses the persistQueryClient plugin of React Query to handle temporary network failures.
// services/ApiMealService.js
import {
generateClient
} from 'aws-amplify/api';
import {
GET_MEALS,
ADD_MEAL
} from '../graphql/operations';
const client = generateClient();
export const apiMealService = {
async getMeals({
from,
to
}) {
const response = await client.graphql({
query: GET_MEALS,
variables: {
from,
to
}
});
return response.data.getMeals.meals;
},
async addMeal(mealData) {
// Standard GraphQL Mutation
return await client.graphql({
query: ADD_MEAL,
variables: {
...mealData
}
});
}
};
Step 3: The Local Implementation (Future / Guest Mode)
This uses Dexie.js (a friendly wrapper for IndexedDB) to mimic the backend logic in the browser.
// services/LocalMealService.js (Future)
import {
db
} from './db'; // Dexie instance
export const localMealService = {
async getMeals({
from,
to
}) {
// Manual query logic mirroring DynamoDB
return await db.meals
.where('createdAt')
.between(from, to)
.toArray();
},
async addMeal(mealData) {
// Write directly to browser DB
const id = crypto.randomUUID();
return await db.meals.add({
...mealData,
SK: id
});
}
};
Step 4: The Hook Integration (The "Gluer")
The hook decides which service to use based on the Auth state.
// hooks/useMeals.js
import {
useQuery,
useMutation,
useQueryClient
} from '@tanstack/react-query';
import {
apiMealService
} from '../services/ApiMealService';
import {
localMealService
} from '../services/LocalMealService'; // Future
import {
useAuth
} from './useAuth';
export function useMeals(currentDate) {
const {
isGuest
} = useAuth();
const queryClient = useQueryClient();
const dateRange = getUtcRange(currentDate);
// 1. Select Strategy
const service = isGuest ? localMealService : apiMealService;
// 2. Query (Read)
const query = useQuery({
queryKey: ['meals', dateRange.from], // Cache Key
queryFn: () => service.getMeals(dateRange),
staleTime: 10 * 1000, // Data is fresh for 10 seconds
gcTime: Infinity, // Keep in cache indefinitely (avoids overflow)
});
// 3. Mutation (Write)
const addMutation = useMutation({
mutationFn: (newMeal) => service.addMeal(newMeal),
// Optimistic Update Magic
onMutate: async (newMeal) => {
await queryClient.cancelQueries(['meals', dateRange.from]);
const previousMeals = queryClient.getQueryData(['meals', dateRange.from]);
// Inject fake meal instantly
queryClient.setQueryData(['meals', dateRange.from], (old) => [
...old, {
...newMeal,
SK: 'temp-id',
isPending: true
}
]);
return {
previousMeals
};
},
onError: (err, newMeal, context) => {
// Rollback on failure
queryClient.setQueryData(['meals', dateRange.from], context.previousMeals);
},
onSettled: () => {
// Sync with truth
queryClient.invalidateQueries(['meals', dateRange.from]);
}
});
return {
meals: query.data || [],
isLoading: query.isLoading,
addMeal: addMutation.mutate
};
}
3. Scenario Walkthroughs
Scenario A: Online Authenticated User
- Read:
useMealscallsapiMealService.getMeals. - Network: Request goes to AppSync.
- Result: Data is displayed and stored in React Query Cache (RAM).
Scenario B: Offline Authenticated User
- Read: User opens app in Airplane Mode.
- React Query: Detects network is down (or uses
persistQueryClientstorage). - Result: Displays data from localStorage (via React Query Persister).
- Write: User adds a meal.
- Optimistic UI:
onMutateupdates the list instantly. - Queue: React Query pauses the
mutationFnbecause it knows we are offline. It adds the request to a "Mutation Queue" in localStorage. - Reconnect: When online, the queue flushes -> AppSync.
Scenario C: Guest User (Offline-Only Mode)
- Read:
isGuestis true.useMealscallslocalMealService.getMeals. - Network: None. Request goes to IndexedDB.
- Result: React Query caches the result (redundant but consistent API).
- Write:
localMealService.addMealwrites to IndexedDB. - Result: Data is saved permanently on the device. No sync happens.
Summary
By using this pattern, we solve the "Offline" problem today (Scenario B) while leaving the door wide open for "Guest Mode" (Scenario C) by simply swapping the implementation of 2 functions (getMeals, addMeal), without touching the complex UI or state management logic.