Patterns front-end
Repository
Abstraire l'accès aux données derrière une interface
Problème
Les composants qui appellent fetch directement sont couplés à l'URL de l'API, au format de réponse, et à la stratégie de fetching. Changer d'API, ajouter un cache, ou tester devient difficile.
Structure du pattern
Le Repository isole la couche d'accès aux données. Les composants ne savent pas si les données viennent d'une REST API, GraphQL, IndexedDB, ou d'un mock.
// Contrat : ce que l'application attend
interface ProductRepository {
findById(id: string): Promise<Product | null>;
findByCategory(categoryId: string): Promise<Product[]>;
search(query: string): Promise<Product[]>;
save(product: Omit<Product, 'id'>): Promise<Product>;
}// Implémentation REST
class RestProductRepository implements ProductRepository {
private baseUrl = '/api/products';
async findById(id: string): Promise<Product | null> {
const res = await fetch(`${this.baseUrl}/${id}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json().then(adaptApiProduct);
}
async findByCategory(categoryId: string): Promise<Product[]> {
const res = await fetch(`${this.baseUrl}?categoryId=${categoryId}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.map(adaptApiProduct);
}
async search(query: string): Promise<Product[]> {
const res = await fetch(`${this.baseUrl}/search?q=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.map(adaptApiProduct);
}
async save(product: Omit<Product, 'id'>): Promise<Product> {
const res = await fetch(this.baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json().then(adaptApiProduct);
}
}Repository avec TanStack Query
En pratique, on intègre le repository avec TanStack Query pour le cache et la gestion des états :
// Les query keys centralisées dans le repository ou un fichier dédié
const productKeys = {
all: ['products'] as const,
byId: (id: string) => ['products', id] as const,
byCategory: (categoryId: string) => ['products', 'category', categoryId] as const,
search: (query: string) => ['products', 'search', query] as const,
};
// Hooks qui encapsulent le repository
function useProduct(id: string) {
const repository = useProductRepository();
return useQuery({
queryKey: productKeys.byId(id),
queryFn: () => repository.findById(id),
enabled: !!id,
});
}
function useProductsByCategory(categoryId: string) {
const repository = useProductRepository();
return useQuery({
queryKey: productKeys.byCategory(categoryId),
queryFn: () => repository.findByCategory(categoryId),
});
}
function useCreateProduct() {
const repository = useProductRepository();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (product: Omit<Product, 'id'>) => repository.save(product),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: productKeys.all });
},
});
}Injection via contexte (DIP)
const ProductRepositoryContext = createContext<ProductRepository | null>(null);
function useProductRepository(): ProductRepository {
const repo = useContext(ProductRepositoryContext);
if (!repo) throw new Error('ProductRepository not provided');
return repo;
}
// En prod
function App() {
return (
<ProductRepositoryContext.Provider value={new RestProductRepository()}>
<Router />
</ProductRepositoryContext.Provider>
);
}
// En test
function renderWithMockRepo(ui: React.ReactNode) {
const mockRepo: ProductRepository = {
findById: vi.fn().mockResolvedValue(mockProduct),
findByCategory: vi.fn().mockResolvedValue([mockProduct]),
search: vi.fn().mockResolvedValue([]),
save: vi.fn().mockResolvedValue(mockProduct),
};
return render(
<ProductRepositoryContext.Provider value={mockRepo}>
{ui}
</ProductRepositoryContext.Provider>
);
}Repository vs Service
La distinction est courante :
- Repository : CRUD sur une entité. Ne contient pas de logique métier.
findById,save,delete. - Service : orchestre des repositories et contient la logique métier.
checkout,processRefund.
Un CheckoutService utilise ProductRepository, OrderRepository, et PaymentRepository.