Services & Data Loading
Service layer architecture and data fetching patterns in DesQTA
Service Layer Overview
The service layer in DesQTA encapsulates business logic, API communication, and data transformation. Services provide a clean interface between components and the backend/APIs.
Service Architecture
Component
│
▼
Service Layer (Business Logic)
│
├──► Tauri Commands (Backend)
├──► SEQTA API
├──► BetterSEQTA Cloud API
└──► Local Cache
Service Patterns
Pattern 1: Simple Service Object
// src/lib/services/authService.ts
import { invoke } from '@tauri-apps/api/core';
import { logger } from '../../utils/logger';
export interface UserInfo {
id: number;
email: string;
userName: string;
displayName?: string;
}
export const authService = {
async checkSession(): Promise<boolean> {
try {
return await invoke<boolean>('check_session_exists');
} catch (error) {
logger.error('authService', 'checkSession', `Failed: ${error}`, { error });
throw error;
}
},
async loadUserInfo(): Promise<UserInfo | undefined> {
try {
const cached = cache.get<UserInfo>('userInfo');
if (cached) return cached;
const user = await invoke<UserInfo>('get_user_info');
cache.set('userInfo', user, 60);
return user;
} catch (error) {
logger.error('authService', 'loadUserInfo', `Failed: ${error}`, { error });
return undefined;
}
},
async logout(): Promise<boolean> {
cache.delete('userInfo');
return await invoke<boolean>('logout');
}
};
Pattern 2: Service Class
// src/lib/services/notesService.ts
export class NotesService {
private cache: Map<string, Note> = new Map();
async loadNote(id: string): Promise<Note | null> {
// Check cache
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
// Load from backend
const note = await invoke<Note>('load_note', { id });
if (note) {
this.cache.set(id, note);
}
return note;
}
async saveNote(note: Note): Promise<void> {
await invoke('save_note', { note });
this.cache.set(note.id, note);
}
clearCache() {
this.cache.clear();
}
}
export const notesService = new NotesService();
useDataLoader Hook
DesQTA provides a powerful useDataLoader utility for consistent data loading with caching.
Basic Usage
import { useDataLoader } from '$lib/utils/useDataLoader';
const data = await useDataLoader({
cacheKey: 'assessments',
ttlMinutes: 10,
context: 'assessments',
functionName: 'loadAssessments',
fetcher: async () => {
return await invoke('get_assessments');
},
onDataLoaded: (data) => {
assessments = data;
}
});
Advanced Options
const data = await useDataLoader({
cacheKey: 'user_profile',
ttlMinutes: 30,
context: 'user',
functionName: 'loadProfile',
skipCache: false, // Set to true to bypass cache
fetcher: async () => {
return await invoke('get_user_profile');
},
onDataLoaded: (data) => {
userProfile = data;
},
shouldSyncInBackground: (data) => {
// Only sync if data exists
return data !== null;
}
});
How useDataLoader Works
- Check Memory Cache: First checks in-memory cache
- Check IndexedDB: Falls back to IndexedDB if memory cache expired
- Fetch Fresh: If no cache, fetches fresh data
- Background Sync: Updates cache in background if online
- Error Handling: Returns null on error, logs appropriately
Service Examples
AuthService
// src/lib/services/authService.ts
export const authService = {
async checkSession(): Promise<boolean> {
return await invoke<boolean>('check_session_exists');
},
async startLogin(seqtaUrl: string): Promise<void> {
await invoke('create_login_window', { url: seqtaUrl });
},
async loadUserInfo(): Promise<UserInfo | undefined> {
const cached = cache.get<UserInfo>('userInfo');
if (cached) return cached;
const user = await invoke<UserInfo>('get_user_info');
if (user) {
cache.set('userInfo', user, 60);
}
return user;
},
async logout(): Promise<boolean> {
cache.delete('userInfo');
return await invoke<boolean>('logout');
}
};
CloudAuthService
// src/lib/services/cloudAuthService.ts
export class CloudAuthService {
private token: string | null = null;
private user: CloudUser | null = null;
async init(): Promise<CloudUser | null> {
const tokenData = await invoke<CloudToken>('get_cloud_token');
if (tokenData?.token) {
this.token = tokenData.token;
this.user = tokenData.user || null;
return this.user;
}
return null;
}
async login(email: string, password: string): Promise<CloudUser> {
const response = await fetch(`${CLOUD_API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
this.token = data.token;
this.user = data.user;
await invoke('save_cloud_token', { token: this.token, user: this.user });
return this.user;
}
async logout(): Promise<void> {
this.token = null;
this.user = null;
await invoke('clear_cloud_token');
}
}
export const cloudAuthService = new CloudAuthService();
ThemeService
// src/lib/services/themeService.ts
export class ThemeService {
private themes: Map<string, ThemeManifest> = new Map();
async loadTheme(themeName: string): Promise<void> {
if (this.themes.has(themeName)) {
return; // Already loaded
}
const manifest = await this.getThemeManifest(themeName);
if (manifest) {
this.themes.set(themeName, manifest);
await this.injectThemeCSS(manifest);
}
}
async getThemeManifest(themeName: string): Promise<ThemeManifest | null> {
try {
const manifestPath = `/themes/${themeName}/manifest.json`;
const response = await fetch(manifestPath);
return await response.json();
} catch (error) {
logger.error('themeService', 'getThemeManifest', `Failed: ${error}`, { themeName, error });
return null;
}
}
private async injectThemeCSS(manifest: ThemeManifest): Promise<void> {
const cssPath = `/themes/${manifest.name}/theme.css`;
const css = await fetch(cssPath).then(r => r.text());
// Inject CSS into document
const style = document.createElement('style');
style.id = `theme-${manifest.name}`;
style.textContent = css;
document.head.appendChild(style);
}
}
export const themeService = new ThemeService();
Data Fetching Patterns
Pattern 1: Simple Fetch
async function fetchData() {
const response = await fetch('/api/data');
return await response.json();
}
Pattern 2: With Error Handling
async function fetchData(): Promise<Data | null> {
try {
const response = await fetch('/api/data');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
logger.error('service', 'fetchData', `Failed: ${error}`, { error });
return null;
}
}
Pattern 3: With Retry Logic
async function fetchWithRetry(url: string, retries = 3): Promise<Data | null> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url);
if (response.ok) {
return await response.json();
}
} catch (error) {
if (i === retries - 1) {
logger.error('service', 'fetchWithRetry', `Failed after ${retries} retries`, { error });
return null;
}
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
return null;
}
Pattern 4: Using Tauri Commands
import { invoke } from '@tauri-apps/api/core';
async function fetchFromBackend(): Promise<Data> {
return await invoke<Data>('get_data');
}
async function saveToBackend(data: Data): Promise<void> {
await invoke('save_data', { data });
}
Caching in Services
Memory Cache
import { cache } from '../../utils/cache';
async function getCachedData(key: string): Promise<Data | null> {
// Check cache first
const cached = cache.get<Data>(key);
if (cached) {
return cached;
}
// Fetch fresh data
const data = await fetchData();
// Cache for 10 minutes
if (data) {
cache.set(key, data, 10);
}
return data;
}
IndexedDB Cache
import { setIdb, getWithIdbFallback } from '$lib/services/idbCache';
async function getPersistedData(key: string): Promise<Data | null> {
// Check IndexedDB
const cached = await getWithIdbFallback<Data>(key, key, () => null);
if (cached) {
return cached;
}
// Fetch fresh
const data = await fetchData();
// Persist
if (data) {
await setIdb(key, data);
}
return data;
}
Error Handling in Services
Standard Error Handling
export const dataService = {
async loadData(): Promise<Data | null> {
try {
return await invoke<Data>('get_data');
} catch (error) {
logger.error('dataService', 'loadData', `Failed: ${error}`, { error });
errorService.handleError(error);
return null;
}
}
};
Error Service Integration
import { errorService } from './errorService';
export const apiService = {
async callAPI(endpoint: string): Promise<Response | null> {
try {
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response;
} catch (error) {
errorService.handleError(error, {
context: 'apiService',
endpoint,
retryable: true
});
return null;
}
}
};
Service Composition
Combining Services
import { authService } from './authService';
import { cloudAuthService } from './cloudAuthService';
export const syncService = {
async syncSettings(): Promise<void> {
// Check local auth
const hasLocalSession = await authService.checkSession();
if (!hasLocalSession) {
throw new Error('No local session');
}
// Check cloud auth
const cloudUser = await cloudAuthService.init();
if (!cloudUser) {
throw new Error('No cloud session');
}
// Sync settings
await cloudSettingsService.sync();
}
};
Best Practices
1. Single Responsibility
// ✅ Good - Focused service
export const authService = {
checkSession,
login,
logout
};
// ❌ Avoid - Too many responsibilities
export const megaService = {
checkSession,
login,
loadAssessments,
saveSettings,
fetchWeather
};
2. Error Handling
// ✅ Good - Always handle errors
async function loadData(): Promise<Data | null> {
try {
return await fetchData();
} catch (error) {
logger.error('service', 'loadData', `Failed: ${error}`, { error });
return null;
}
}
3. Caching Strategy
// ✅ Good - Use appropriate TTL
cache.set('userInfo', user, 60); // Long-lived
cache.set('assessments', data, 10); // Short-lived
cache.set('config', config, 1440); // Very long-lived
4. Type Safety
// ✅ Good - Type everything
export interface UserInfo {
id: number;
email: string;
}
async function loadUser(): Promise<UserInfo | null> {
return await invoke<UserInfo>('get_user');
}
Next Steps
- Component Architecture - Building components
- Tauri Commands - Backend communication
- Caching Strategy - Advanced caching
On this page