# Chapter 13: Building API Abstractions *"The best code is the code you don't have to write twice."* --- ## The Scaling Problem Weather Buddy was a massive success. Sarah's team had grown to 20 developers, and the codebase was expanding rapidly. But there was a problem. "Why does every component make raw API calls?" the new senior engineer asked during code review. "We have the same weather-fetching logic copied in 15 different places!" Sarah looked at the codebase with fresh eyes. He was right. Every developer was writing: ```typescript const { data } = await tf.get(`/api/weather/${city}`) const temp = data.current_condition[0].temp_C // ... transform data // ... handle errors // ... update cache ``` "Time to level up our architecture," Marcus said. "Let me show you how to build proper API abstractions that scale with your team." ## The Repository Pattern: Your Data Layer Instead of scattered API calls, centralize data access: ```typescript // repositories/WeatherRepository.ts export class WeatherRepository { constructor(private tf: TypedFetch) {} async getByCity(city: string): Promise { const { data } = await this.tf.get(`/api/weather/${city}`) return this.transformWeatherData(data) } async getByCities(cities: string[]): Promise { // Batch request for efficiency const promises = cities.map(city => this.getByCity(city)) return Promise.all(promises) } async search(query: string): Promise { const { data } = await this.tf.get('/api/weather/search', { params: { q: query, limit: 10 } }) return data.results.map(this.transformSearchResult) } async getForecast(city: string, days = 7): Promise { const { data } = await this.tf.get(`/api/weather/${city}/forecast`, { params: { days } }) return this.transformForecast(data) } private transformWeatherData(raw: any): Weather { return { city: raw.location.name, country: raw.location.country, temperature: { current: parseInt(raw.current_condition[0].temp_C), feelsLike: parseInt(raw.current_condition[0].FeelsLikeC), unit: 'celsius' }, condition: { text: raw.current_condition[0].weatherDesc[0].value, code: raw.current_condition[0].weatherCode, icon: this.getIconUrl(raw.current_condition[0].weatherCode) }, wind: { speed: parseInt(raw.current_condition[0].windspeedKmph), direction: raw.current_condition[0].winddir16Point, degree: parseInt(raw.current_condition[0].winddirDegree) }, humidity: parseInt(raw.current_condition[0].humidity), pressure: parseInt(raw.current_condition[0].pressure), visibility: parseInt(raw.current_condition[0].visibility), uv: parseInt(raw.current_condition[0].uvIndex), lastUpdated: new Date(raw.current_condition[0].localObsDateTime) } } private transformSearchResult(raw: any): WeatherSearchResult { return { city: raw.name, country: raw.country, region: raw.region, lat: raw.lat, lon: raw.lon, population: raw.population } } private transformForecast(raw: any): Forecast { return { city: raw.location.name, days: raw.forecast.forecastday.map((day: any) => ({ date: new Date(day.date), maxTemp: parseInt(day.day.maxtemp_c), minTemp: parseInt(day.day.mintemp_c), avgTemp: parseInt(day.day.avgtemp_c), condition: { text: day.day.condition.text, code: day.day.condition.code, icon: this.getIconUrl(day.day.condition.code) }, chanceOfRain: parseInt(day.day.daily_chance_of_rain), totalPrecipitation: parseFloat(day.day.totalprecip_mm), avgHumidity: parseInt(day.day.avghumidity), maxWind: parseInt(day.day.maxwind_kph), uv: parseInt(day.day.uv) })) } } private getIconUrl(code: string): string { return `https://cdn.weatherbuddy.com/icons/${code}.svg` } } // repositories/UserRepository.ts export class UserRepository { constructor(private tf: TypedFetch) {} async getCurrentUser(): Promise { const { data } = await this.tf.get('/api/users/me') return this.transformUser(data) } async updateProfile(updates: Partial): Promise { const { data } = await this.tf.patch('/api/users/me', { data: updates }) return this.transformUser(data) } async getFavorites(): Promise { const { data } = await this.tf.get('/api/users/me/favorites') return data.map(this.transformFavorite) } async addFavorite(city: string): Promise { const { data } = await this.tf.post('/api/users/me/favorites', { data: { city } }) return this.transformFavorite(data) } async removeFavorite(cityId: string): Promise { await this.tf.delete(`/api/users/me/favorites/${cityId}`) } async reorderFavorites(cityIds: string[]): Promise { const { data } = await this.tf.put('/api/users/me/favorites/order', { data: { cityIds } }) return data.map(this.transformFavorite) } private transformUser(raw: any): User { return { id: raw.id, email: raw.email, name: raw.name, avatar: raw.avatar_url, preferences: { temperatureUnit: raw.preferences.temp_unit, windSpeedUnit: raw.preferences.wind_unit, timeFormat: raw.preferences.time_format, theme: raw.preferences.theme }, subscription: { plan: raw.subscription.plan, status: raw.subscription.status, expiresAt: raw.subscription.expires_at ? new Date(raw.subscription.expires_at) : null }, createdAt: new Date(raw.created_at), updatedAt: new Date(raw.updated_at) } } private transformFavorite(raw: any): FavoriteCity { return { id: raw.id, city: raw.city_name, country: raw.country, position: raw.position, addedAt: new Date(raw.added_at), lastViewed: raw.last_viewed ? new Date(raw.last_viewed) : null } } } ``` ## Domain-Driven Design: Speaking Business Language Create a domain layer that speaks your business language: ```typescript // domain/Weather.ts export class WeatherDomain { constructor( private weatherRepo: WeatherRepository, private userRepo: UserRepository, private alertService: AlertService ) {} async getDashboard(): Promise { const [user, favorites] = await Promise.all([ this.userRepo.getCurrentUser(), this.userRepo.getFavorites() ]) const weatherData = await this.weatherRepo.getByCities( favorites.map(f => f.city) ) const alerts = await this.alertService.getActiveAlerts( favorites.map(f => f.city) ) return { user, cities: favorites.map((fav, index) => ({ ...fav, weather: weatherData[index], alerts: alerts.filter(a => a.city === fav.city) })), lastUpdated: new Date() } } async searchAndAdd(query: string): Promise { const results = await this.weatherRepo.search(query) // Enhance with additional data const enhanced = await Promise.all( results.map(async (result) => { const weather = await this.weatherRepo.getByCity(result.city) return { ...result, currentTemp: weather.temperature.current, condition: weather.condition.text } }) ) return enhanced } async getDetailedForecast(city: string): Promise { const [current, forecast, historical, alerts] = await Promise.all([ this.weatherRepo.getByCity(city), this.weatherRepo.getForecast(city, 14), this.weatherRepo.getHistorical(city, 7), this.alertService.getAlertsForCity(city) ]) return { current, forecast, historical: this.analyzeHistorical(historical), alerts, insights: this.generateInsights(current, forecast, historical) } } private analyzeHistorical(data: HistoricalWeather[]): HistoricalAnalysis { const temps = data.map(d => d.temperature) return { avgTemp: average(temps), maxTemp: Math.max(...temps), minTemp: Math.min(...temps), trend: this.calculateTrend(temps), anomalies: this.detectAnomalies(data) } } private generateInsights( current: Weather, forecast: Forecast, historical: HistoricalWeather[] ): WeatherInsight[] { const insights: WeatherInsight[] = [] // Temperature insights const avgHistorical = average(historical.map(h => h.temperature)) if (current.temperature.current > avgHistorical + 5) { insights.push({ type: 'temperature', severity: 'info', message: `Today is ${Math.round(current.temperature.current - avgHistorical)}°C warmer than usual` }) } // Rain insights const rainyDays = forecast.days.filter(d => d.chanceOfRain > 50) if (rainyDays.length >= 3) { insights.push({ type: 'precipitation', severity: 'warning', message: `Rainy period ahead: ${rainyDays.length} days of rain expected` }) } // UV insights const highUvDays = forecast.days.filter(d => d.uv >= 8) if (highUvDays.length > 0) { insights.push({ type: 'uv', severity: 'warning', message: `High UV levels expected on ${highUvDays.length} days. Use sun protection!` }) } return insights } private calculateTrend(values: number[]): 'rising' | 'falling' | 'stable' { if (values.length < 2) return 'stable' const firstHalf = average(values.slice(0, Math.floor(values.length / 2))) const secondHalf = average(values.slice(Math.floor(values.length / 2))) const difference = secondHalf - firstHalf if (difference > 2) return 'rising' if (difference < -2) return 'falling' return 'stable' } private detectAnomalies(data: HistoricalWeather[]): Anomaly[] { const anomalies: Anomaly[] = [] const temps = data.map(d => d.temperature) const mean = average(temps) const stdDev = standardDeviation(temps) data.forEach((day, index) => { const zScore = Math.abs((day.temperature - mean) / stdDev) if (zScore > 2) { anomalies.push({ date: day.date, type: 'temperature', value: day.temperature, deviation: zScore, description: `Unusual ${day.temperature > mean ? 'high' : 'low'} of ${day.temperature}°C` }) } }) return anomalies } } ``` ## API Client Factory: Configuration Made Easy Create specialized API clients for different services: ```typescript // factories/ApiClientFactory.ts export class ApiClientFactory { private clients = new Map() constructor(private baseConfig: ApiConfig) {} create(name: string, config: Partial = {}): TypedFetch { if (this.clients.has(name)) { return this.clients.get(name)! } const client = this.buildClient(name, config) this.clients.set(name, client) return client } private buildClient(name: string, config: Partial): TypedFetch { const clientConfig = { ...this.baseConfig, ...config, ...this.getServiceConfig(name) } const client = createTypedFetch(clientConfig) // Add service-specific interceptors this.addInterceptors(client, name) // Add telemetry this.addTelemetry(client, name) return client } private getServiceConfig(name: string): Partial { const configs: Record> = { weather: { baseURL: process.env.WEATHER_API_URL, timeout: 10000, retries: 3, cache: { maxAge: 300000, // 5 minutes strategy: 'stale-while-revalidate' } }, user: { baseURL: process.env.USER_API_URL, timeout: 5000, retries: 1, cache: { maxAge: 60000, // 1 minute private: true } }, analytics: { baseURL: process.env.ANALYTICS_API_URL, timeout: 30000, retries: 0, cache: false }, maps: { baseURL: process.env.MAPS_API_URL, timeout: 20000, retries: 2, cache: { maxAge: 86400000, // 24 hours strategy: 'cache-first' } } } return configs[name] || {} } private addInterceptors(client: TypedFetch, name: string) { // Common auth interceptor client.addRequestInterceptor(config => { const token = this.getAuthToken() if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config }) // Service-specific interceptors switch (name) { case 'weather': client.addRequestInterceptor(config => { config.headers['X-Weather-API-Key'] = process.env.WEATHER_API_KEY! return config }) break case 'maps': client.addRequestInterceptor(config => { // Add signature for maps API const signature = this.signMapRequest(config) config.headers['X-Map-Signature'] = signature return config }) break case 'analytics': client.addRequestInterceptor(config => { // Add tracking headers config.headers['X-Client-ID'] = this.getClientId() config.headers['X-Session-ID'] = this.getSessionId() return config }) break } } private addTelemetry(client: TypedFetch, name: string) { client.addRequestInterceptor(config => { config.metadata.service = name config.metadata.startTime = Date.now() return config }) client.addResponseInterceptor(response => { const duration = Date.now() - response.config.metadata.startTime this.recordMetric({ service: name, endpoint: response.config.url, method: response.config.method, status: response.status, duration, cached: response.cached || false }) return response }) client.addErrorInterceptor(error => { this.recordError({ service: name, endpoint: error.config.url, method: error.config.method, error: error.message, status: error.response?.status }) throw error }) } private getAuthToken(): string | null { return localStorage.getItem('authToken') } private signMapRequest(config: RequestConfig): string { // Implementation of request signing return 'signature' } private getClientId(): string { return 'client-id' } private getSessionId(): string { return 'session-id' } private recordMetric(metric: any) { // Send to telemetry service } private recordError(error: any) { // Send to error tracking } } // Usage const factory = new ApiClientFactory({ timeout: 10000, retries: 2 }) const weatherClient = factory.create('weather') const userClient = factory.create('user') const analyticsClient = factory.create('analytics') ``` ## Composable API Layers Build APIs that compose like LEGO blocks: ```typescript // composables/useWeather.ts export function useWeather() { const weatherRepo = new WeatherRepository(weatherClient) const cache = new WeatherCache() const getWeather = async (city: string): Promise => { // Check cache first const cached = cache.get(city) if (cached && !cached.isStale()) { return cached.data } // Fetch fresh data const weather = await weatherRepo.getByCity(city) cache.set(city, weather) return weather } const prefetchWeather = async (cities: string[]) => { const uncached = cities.filter(city => !cache.has(city)) if (uncached.length > 0) { const weather = await weatherRepo.getByCities(uncached) weather.forEach((w, i) => cache.set(uncached[i], w)) } } const subscribeToWeather = (city: string, callback: (weather: Weather) => void) => { // Initial data getWeather(city).then(callback) // Subscribe to updates const unsubscribe = weatherEvents.on(`weather:${city}`, callback) // Polling fallback const interval = setInterval(() => { getWeather(city).then(callback) }, 60000) return () => { unsubscribe() clearInterval(interval) } } return { getWeather, prefetchWeather, subscribeToWeather, cache } } // composables/useWeatherDashboard.ts export function useWeatherDashboard() { const { getWeather, prefetchWeather } = useWeather() const { getFavorites, addFavorite, removeFavorite } = useFavorites() const { showNotification } = useNotifications() const dashboard = reactive({ cities: [], loading: false, error: null }) const loadDashboard = async () => { dashboard.loading = true dashboard.error = null try { const favorites = await getFavorites() // Prefetch all weather data await prefetchWeather(favorites.map(f => f.city)) // Load weather for each city const weatherPromises = favorites.map(async (fav) => { const weather = await getWeather(fav.city) return { ...fav, weather } }) dashboard.cities = await Promise.all(weatherPromises) } catch (error) { dashboard.error = error.message showNotification({ type: 'error', message: 'Failed to load weather dashboard' }) } finally { dashboard.loading = false } } const addCity = async (city: string) => { try { const weather = await getWeather(city) const favorite = await addFavorite(city) dashboard.cities.push({ ...favorite, weather }) showNotification({ type: 'success', message: `Added ${city} to your dashboard` }) } catch (error) { showNotification({ type: 'error', message: `Failed to add ${city}` }) } } const removeCity = async (cityId: string) => { try { await removeFavorite(cityId) dashboard.cities = dashboard.cities.filter(c => c.id !== cityId) showNotification({ type: 'success', message: 'City removed from dashboard' }) } catch (error) { showNotification({ type: 'error', message: 'Failed to remove city' }) } } const refreshAll = async () => { // Invalidate cache dashboard.cities.forEach(city => { cache.invalidate(city.city) }) // Reload await loadDashboard() } // Auto-refresh every 5 minutes const autoRefresh = setInterval(refreshAll, 5 * 60 * 1000) onMounted(loadDashboard) onUnmounted(() => clearInterval(autoRefresh)) return { dashboard: readonly(dashboard), addCity, removeCity, refreshAll } } ``` ## Plugin Architecture: Extensible APIs Make your API layer extensible with plugins: ```typescript // plugins/ApiPlugin.ts export interface ApiPlugin { name: string version: string // Lifecycle hooks install?(api: TypedFetch): void uninstall?(api: TypedFetch): void // Request/Response hooks beforeRequest?(config: RequestConfig): RequestConfig | Promise afterResponse?(response: Response): Response | Promise onError?(error: Error): Error | Promise // Custom methods methods?: Record } // plugins/CachingPlugin.ts export class CachingPlugin implements ApiPlugin { name = 'caching' version = '1.0.0' private cache = new Map() install(api: TypedFetch) { // Add cache methods to API api.cache = { get: (key: string) => this.cache.get(key), set: (key: string, value: any, ttl?: number) => { this.cache.set(key, { value, expires: ttl ? Date.now() + ttl : Infinity }) }, clear: () => this.cache.clear(), size: () => this.cache.size } } async beforeRequest(config: RequestConfig): Promise { if (config.method !== 'GET') return config const cacheKey = this.getCacheKey(config) const cached = this.cache.get(cacheKey) if (cached && !this.isExpired(cached)) { // Return cached response throw { cached: true, data: cached.value, config } } return config } async afterResponse(response: Response): Promise { if (response.config.method === 'GET' && response.ok) { const cacheKey = this.getCacheKey(response.config) const ttl = this.getTTL(response) this.cache.set(cacheKey, { value: response.data, expires: Date.now() + ttl }) } return response } private getCacheKey(config: RequestConfig): string { return `${config.method}:${config.url}:${JSON.stringify(config.params)}` } private isExpired(entry: CacheEntry): boolean { return Date.now() > entry.expires } private getTTL(response: Response): number { const cacheControl = response.headers.get('cache-control') if (cacheControl) { const maxAge = cacheControl.match(/max-age=(\d+)/) if (maxAge) { return parseInt(maxAge[1]) * 1000 } } return 5 * 60 * 1000 // 5 minutes default } } // plugins/MetricsPlugin.ts export class MetricsPlugin implements ApiPlugin { name = 'metrics' version = '1.0.0' private metrics = { requests: 0, successes: 0, failures: 0, totalDuration: 0, endpoints: new Map() } install(api: TypedFetch) { api.metrics = { get: () => ({ ...this.metrics }), reset: () => this.resetMetrics(), getEndpoint: (url: string) => this.metrics.endpoints.get(url) } } beforeRequest(config: RequestConfig): RequestConfig { config.metadata.metricsStart = Date.now() this.metrics.requests++ return config } afterResponse(response: Response): Response { const duration = Date.now() - response.config.metadata.metricsStart this.metrics.successes++ this.metrics.totalDuration += duration this.updateEndpointMetrics(response.config.url, { success: true, duration }) return response } onError(error: Error): Error { const duration = Date.now() - error.config.metadata.metricsStart this.metrics.failures++ this.metrics.totalDuration += duration this.updateEndpointMetrics(error.config.url, { success: false, duration, error: error.message }) return error } private updateEndpointMetrics(url: string, data: any) { if (!this.metrics.endpoints.has(url)) { this.metrics.endpoints.set(url, { requests: 0, successes: 0, failures: 0, avgDuration: 0, errors: new Map() }) } const endpoint = this.metrics.endpoints.get(url)! endpoint.requests++ if (data.success) { endpoint.successes++ } else { endpoint.failures++ const errorCount = endpoint.errors.get(data.error) || 0 endpoint.errors.set(data.error, errorCount + 1) } // Update average duration endpoint.avgDuration = (endpoint.avgDuration * (endpoint.requests - 1) + data.duration) / endpoint.requests } private resetMetrics() { this.metrics = { requests: 0, successes: 0, failures: 0, totalDuration: 0, endpoints: new Map() } } } // core/PluginManager.ts export class PluginManager { private plugins = new Map() register(plugin: ApiPlugin, api: TypedFetch) { if (this.plugins.has(plugin.name)) { throw new Error(`Plugin ${plugin.name} already registered`) } this.plugins.set(plugin.name, plugin) // Install plugin if (plugin.install) { plugin.install(api) } // Register interceptors if (plugin.beforeRequest) { api.addRequestInterceptor(config => plugin.beforeRequest!(config)) } if (plugin.afterResponse) { api.addResponseInterceptor(response => plugin.afterResponse!(response)) } if (plugin.onError) { api.addErrorInterceptor(error => plugin.onError!(error)) } console.log(`Plugin ${plugin.name} v${plugin.version} registered`) } unregister(pluginName: string, api: TypedFetch) { const plugin = this.plugins.get(pluginName) if (plugin?.uninstall) { plugin.uninstall(api) } this.plugins.delete(pluginName) console.log(`Plugin ${pluginName} unregistered`) } get(pluginName: string): ApiPlugin | undefined { return this.plugins.get(pluginName) } list(): PluginInfo[] { return Array.from(this.plugins.values()).map(plugin => ({ name: plugin.name, version: plugin.version })) } } // Usage const api = createTypedFetch() const pluginManager = new PluginManager() // Register plugins pluginManager.register(new CachingPlugin(), api) pluginManager.register(new MetricsPlugin(), api) pluginManager.register(new LoggingPlugin(), api) pluginManager.register(new RetryPlugin(), api) // Use enhanced API const { data } = await api.get('/users') console.log(api.metrics.get()) ``` ## Code Generation: Let Machines Write Code Generate TypeScript clients from OpenAPI specs: ```typescript // generators/ApiGenerator.ts export class ApiGenerator { async generateFromOpenAPI(specUrl: string): Promise { const spec = await this.fetchSpec(specUrl) const code = { types: this.generateTypes(spec), client: this.generateClient(spec), mocks: this.generateMocks(spec), tests: this.generateTests(spec) } return code } private generateTypes(spec: OpenAPISpec): string { const types: string[] = [] // Generate interfaces from schemas Object.entries(spec.components.schemas).forEach(([name, schema]) => { types.push(this.schemaToInterface(name, schema)) }) // Generate request/response types Object.entries(spec.paths).forEach(([path, methods]) => { Object.entries(methods).forEach(([method, operation]) => { if (operation.requestBody) { types.push(this.generateRequestType(path, method, operation)) } if (operation.responses) { types.push(this.generateResponseTypes(path, method, operation)) } }) }) return types.join('\n\n') } private generateClient(spec: OpenAPISpec): string { const methods: string[] = [] Object.entries(spec.paths).forEach(([path, pathItem]) => { Object.entries(pathItem).forEach(([method, operation]) => { if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) { methods.push(this.generateMethod(path, method, operation)) } }) }) return ` export class ${this.getClientName(spec)}Client { constructor(private tf: TypedFetch) {} ${methods.join('\n\n ')} } `.trim() } private generateMethod(path: string, method: string, operation: Operation): string { const name = this.getMethodName(operation, path, method) const params = this.getMethodParams(operation) const returnType = this.getReturnType(operation) return ` async ${name}(${params}): Promise<${returnType}> { const { data } = await this.tf.${method}<${returnType}>(\`${path}\`, { ${this.getRequestConfig(operation)} }) return data } `.trim() } private schemaToInterface(name: string, schema: Schema): string { const properties = Object.entries(schema.properties || {}) .map(([prop, propSchema]) => { const type = this.schemaToType(propSchema) const optional = !schema.required?.includes(prop) ? '?' : '' return ` ${prop}${optional}: ${type}` }) .join('\n') return ` export interface ${name} { ${properties} } `.trim() } private schemaToType(schema: Schema): string { if (schema.$ref) { return schema.$ref.split('/').pop()! } switch (schema.type) { case 'string': return schema.enum ? schema.enum.map(e => `'${e}'`).join(' | ') : 'string' case 'number': case 'integer': return 'number' case 'boolean': return 'boolean' case 'array': return `${this.schemaToType(schema.items)}[]` case 'object': return 'any' // Could be more specific default: return 'any' } } private getMethodName(operation: Operation, path: string, method: string): string { if (operation.operationId) { return camelCase(operation.operationId) } // Generate from path and method const parts = path.split('/').filter(p => p && !p.startsWith('{')) return camelCase(`${method}_${parts.join('_')}`) } } // Usage const generator = new ApiGenerator() const code = await generator.generateFromOpenAPI('https://api.example.com/openapi.json') // Write generated code await fs.writeFile('generated/api-types.ts', code.types) await fs.writeFile('generated/api-client.ts', code.client) await fs.writeFile('generated/api-mocks.ts', code.mocks) await fs.writeFile('generated/api-tests.ts', code.tests) ``` ## Weather Buddy 13.0: Architecture at Scale Let's rebuild Weather Buddy with proper architecture: ```typescript // Weather Buddy 13.0 - Enterprise Architecture // 1. Core Domain Models // domain/models/Weather.ts export interface Weather { city: string country: string coordinates: Coordinates current: CurrentConditions forecast?: Forecast alerts?: Alert[] lastUpdated: Date } export interface CurrentConditions { temperature: Temperature feelsLike: Temperature humidity: Percentage pressure: Pressure visibility: Distance wind: Wind uv: UVIndex condition: WeatherCondition } export interface Temperature { value: number unit: 'celsius' | 'fahrenheit' | 'kelvin' } export interface Wind { speed: Speed direction: Direction gust?: Speed } // 2. Repository Layer // repositories/base/BaseRepository.ts export abstract class BaseRepository { constructor( protected tf: TypedFetch, protected cache?: CacheManager ) {} protected async fetchWithCache( key: string, fetcher: () => Promise, ttl?: number ): Promise { if (this.cache) { const cached = await this.cache.get(key) if (cached) return cached } const data = await fetcher() if (this.cache) { await this.cache.set(key, data, ttl) } return data } protected handleError(error: any): never { if (error.response?.status === 404) { throw new NotFoundError(error.message) } if (error.response?.status === 401) { throw new UnauthorizedError(error.message) } if (error.code === 'NETWORK_ERROR') { throw new NetworkError(error.message) } throw new ApiError(error.message, error) } } // 3. Service Layer // services/WeatherService.ts export class WeatherService { constructor( private weatherRepo: WeatherRepository, private userRepo: UserRepository, private alertService: AlertService, private analyticsService: AnalyticsService ) {} async getWeatherForUser(userId: string): Promise { const user = await this.userRepo.getById(userId) const favorites = await this.userRepo.getFavorites(userId) // Track analytics this.analyticsService.track('weather_dashboard_viewed', { userId, favoriteCount: favorites.length }) // Fetch weather in parallel const weatherPromises = favorites.map(fav => this.getEnhancedWeather(fav.city) ) const weatherData = await Promise.all(weatherPromises) return { user, preferences: user.preferences, weather: weatherData, generated: new Date() } } private async getEnhancedWeather(city: string): Promise { const [weather, alerts, insights] = await Promise.all([ this.weatherRepo.getByCity(city), this.alertService.getForCity(city), this.generateInsights(city) ]) return { ...weather, alerts, insights, enhanced: true } } private async generateInsights(city: string): Promise { const insights: Insight[] = [] // Get historical data const historical = await this.weatherRepo.getHistorical(city, 30) // Temperature trends const tempTrend = this.analyzeTrend( historical.map(h => h.temperature.value) ) if (tempTrend.significant) { insights.push({ type: 'temperature_trend', title: `Temperature ${tempTrend.direction}`, description: `Average temperature has ${tempTrend.direction} by ${tempTrend.change}° over the past month`, severity: 'info' }) } return insights } private analyzeTrend(values: number[]): TrendAnalysis { // Linear regression to find trend const n = values.length const sumX = values.reduce((sum, _, i) => sum + i, 0) const sumY = values.reduce((sum, val) => sum + val, 0) const sumXY = values.reduce((sum, val, i) => sum + i * val, 0) const sumX2 = values.reduce((sum, _, i) => sum + i * i, 0) const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX) const change = slope * n return { direction: slope > 0 ? 'increasing' : 'decreasing', change: Math.abs(change), significant: Math.abs(change) > 2 } } } // 4. Application Layer // app/WeatherBuddyApp.ts export class WeatherBuddyApp { private container: DIContainer constructor() { this.container = new DIContainer() this.registerServices() } private registerServices() { // Register API clients this.container.register('apiFactory', () => new ApiClientFactory(config) ) this.container.register('weatherClient', (c) => c.get('apiFactory').create('weather') ) this.container.register('userClient', (c) => c.get('apiFactory').create('user') ) // Register repositories this.container.register('weatherRepo', (c) => new WeatherRepository(c.get('weatherClient')) ) this.container.register('userRepo', (c) => new UserRepository(c.get('userClient')) ) // Register services this.container.register('weatherService', (c) => new WeatherService( c.get('weatherRepo'), c.get('userRepo'), c.get('alertService'), c.get('analyticsService') ) ) // Register plugins this.registerPlugins() } private registerPlugins() { const pluginManager = new PluginManager() // Core plugins pluginManager.register(new CachingPlugin()) pluginManager.register(new MetricsPlugin()) pluginManager.register(new LoggingPlugin()) // Feature plugins pluginManager.register(new OfflinePlugin()) pluginManager.register(new CompressionPlugin()) pluginManager.register(new SecurityPlugin()) this.container.register('plugins', () => pluginManager) } async initialize() { // Load configuration await this.loadConfig() // Initialize services await this.container.get('weatherService').initialize() // Start background tasks this.startBackgroundTasks() // Setup error handling this.setupErrorHandling() } private startBackgroundTasks() { // Sync favorites setInterval(() => { this.container.get('syncService').syncFavorites() }, 5 * 60 * 1000) // Update cache setInterval(() => { this.container.get('cacheService').cleanup() }, 60 * 60 * 1000) // Send analytics setInterval(() => { this.container.get('analyticsService').flush() }, 30 * 1000) } private setupErrorHandling() { window.addEventListener('unhandledrejection', (event) => { this.container.get('errorService').handle(event.reason) }) } getService(name: string): T { return this.container.get(name) } } // 5. Dependency Injection Container // core/DIContainer.ts export class DIContainer { private services = new Map() private factories = new Map() register(name: string, factory: Factory) { this.factories.set(name, factory) } get(name: string): T { if (this.services.has(name)) { return this.services.get(name) } const factory = this.factories.get(name) if (!factory) { throw new Error(`Service ${name} not registered`) } const service = factory(this) this.services.set(name, service) return service } has(name: string): boolean { return this.factories.has(name) } reset() { this.services.clear() } } // Initialize the app const app = new WeatherBuddyApp() await app.initialize() export default app ``` ## Best Practices for API Abstractions 🎯 ### 1. Separation of Concerns ```typescript // ✅ Good: Clear separation class WeatherRepository { // Data access async getByCity(city: string): Promise } class WeatherService { // Business logic async getWeatherWithInsights(city: string): Promise } class WeatherController { // HTTP handling async handleGetWeather(req: Request): Promise } // ❌ Bad: Mixed concerns class WeatherManager { async getWeather(req: Request) { // Validation, data access, business logic, response formatting // all in one place! } } ``` ### 2. Dependency Injection ```typescript // ✅ Good: Dependencies injected class WeatherService { constructor( private weatherRepo: WeatherRepository, private cache: CacheService ) {} } // ❌ Bad: Hard dependencies class WeatherService { private weatherRepo = new WeatherRepository() private cache = new CacheService() } ``` ### 3. Error Handling ```typescript // ✅ Good: Domain-specific errors class CityNotFoundError extends Error { constructor(city: string) { super(`City ${city} not found`) this.name = 'CityNotFoundError' } } // ❌ Bad: Generic errors throw new Error('City not found') ``` ### 4. Testability ```typescript // ✅ Good: Easily testable const mockRepo = createMock() const service = new WeatherService(mockRepo) // ❌ Bad: Hard to test const service = new WeatherService() // Creates own dependencies ``` ## Practice Time! 🏋️ ### Exercise 1: Build a Repository Create a complete repository: ```typescript class ProductRepository { // Your code here: // - CRUD operations // - Search functionality // - Batch operations // - Error handling } ``` ### Exercise 2: Create a Plugin Build a custom plugin: ```typescript class RateLimitPlugin implements ApiPlugin { // Your code here: // - Track requests per endpoint // - Implement backoff // - Queue when limited // - Provide status } ``` ### Exercise 3: Design an API Factory Create a flexible factory: ```typescript class ApiFactory { // Your code here: // - Service registration // - Configuration management // - Interceptor setup // - Plugin system } ``` ## Key Takeaways 🎯 1. **Repository pattern centralizes data access** - One place for API calls 2. **Domain layer speaks business language** - Not API language 3. **Factories create configured clients** - Consistent setup 4. **Plugins make APIs extensible** - Add features without modifying core 5. **Code generation saves time** - Let machines write boilerplate 6. **Dependency injection enables testing** - Mock everything 7. **Composable APIs scale with teams** - Build once, use everywhere 8. **Clear separation of concerns** - Each layer has one job ## Common Pitfalls 🚨 1. **Leaking API details into UI** - Keep transformations in repository 2. **Tight coupling to API structure** - Use domain models 3. **Scattered API configuration** - Centralize in factories 4. **No error abstraction** - Throw domain errors 5. **Missing dependency injection** - Hard to test 6. **Over-engineering** - Start simple, evolve as needed ## What's Next? You've mastered API abstractions! But how do you integrate with frameworks? In Chapter 14, we'll explore framework integration: - React hooks and providers - Vue composables - Angular services - Svelte stores - Next.js integration - Framework-agnostic patterns Ready to integrate with any framework? See you in Chapter 14! ⚛️ --- ## Chapter Summary - Repository pattern centralizes all API calls in one place per domain - Domain-driven design creates a business language layer above APIs - API client factories provide consistent configuration across services - Composable APIs enable building complex features from simple pieces - Plugin architecture makes APIs extensible without modifying core code - Code generation from OpenAPI specs eliminates boilerplate - Dependency injection enables testing and flexibility - Weather Buddy 13.0 demonstrates enterprise-scale architecture patterns **Next Chapter Preview**: Framework Integration - React hooks, Vue composables, Angular services, and framework-agnostic patterns for using TypedFetch.