Features: - Zero configuration, just works out of the box - Runtime type inference and validation - Built-in caching with W-TinyLFU algorithm - Automatic retries with exponential backoff - Circuit breaker for resilience - Request deduplication - Offline support with queue - OpenAPI schema discovery - Full TypeScript support with type descriptors - Modular architecture - Configurable for advanced use cases Built with bun, ready for npm publishing
41 KiB
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:
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:
// repositories/WeatherRepository.ts
export class WeatherRepository {
constructor(private tf: TypedFetch) {}
async getByCity(city: string): Promise<Weather> {
const { data } = await this.tf.get(`/api/weather/${city}`)
return this.transformWeatherData(data)
}
async getByCities(cities: string[]): Promise<Weather[]> {
// Batch request for efficiency
const promises = cities.map(city => this.getByCity(city))
return Promise.all(promises)
}
async search(query: string): Promise<WeatherSearchResult[]> {
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<Forecast> {
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<User> {
const { data } = await this.tf.get('/api/users/me')
return this.transformUser(data)
}
async updateProfile(updates: Partial<UserProfile>): Promise<User> {
const { data } = await this.tf.patch('/api/users/me', {
data: updates
})
return this.transformUser(data)
}
async getFavorites(): Promise<FavoriteCity[]> {
const { data } = await this.tf.get('/api/users/me/favorites')
return data.map(this.transformFavorite)
}
async addFavorite(city: string): Promise<FavoriteCity> {
const { data } = await this.tf.post('/api/users/me/favorites', {
data: { city }
})
return this.transformFavorite(data)
}
async removeFavorite(cityId: string): Promise<void> {
await this.tf.delete(`/api/users/me/favorites/${cityId}`)
}
async reorderFavorites(cityIds: string[]): Promise<FavoriteCity[]> {
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:
// domain/Weather.ts
export class WeatherDomain {
constructor(
private weatherRepo: WeatherRepository,
private userRepo: UserRepository,
private alertService: AlertService
) {}
async getDashboard(): Promise<WeatherDashboard> {
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<WeatherSearchResult[]> {
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<DetailedForecast> {
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:
// factories/ApiClientFactory.ts
export class ApiClientFactory {
private clients = new Map<string, TypedFetch>()
constructor(private baseConfig: ApiConfig) {}
create(name: string, config: Partial<ApiConfig> = {}): 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<ApiConfig>): 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<ApiConfig> {
const configs: Record<string, Partial<ApiConfig>> = {
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:
// composables/useWeather.ts
export function useWeather() {
const weatherRepo = new WeatherRepository(weatherClient)
const cache = new WeatherCache()
const getWeather = async (city: string): Promise<Weather> => {
// 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<WeatherDashboard>({
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:
// 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<RequestConfig>
afterResponse?(response: Response): Response | Promise<Response>
onError?(error: Error): Error | Promise<Error>
// Custom methods
methods?: Record<string, Function>
}
// plugins/CachingPlugin.ts
export class CachingPlugin implements ApiPlugin {
name = 'caching'
version = '1.0.0'
private cache = new Map<string, CacheEntry>()
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<RequestConfig> {
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<Response> {
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<string, EndpointMetrics>()
}
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<string, ApiPlugin>()
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:
// generators/ApiGenerator.ts
export class ApiGenerator {
async generateFromOpenAPI(specUrl: string): Promise<GeneratedCode> {
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:
// 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<T> {
constructor(
protected tf: TypedFetch,
protected cache?: CacheManager
) {}
protected async fetchWithCache<R>(
key: string,
fetcher: () => Promise<R>,
ttl?: number
): Promise<R> {
if (this.cache) {
const cached = await this.cache.get<R>(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<UserWeatherData> {
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<EnhancedWeather> {
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<Insight[]> {
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<T>(name: string): T {
return this.container.get(name)
}
}
// 5. Dependency Injection Container
// core/DIContainer.ts
export class DIContainer {
private services = new Map<string, any>()
private factories = new Map<string, Factory>()
register(name: string, factory: Factory) {
this.factories.set(name, factory)
}
get<T>(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
// ✅ Good: Clear separation
class WeatherRepository { // Data access
async getByCity(city: string): Promise<RawWeather>
}
class WeatherService { // Business logic
async getWeatherWithInsights(city: string): Promise<EnhancedWeather>
}
class WeatherController { // HTTP handling
async handleGetWeather(req: Request): Promise<Response>
}
// ❌ Bad: Mixed concerns
class WeatherManager {
async getWeather(req: Request) {
// Validation, data access, business logic, response formatting
// all in one place!
}
}
2. Dependency Injection
// ✅ 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
// ✅ 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
// ✅ Good: Easily testable
const mockRepo = createMock<WeatherRepository>()
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:
class ProductRepository {
// Your code here:
// - CRUD operations
// - Search functionality
// - Batch operations
// - Error handling
}
Exercise 2: Create a Plugin
Build a custom plugin:
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:
class ApiFactory {
// Your code here:
// - Service registration
// - Configuration management
// - Interceptor setup
// - Plugin system
}
Key Takeaways 🎯
- Repository pattern centralizes data access - One place for API calls
- Domain layer speaks business language - Not API language
- Factories create configured clients - Consistent setup
- Plugins make APIs extensible - Add features without modifying core
- Code generation saves time - Let machines write boilerplate
- Dependency injection enables testing - Mock everything
- Composable APIs scale with teams - Build once, use everywhere
- Clear separation of concerns - Each layer has one job
Common Pitfalls 🚨
- Leaking API details into UI - Keep transformations in repository
- Tight coupling to API structure - Use domain models
- Scattered API configuration - Centralize in factories
- No error abstraction - Throw domain errors
- Missing dependency injection - Hard to test
- 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.