Last week, I was wrapping up a client project that required integrating three distinct third-party APIs: Stripe for payment processing, SendGrid for transactional order emails, and a legacy custom CRM for syncing customer data. I made the classic freelance rookie mistake of hardcoding base URLs, API keys, and even custom retry logic directly into the service functions that called each API. When the client unexpectedly switched their CRM provider mid-sprint, I had to hunt down 12 separate files to update the old API base URL, rename environment variables in 8 places, and reimplement retry logic for the new provider’s rate limit structure. It took 6 hours of frantic debugging to get everything working again, all because I’d scattered integration details across the codebase.
That headache taught me to always build a dedicated, centralized integration config layer before writing a single API call. I now create an integrations/ directory for every client project with two core parts: first, a config.ts file that pulls all environment-specific variables (API keys, base URLs, rate limits, timeout values) from process.env with safe fallbacks for local development. Second, thin wrapper files for each API (e.g., stripeClient.ts, crmClient.ts) that initialize the official SDK or a fetch/axios instance with config values, and bake in shared logic like 429 rate limit retries, default headers, and structured error logging. Services that need to call an API never touch raw config or env variables directly — they only import the pre-configured client:
// integrations/crmClient.ts
import { crmConfig } from './config'
import axios from 'axios'
const crmClient = axios.create({
baseURL: crmConfig.baseUrl,
headers: { 'Authorization': `Bearer ${crmConfig.apiKey}` },
timeout: crmConfig.timeoutMs
})
// Baked-in retry logic for rate limits
crmClient.interceptors.response.use(
(res) => res,
async (error) => {
if (error.response?.status === 429 && (error.config.retryCount || 0) < crmConfig.maxRetries) {
error.config.retryCount = (error.config.retryCount || 0) + 1
await new Promise(r => setTimeout(r, crmConfig.retryDelayMs))
return crmClient(error.config)
}
return Promise.reject(error)
}
)
export default crmClient
Since adopting this pattern, I’ve eliminated 90% of integration-related production bugs across my freelance projects. When that same client later switched email providers from SendGrid to Postmark, I only updated 3 lines in config.ts and swapped the SendGrid wrapper for a Postmark one — no other code in the project needed changes. For freelancers juggling 4-5 active client codebases at once, that small upfront setup saves hours of reactive debugging, and makes handing off projects to client in-house teams far smoother since all integration logic is in one predictable place.
Top comments (0)