- The Breaking Point: When the Pattern Became the Problem
- Planning: Understanding the Real Problem
- The Architecture: Three Patterns, One Solution
- Execution: Component by Component
- The Provider Interface: Setting the Standard
- Base Provider: Building Common Ground
- PostHog: Refactoring What We Already Had
- MoEngage: Refactoring the Second Provider
- The Factory: Where Creation Happens
- The Registry: Lifecycle and Health Management
- The Main Service: Orchestrating Everything
- Centralized Configuration: The Control Panel
- The Roadblocks Nobody Warned Us About
- What I’d Tell My Past Self
- The Results That Actually Mattered
- Closing Thoughts
Six months ago, we integrated PostHog into our application. It was straightforward—add the SDK, sprinkle `posthog.capture()` calls across components where we needed tracking. Product analytics, user behavior, feature usage. It worked well enough.
The calls were scattered across our codebase—order placement, user actions, navigation events. About 200 components in total. But PostHog was our only analytics provider, so it didn’t feel like a problem.
**Then the product manager walked in.**
“We need to add MoEngage for user engagement and targeted campaigns. How long will it take?”
I looked at the codebase. We had PostHog tracking in about 200 places. Adding MoEngage would mean finding each one and adding `moengage.track_event()` right next to it. Plus all the new engagement events we needed.
“A day, maybe two,” I said, underestimating as engineers often do.
**I was wrong. Very wrong.**
I watched our frontend engineer make the same change for the 87th time that day. Copy-paste, copy-paste, copy-paste. `posthog.capture()` in one component. `moengage.track_event()` right below it. The same event, the same properties, duplicated logic, scattered across 87 different files. We’d been at it for six hours, and we were only halfway done.
“There has to be a better way,” she said, frustration evident in her voice.
She was right.
We finished the MoEngage integration two days later. 350+ files updated. 4 bugs caught in production. Exhausted team. But it was done.
**A week later, during code review, I saw this:**
| “`typescript // orders.component.ts posthog.capture(‘order_placed’, eventData); moengage.track_event(‘order_placed’, eventData); “` “`typescript // checkout.component.ts posthog.capture(‘checkout_complete’, eventData); moengage.track_event(‘checkout_complete’, eventData); “` “`typescript // user-profile.component.ts posthog.capture(‘profile_updated’, eventData); moengage.track_event(‘profile_updated’, eventData); “` |
**The same pattern. Everywhere. 350+ times.**
And I realized: What happens when we need a third provider? A fourth? Every new analytics tool would mean updating hundreds of files. More copy-paste. More bugs. More maintenance debt.
**That’s when I made the decision: we’re stopping feature work for two weeks to fix this properly.**

The Breaking Point: When the Pattern Became the Problem
Looking back, the problem was obvious. We’d integrated PostHog the quick way—directly calling the SDK wherever we needed tracking. It worked fine when it was just one provider.
When MoEngage came along, we followed the same pattern. Now we had two sets of calls scattered everywhere. The pain was real, but not unbearable.
But a third provider? That’s when the old pattern broke completely
Our operational reality was painful:
- 500+ manual analytics calls scattered across components
- 6 hours to integrate a new provider (and that was if nothing went wrong)
- 3-4 bugs per integration because we’d inevitably miss some edge cases
- Testing was a nightmare because components were tightly coupled to provider SDKs
- No centralized control – couldn’t easily disable a provider without code changes
But the real problem wasn’t just time—it was architectural debt. Every new provider made the codebase more brittle. Every component became more coupled. Every bug fix required hunting through dozens of files.
This wasn’t sustainable. We needed a solution that would let us add providers in minutes, not hours. That would isolate failures. That would make testing straightforward. That would give us centralized control.
The mandate was clear: Build an architecture that makes third-party integration trivial, and do it without disrupting ongoing product development.
Planning: Understanding the Real Problem
I didn’t start with code. I started with questions.
What patterns exist for this problem? I’d seen plugin architectures in IDE’s, content management systems, even Kubernetes operators. The common thread: provider-agnostic core systems that discover and manage plugins dynamically.
I mapped our current pain points:
- Tight coupling – Components knew too much about specific providers
- Scattered logic – Same tracking code repeated hundreds of times
- No lifecycle management – We initialized providers wherever we first needed them
- Error propagation – One provider’s failure could crash the component
- Configuration chaos – Enable/disable required code changes
Then I mapped what “good” would look like:
- Single interface – All providers speak the same language
- Automatic discovery – System finds and initializes enabled providers
- Centralized control – One place to enable/disable providers
- Failure isolation – Provider errors don’t affect the application
- One-line API – Components use
trackEvent(), system handles routing
Three design patterns emerged as the foundation:
- Adapter Pattern – Unify different provider APIs into one interface
- Factory Pattern – Create providers with their specific configurations
- Registry Pattern – Manage provider lifecycle and health
I presented this to the team. The senior engineers were skeptical. “Isn’t this over-engineering?” one asked. Fair question. But I’d done the math: if we add one provider per quarter (conservative estimate), this architecture pays for itself in three months. And it would prevent the codebase from becoming unmaintainable.
We committed two weeks to build it properly.
The Architecture: Three Patterns, One Solution
The core insight was simple: components should never know which analytics providers exist. They should call a service, and that service handles everything else.
Here’s how the layers work together:
Application Component
↓
AnalyticsService (single entry point)
↓
Registry (manages provider instances)
↓
Factory (creates providers)
↓
Individual Providers (PostHog, MoEngage, etc.)
Each layer has a specific job. No layer knows too much. Changes propagate cleanly.
Execution: Component by Component
The Provider Interface: Setting the Standard
I started with the interface. This was critical—get this wrong, and everything else breaks.
| “`typescript // analytics.interface.ts export interface AnalyticsProvider { readonly name: string; readonly isEnabled: boolean; trackEvent(eventName: string, properties?: Record<string, any>): Promise<void>; identifyUser(userId: string, properties?: Record<string, any>): Promise<void>; setUserProperties(properties: Record<string, any>): Promise<void>; trackPageView(properties?: Record<string, any>): Promise<void>; initialize(): Promise<void>; destroy(): Promise<void>; } “` |
The key decision: make everything async. Even if a provider’s API is synchronous, we use promises. Why? Because some providers (like PostHog) batch events asynchronously, and we wanted consistent error handling across all providers.
I also made initialize() and destroy() required. This forced every provider to have proper lifecycle management from day one.
Base Provider: Building Common Ground
Next came the abstract base class. This is where we’d put functionality that every provider would need.
| “`typescript // base-analytics.provider.ts export abstract class BaseAnalyticsProvider implements AnalyticsAdapter { abstract readonly name: string; abstract readonly providerType: string; abstract readonly version: string; protected _isEnabled: boolean = false; protected _isInitialized: boolean = false; protected _lastError: Error | null = null; protected _config: any = {}; constructor(config: any = {}) { this._config = config; this._isEnabled = config?.enabled ?? false; } // Abstract methods – MUST be implemented by subclasses abstract initialize(): Promise<void>; abstract trackEvent(eventName: string, properties?: Record<string, any>): Promise<void>; // … other required methods // Common helpers available to ALL providers protected logInfo(message: string, data?: any): void { if (this._config?.debugLogs) { console.log(`[${this.name}] ${message}`, data || ”); } } protected logError(message: string, error?: any): void { console.error(`[${this.name}] ${message}`, error || ”); this.setLastError(error instanceof Error ? error : new Error(message)); } protected setLastError(error: Error | null): void { this._lastError = error; if (error) { console.error(`[${this.name}] Error:`, error); } } getLastError(): Error | null { return this._lastError; } getProviderInfo(): Record<string, any> { return { name: this.name, type: this.providerType, version: this.version, enabled: this._isEnabled, initialized: this._isInitialized, config: this._config }; } } “` |
**Why this mattered:** Every provider automatically gets error tracking, logging, and state management. When I added MoEngage later, I didn’t have to reimplement this logic. I just extended the base class.
The `getProviderInfo()` method became invaluable for debugging. We could inspect any provider’s state at runtime without provider-specific code.
PostHog: Refactoring What We Already Had
PostHog was already scattered across our codebase—200+ direct SDK calls. Now we needed to refactor it into the new architecture. I chose to start with PostHog because we understood it well—if the architecture didn’t work smoothly here, it wouldn’t work anywhere.
The goal: replace all those scattered `posthog.capture()` calls with our unified service, without breaking existing tracking.
“`typescript // posthog.provider.ts import { BaseAnalyticsProvider } from ‘../../core/base-analytics.provider’; import { PostHogConfig } from ‘./posthog.config’; declare const posthog: any; export class PostHogProvider extends BaseAnalyticsProvider { readonly name = ‘PostHog’; readonly providerType = ‘posthog’; readonly version = ‘1.0.0’; private posthogInstance: any = null; constructor(config: PostHogConfig) { super(config); } async initialize(): Promise<void> { try { if (!this._config.key || !this._config.host) { throw new Error(‘PostHog API key and host are required’); } // Initialize PostHog with provider-specific settings posthog.init(this._config.key, { api_host: this._config.host, disable_compression: this._config.disableCompression, capture_pageview: this._config.capturePageview, autocapture: this._config.autocapture, person_profiles: this._config.personProfiles, persistence: this._config.persistence, loaded: (ph: any) => { ph.debug(this._config.debugLogs || false); } }); this.posthogInstance = posthog; this.setInitialized(true); this.setLastError(null); this.logInfo(‘PostHog provider initialized successfully’); } catch (error) { this.setLastError(error instanceof Error ? error : new Error(String(error))); this.setInitialized(false); throw error; } } async trackEvent(eventName: string, properties?: Record<string, any>): Promise<void> { if (!this.isEnabled || !this.isInitialized) { this.logWarning(‘PostHog provider not enabled or not initialized’); return; } try { const eventProperties = { …properties, timestamp: new Date().toISOString(), source: ‘posthog_provider’ }; this.posthogInstance.capture(eventName, eventProperties); this.logInfo(`Event tracked: ${eventName}`, eventProperties); } catch (error) { this.logError(`Error tracking event: ${eventName}`, error); throw error; } } async identifyUser(userId: string, properties?: Record<string, any>): Promise<void> { if (!this.isEnabled || !this.isInitialized) { this.logWarning(‘PostHog provider not enabled or not initialized’); return; } try { this.posthogInstance.identify(userId, properties); this.logInfo(`User identified: ${userId}`, properties); } catch (error) { this.logError(`Error identifying user: ${userId}`, error); throw error; } } // … other required methods (setUserProperties, trackPageView, destroy) validateConfig(config: any): boolean { return config && typeof config === ‘object’ && typeof config.key === ‘string’ && config.key.length > 0 && typeof config.host === ‘string’ && config.host.length > 0; } } “` |
What I learned: The guards (if (!this.isEnabled || !this.isInitialized)) were essential. In production, we discovered providers sometimes failed to initialize due to network issues or misconfiguration. These guards prevented cascading failures.
The try-catch blocks with setLastError() gave us detailed error tracking. When PostHog’s API changed in a minor version update, we caught it immediately and knew exactly which provider was affected.
MoEngage: Refactoring the Second Provider
MoEngage was already in our codebase too—150+ scattered calls that we’d painstakingly added just weeks earlier. Now we were refactoring it into the new architecture.
This is where the architecture’s flexibility showed its value. MoEngage has different concepts—it calls things “attributes” instead of “properties”, has specific methods for first name, last name, email. These differences had made our original integration messy.
But here’s the power of the adapter pattern: the differences stayed contained within the provider class. The rest of the system didn’t need to know about MoEngage’s quirks.
| “`typescript // moengage.provider.ts (excerpt) export class MoEngageProvider extends BaseAnalyticsProvider { readonly name = ‘MoEngage’; readonly providerType = ‘moengage’; readonly version = ‘1.0.0’; async initialize(): Promise<void> { try { if (!this._config.appId || !this._config.cluster) { throw new Error(‘MoEngage app ID and cluster are required’); } // MoEngage-specific initialization Moengage.init({ app_id: this._config.appId, cluster: this._config.cluster, debug_logs: this._config.debugLogs ? 1 : 0, swPath: this._config.swPath, enableSPA: this._config.enableSPA }); this.setInitialized(true); this.logInfo(‘MoEngage provider initialized successfully’); } catch (error) { this.setLastError(error); throw error; } } async trackEvent(eventName: string, properties?: Record<string, any>): Promise<void> { if (!this.isEnabled || !this.isInitialized) { this.logWarning(‘MoEngage provider not enabled or not initialized’); return; } try { // MoEngage uses different method names Moengage.track_event(eventName, properties || {}); this.logInfo(`Event tracked: ${eventName}`, properties); } catch (error) { this.logError(`Error tracking event: ${eventName}`, error); throw error; } } // MoEngage-specific method for user attributes setUserAttributes(attributes: { firstName?: string; lastName?: string; email?: string; mobile?: string; }): void { if (!this.isEnabled || !this.isInitialized) return; try { if (attributes.firstName) Moengage.add_first_name(attributes.firstName); if (attributes.lastName) Moengage.add_last_name(attributes.lastName); if (attributes.email) Moengage.add_email(attributes.email); if (attributes.mobile) Moengage.add_mobile(attributes.mobile); this.logInfo(‘User attributes set’, attributes); } catch (error) { this.logError(‘Error setting user attributes’, error); } } } “` |
The rest of the system didn’t know or care about these differences. That’s the point of the adapter pattern.
The Factory: Where Creation Happens
The Factory Service is where provider instances are created with their specific configurations. This was crucial for keeping configuration isolated.
| “`typescript // analytics-factory.service.ts import { Injectable } from ‘@angular/core’; import { AnalyticsAdapter } from ‘../core/analytics-adapter.interface’; import { PostHogProvider } from ‘../providers/posthog/posthog.provider’; import { MoEngageProvider } from ‘../providers/moengage/moengage.provider’; import { environment } from ‘../../../environments/environment’; export interface ProviderFactory { name: string; create: () => AnalyticsAdapter; } @Injectable({ providedIn: ‘root’ }) export class AnalyticsFactoryService { private readonly providerFactories: Map<string, ProviderFactory> = new Map(); constructor() { this.registerDefaultProviders(); } private registerDefaultProviders(): void { // Each provider manages its OWN configuration this.registerProvider(‘posthog’, { name: ‘PostHog’, create: () => { const config = { enabled: true, key: environment.POSTHOG_KEY || ”, host: environment.POSTHOG_HOST || ”, debugLogs: false, timeout: 5000, retryAttempts: 2, disableCompression: true, capturePageview: true, autocapture: true, personProfiles: ‘identified_only’ as const, persistence: ‘localStorage’ as const, }; return new PostHogProvider(config); } }); this.registerProvider(‘moengage’, { name: ‘MoEngage’, create: () => { const config = { enabled: true, appId: environment.MOENGAGE_APP_ID || ”, cluster: environment.MOENGAGE_CLUSTER || ”, debugLogs: true, timeout: 5000, retryAttempts: 3, enableSPA: true, swPath: ‘/seller/serviceworker.js’, trackLocation: false, }; return new MoEngageProvider(config); } }); } registerProvider(name: string, factory: ProviderFactory): void { this.providerFactories.set(name.toLowerCase(), factory); console.log(`[AnalyticsFactory] Registered provider: ${name}`); } createProvider(name: string): AnalyticsAdapter | null { const factory = this.providerFactories.get(name.toLowerCase()); if (!factory) { console.error(`[AnalyticsFactory] Provider factory not found: ${name}`); return null; } try { const provider = factory.create(); console.log(`[AnalyticsFactory] Created provider: ${name}`); return provider; } catch (error) { console.error(`[AnalyticsFactory] Error creating provider ${name}:`, error); return null; } } getAvailableProviders(): string[] { return Array.from(this.providerFactories.keys()); } } “` |
The key insight: Each provider’s create() function pulls from environment variables and creates the provider instance. When we need to add a new provider, we just add a new registration. The factory knows about providers, but the rest of the system doesn’t.
The Registry: Lifecycle and Health Management
The Registry Service became our control center for provider lifecycle, health, and statistics.
| “`typescript // analytics-registry.service.ts import { Injectable } from ‘@angular/core’; import { AnalyticsAdapter } from ‘../core/analytics-adapter.interface’; import { AnalyticsFactoryService } from ‘./analytics-factory.service’; import { getEnabledProviders } from ‘../config/analytics-provider.config’; @Injectable({ providedIn: ‘root’ }) export class AnalyticsRegistryService { private readonly providers: Map<string, AnalyticsAdapter> = new Map(); private readonly providerStats: Map<string, { eventCount: number; errorCount: number; lastActivity: Date; responseTimes: number[]; }> = new Map(); constructor(private factory: AnalyticsFactoryService) {} async initializeProviders(): Promise<void> { console.log(‘[AnalyticsRegistry] Initializing providers…’); // Get enabled providers from central config const enabledProviders = getEnabledProviders(); if (enabledProviders.length === 0) { console.log(‘[AnalyticsRegistry] No providers enabled’); return; } const initPromises: Promise<void>[] = []; // Initialize all enabled providers in parallel for (const providerName of enabledProviders) { const provider = this.factory.createProvider(providerName); if (!provider) { console.error(`[Registry] Failed to create provider: ${providerName}`); continue; } const initPromise = provider.initialize().then(() => { this.providers.set(providerName.toLowerCase(), provider); // Initialize statistics tracking this.providerStats.set(providerName.toLowerCase(), { eventCount: 0, errorCount: 0, lastActivity: new Date(), responseTimes: [] }); console.log(`[Registry] Provider ${providerName} initialized ✓`); }).catch((error) => { console.error(`[Registry] Failed to initialize ${providerName}:`, error); // Don’t let one provider’s failure block others }); initPromises.push(initPromise); } await Promise.all(initPromises); console.log(‘[AnalyticsRegistry] Initialization complete’); } getProvidersByNames(names: string[]): AnalyticsAdapter[] { return names .map(name => this.providers.get(name.toLowerCase())) .filter(provider => provider !== null) as AnalyticsAdapter[]; } updateProviderStats( name: string, eventType: ‘success’ | ‘error’, responseTime?: number ): void { const stats = this.providerStats.get(name.toLowerCase()); if (!stats) return; stats.lastActivity = new Date(); if (eventType === ‘success’) { stats.eventCount++; if (responseTime !== undefined) { stats.responseTimes.push(responseTime); // Keep only last 100 response times for rolling average if (stats.responseTimes.length > 100) { stats.responseTimes.shift(); } } } else { stats.errorCount++; } } getProvidersStatus(): any[] { return Array.from(this.providers.entries()).map(([name, provider]) => { const stats = this.providerStats.get(name) || { eventCount: 0, errorCount: 0, lastActivity: new Date(), responseTimes: [] }; const averageResponseTime = stats.responseTimes.length > 0 ? stats.responseTimes.reduce((sum, time) => sum + time, 0) / stats.responseTimes.length : 0; return { name: provider.name, enabled: provider.isEnabled, initialized: provider.isInitialized, lastError: provider.getLastError()?.message, lastActivity: stats.lastActivity, eventCount: stats.eventCount, errorCount: stats.errorCount, averageResponseTime: Math.round(averageResponseTime) }; }); } } “` |
What I learned building this: The statistics tracking became incredibly valuable in production. When PostHog started having higher-than-normal response times, we caught it immediately because of the rolling average tracking. The Promise.all() with individual error handling meant one provider’s initialization failure didn’t block others.
The Main Service: Orchestrating Everything
The Analytics Service is what components actually use. It’s the public API of our entire system.
| “`typescript // analytics.service.ts import { Injectable } from ‘@angular/core’; import { AnalyticsRegistryService } from ‘./analytics-registry.service’; import { getEventRouting } from ‘../constants/analytics-routing.constants’; import { isProviderEnabled, getEnabledProviders } from ‘../config/analytics-provider.config’; @Injectable({ providedIn: ‘root’ }) export class AnalyticsService { private isInitialized = false; constructor(private registry: AnalyticsRegistryService) {} async initialize(): Promise<void> { if (this.isInitialized) return; try { console.log(‘[AnalyticsService] Initializing…’); const enabledProviders = getEnabledProviders(); if (enabledProviders.length === 0) { console.warn(‘[AnalyticsService] No providers enabled’); this.isInitialized = true; return; } console.log(`[AnalyticsService] Enabled providers: ${enabledProviders.join(‘, ‘)}`); await this.registry.initializeProviders(); this.isInitialized = true; console.log(‘[AnalyticsService] Initialized successfully ✓’); } catch (error) { console.error(‘[AnalyticsService] Initialization error:’, error); // Mark as initialized anyway to prevent retry loops this.isInitialized = true; } } async trackEvent(eventName: string, properties?: Record<string, any>): Promise<void> { if (!this.isInitialized) { console.warn(‘[AnalyticsService] Service not initialized’); return; } // Get routing rules for this event const routing = getEventRouting(eventName); // Filter to only enabled providers const targetProviders = routing.providers.filter( providerName => isProviderEnabled(providerName) ); if (targetProviders.length === 0) { return; // No enabled providers for this event } console.log(`[Analytics] Routing “${eventName}” to: ${targetProviders.join(‘, ‘)}`); const providers = this.registry.getProvidersByNames(targetProviders); // Send to all target providers in parallel for (const provider of providers) { if (provider.isEnabled && provider.isInitialized) { try { const startTime = Date.now(); await provider.trackEvent(eventName, properties); const responseTime = Date.now() – startTime; this.registry.updateProviderStats(provider.name, ‘success’, responseTime); } catch (error) { this.registry.updateProviderStats(provider.name, ‘error’); console.error(`[Analytics] Error in ${provider.name}:`, error); // Error in one provider doesn’t affect others } } } } async identifyUser(userId: string, properties?: Record<string, any>): Promise<void> { if (!this.isInitialized || !userId) return; const targetProviders = getEnabledProviders(); const providers = this.registry.getProvidersByNames(targetProviders); for (const provider of providers) { if (provider.isEnabled && provider.isInitialized) { try { await provider.identifyUser(userId, properties); } catch (error) { console.error(`[Analytics] Error identifying user in ${provider.name}:`, error); } } } } // Simple API for components getProvidersStatus() { return this.registry.getProvidersStatus(); } } “` |
The critical design decision: Error isolation. When a provider fails, we log it and continue. The component that called trackEvent() never sees the error. Analytics failures should never break application functionality.
Component refactoring was the payoff:
| “`typescript // BEFORE – The old way we had with PostHog and MoEngage export class OrdersComponent { placeOrder() { // … business logic // Multiple provider calls scattered everywhere posthog.capture(‘order_placed’, { orderId: this.order.id, amount: this.order.total, currency: ‘INR’ }); moengage.track_event(‘order_placed’, { orderId: this.order.id, amount: this.order.total, currency: ‘INR’ }); // Now imagine adding Segment here too… } } “` “`typescript // AFTER – The new architecture import { Component } from ‘@angular/core’; import { AnalyticsService } from ‘../analytics/services/analytics.service’; @Component({ selector: ‘app-orders’, template: `<button (click)=”placeOrder()”>Place Order</button>` }) export class OrdersComponent { constructor(private analytics: AnalyticsService) {} placeOrder() { // … business logic // One line – auto-routes to ALL configured providers this.analytics.trackEvent(‘order_placed’, { orderId: this.order.id, amount: this.order.total, currency: ‘INR’ }); } } “` |
That’s the transformation. We went through 200+ components, replacing multiple provider calls with a single service call. The component doesn’t know which providers exist. It doesn’t handle errors. It doesn’t manage configuration. It just tracks the event.
Centralized Configuration: The Control Panel
The final piece was centralized provider control:
| “`typescript // analytics-provider.config.ts export interface ProviderEnableConfig { posthog: boolean; moengage: boolean; // Future providers go here [key: string]: boolean; } export const DEFAULT_PROVIDER_CONFIG: AnalyticsProviderConfig = { providers: { posthog: false, // Disabled moengage: true, // Enabled } }; export function isProviderEnabled(providerName: string): boolean { const config = getEnvironmentProviderConfig(); return config.providers[providerName] === true; } export function getEnabledProviders(): string[] { const config = getEnvironmentProviderConfig(); return Object.entries(config.providers) .filter(([_, enabled]) => enabled) .map(([name, _]) => name); } “` |
One file controls everything. Want to disable PostHog for debugging? Change one boolean. Want to add a new provider? Add one line.
The Roadblocks Nobody Warned Us About
The Event Routing Complexity
Two days into rollout, we discovered a problem: some events should only go to PostHog (product analytics), others only to MoEngage (user engagement), and some to both.
I initially thought we could handle this with simple flags. Wrong. We needed proper routing configuration:
| “`typescript // analytics-routing.constants.ts export const EVENT_ROUTING = { // Auth events – all providers ‘login_success’: { providers: [‘posthog’, ‘moengage’] }, // Product usage – PostHog only ‘feature_clicked’: { providers: [‘posthog’] }, // User engagement – MoEngage only ‘notification_sent’: { providers: [‘moengage’] }, // Default routing ‘default’: { providers: [‘posthog’, ‘moengage’] } }; “` |
This added complexity but gave us fine-grained control. The lesson: Real-world requirements emerge during rollout, not during planning.
The Initialization Race Condition
In production, we hit a subtle bug: components were calling trackEvent() before the analytics service finished initializing. Events were lost.
The fix required ensuring initialization happened early in the application lifecycle:
| “`typescript // app.component.ts export class AppComponent implements OnInit { constructor(private analytics: AnalyticsService) {} async ngOnInit() { // Initialize analytics before anything else await this.analytics.initialize(); // … rest of initialization } } “` |
We also added guards in the service to queue events if initialization wasn’t complete. The lesson: Async initialization needs careful orchestration.
The Provider Version Update Surprise
Three months after deployment, PostHog released a breaking change in their SDK. Our entire tracking system broke.
But here’s where the architecture saved us: we only had to fix one file (posthog.provider.ts). The change was contained. We updated the provider, tested it in isolation, deployed. Total time: 20 minutes.
Under the old system, we would’ve had to update 500+ files. The lesson: Isolation isn’t just about errors—it’s about change management.
What I’d Tell My Past Self
1. Invest in Architecture Early, But Not Too Early
We built this after integrating two providers the “quick” way. If we’d done it after just PostHog, we would’ve over-engineered for a problem we didn’t have yet. If we’d waited until we had three or four providers, the refactoring cost would’ve been prohibitive.
The pain of integrating MoEngage showed us the pattern. Looking at 350+ duplicated calls made us realize we couldn’t keep doing this. Two is the right number to recognize the problem and act.
2. Make the API Stupid Simple
I initially designed a more complex API with different methods for different event types. The team pushed back: “Just give us one trackEvent() method.” They were right. Simplicity wins.
3. Error Isolation is Non-Negotiable
When PostHog’s servers went down for 3 hours, our application kept working perfectly. Users never knew. Metrics were lost for those hours, but business continued. This is the value of proper error isolation.
4. Statistics and Monitoring Aren’t Optional
The provider statistics we built into the Registry Service have saved us countless times. Seeing response times spike before users notice problems is invaluable. Build observability from day one.
5. Document the “Why”, Not Just the “How”
Our README explains the architecture and the code. But I wish we’d documented more of the decisions—why we chose async over sync, why we isolate errors, why we use a registry pattern. Future maintainers need context, not just code.
6. Make Adding Providers Trivial, Then Advertise It
Once the system was live, I did a demo for the team showing how to add a new provider in 5 minutes. Four different teams immediately started using it for their own integration needs (payment gateways, notification services). The pattern spread organically because it solved a real problem.
The Results That Actually Mattered
The metrics tell part of the story:
Before (PostHog + MoEngage the old way):
- ⏱️ 2 days to integrate MoEngage after PostHog
- 🐛 4 bugs found in production after integration
- 📝 350+ files updated to add MoEngage calls
- 🔗 Tight coupling everywhere – every component knew about providers
- 😫 Complete exhaustion and mounting technical debt
After (with the new architecture):
- ⏱️ 5 minutes to integrate any new provider (98% reduction)
- 🐛 Zero bugs in new integrations
- 📝 One file to add (just the provider class)
- 🔗 Complete isolation – components don’t know providers exist
- 📊 Two weeks of refactoring – painful upfront, but worth it
- ✅ Ready for scale – third, fourth, fifth providers are trivial now
But the real impact was organizational:
Developer Confidence: Engineers who’d never touched analytics code started adding providers confidently. The standardization made it approachable.
Business Agility: Now when product asks for a new analytics provider, we say “yes” immediately. No negotiation about timeline. No technical debt discussion. Just yes.
Code Quality: Our codebase became cleaner. Components focused on business logic, not analytics plumbing. Testing became straightforward because analytics was easily mockable.
Team Morale: That engineer who spent 2 days manually adding MoEngage? She’s now the go-to person for showing others how to add new providers in under 5 minutes. That’s the real win.
Closing Thoughts
Building this architecture wasn’t about showing off design patterns or writing clever code. It was about solving a real problem that was slowing down our team and making our codebase worse with every integration.
We’d already integrated PostHog and MoEngage the “wrong” way—scattered calls across hundreds of components. The refactoring effort was significant. But the hardest part wasn’t the technical work—it was convincing stakeholders that two weeks of “no features” was worth it.
I made the case: “We can spend two weeks now refactoring PostHog and MoEngage and building the architecture, or we can spend 2 days every time we add a new provider, accumulating more technical debt each time.”
The math was clear. Two weeks upfront versus 2 days × N providers. By provider #5, we’d break even. By provider #10, we’d be saving months.
Six months later, the architecture has proven its worth. When product asks for new analytics tools, we can say yes without hesitation.
The real lesson: Good architecture isn’t about perfect abstraction or textbook patterns. It’s about recognizing when your current approach stops scaling, having the courage to refactor before the debt becomes crushing, and building systems that get better with scale instead of worse.
We did it wrong twice—PostHog and MoEngage as scattered direct calls. But we recognized the pattern before it was too late. That Thursday at 11 PM, watching our engineer manually update the 87th component, I saw a broken system. Two weeks of refactoring later, we had a system that scaled. Six months later, other teams were using the same pattern for their own integrations.
Sometimes the best engineering decision is the one that lets your team move faster, ship confidently, and sleep better at night.
And honestly? That’s worth admitting you got it wrong the first two times and fixing it properly.
Want to Learn More?
This architecture pattern works for any third-party integration scenario:
- Payment gateways (Stripe, Razorpay, PayPal)
- Notification services (SendGrid, Twilio, AWS SES)
- Feature flag systems (LaunchDarkly, Optimizely)
- Any service where you might have multiple providers