Skip to content

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

  1. Read: useMeals calls apiMealService.getMeals.
  2. Network: Request goes to AppSync.
  3. Result: Data is displayed and stored in React Query Cache (RAM).

Scenario B: Offline Authenticated User

  1. Read: User opens app in Airplane Mode.
  2. React Query: Detects network is down (or uses persistQueryClient storage).
  3. Result: Displays data from localStorage (via React Query Persister).
  4. Write: User adds a meal.
  5. Optimistic UI: onMutate updates the list instantly.
  6. Queue: React Query pauses the mutationFn because it knows we are offline. It adds the request to a "Mutation Queue" in localStorage.
  7. Reconnect: When online, the queue flushes -> AppSync.

Scenario C: Guest User (Offline-Only Mode)

  1. Read: isGuest is true. useMeals calls localMealService.getMeals.
  2. Network: None. Request goes to IndexedDB.
  3. Result: React Query caches the result (redundant but consistent API).
  4. Write: localMealService.addMeal writes to IndexedDB.
  5. 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.