Technical architecture and design patterns of WebVentures Comms SDK v2.0.5.
- Overview
- Core Components
- Design Patterns
- Type System
- Provider System
- Strategy System
- Error Handling
- Framework Adapters
- Dependency Management
- Performance Considerations
WebVentures Comms SDK v2.0.5 is architected with the following principles:
- Type Safety: Full TypeScript implementation with comprehensive types
- Modularity: Clear separation of concerns across components
- Extensibility: Plugin-based provider and strategy systems
- Framework Agnostic: Core library with framework-specific adapters
- Performance: Lazy loading, memoization, and efficient data structures
- Reliability: Robust error handling and fallback mechanisms
┌─────────────────────────────────────────────────────────────┐
│ CommsSdk │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Configuration │ │
│ │ - Channels │ │
│ │ - Providers │ │
│ │ - Strategies │ │
│ └───────────────┬───────────────────────────────────────┘ │
│ │ │
│ ┌───────────────▼───────────────┐ │
│ │ Sender │ │
│ │ - Channel orchestration │ │
│ │ - Parallel execution │ │
│ │ - Result aggregation │ │
│ └───────────────┬───────────────┘ │
│ │ │
│ ┌───────────┼───────────┐ │
│ │ │ │ │
│ ┌───▼────┐ ┌──▼─────┐ ┌─▼──────┐ │
│ │Strategy│ │Strategy│ │Strategy│ │
│ │ (Email)│ │ (SMS) │ │ (Push) │ │
│ └───┬────┘ └──┬─────┘ └─┬──────┘ │
│ │ │ │ │
│ ┌───▼────────────────────────▼──┐ │
│ │ Provider Factory │ │
│ │ - Dynamic provider creation │ │
│ │ - Instance management │ │
│ └───┬────────────────────────┬──┘ │
│ │ │ │
│ ┌───▼────┐ ┌───────┐ ┌──▼──────┐ │
│ │Provider│ │Provider│ │Provider │ │
│ │ #1 │ │ #2 │ │ #3 │ │
│ └────────┘ └────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
Location: src/index.ts
Responsibilities:
- SDK initialization and configuration
- Provider factory coordination
- Strategy factory coordination
- Public API surface
Key Methods:
class CommsSdk {
constructor(options: CommsSdkConfig)
send(request: NotificationRequest): Promise<NotificationStatus>
logger: LoggerInstance
}Design Pattern: Facade Pattern
- Provides simplified interface to complex subsystem
- Coordinates multiple components (providers, strategies, sender)
Location: src/sender.ts
Responsibilities:
- Multi-channel notification orchestration
- Parallel channel execution
- Result aggregation
- Error handling and recovery
Key Features:
class Sender {
constructor(channels: ChannelType[], providers: ProvidersMap, strategies: StrategiesMap)
async send(request: NotificationRequest): Promise<NotificationStatus>
private async sendOnEachChannel(request): Promise<ChannelSendResult[]>
}Design Pattern: Coordinator Pattern
- Manages parallel execution across channels
- Aggregates results from multiple sources
- Handles partial failures gracefully
Location: src/providers/index.ts
Responsibilities:
- Dynamic provider instantiation
- Provider type validation
- Custom provider support
- Provider lifecycle management
Design Pattern: Factory Pattern + Registry Pattern
function providerFactory(channels: ChannelsConfig): ProvidersMap {
// Creates providers based on configuration
// Registers instances in registry
// Returns provider map by channel
}Location: src/strategies/providers/index.ts
Responsibilities:
- Strategy selection and instantiation
- Built-in strategy support (fallback, roundrobin, no-fallback)
- Custom strategy support
Design Pattern: Strategy Pattern + Factory Pattern
function strategyProvidersFactory(channels): StrategiesMap {
// Selects appropriate strategy per channel
// Returns strategy function map
}Used extensively for provider and strategy creation:
// Provider Factory
function createEmailProvider(config: EmailProvider): Provider<EmailRequest> {
switch (config.type) {
case 'smtp':
return new SmtpProvider(config)
case 'sendgrid':
return new SendGridProvider(config)
// ... other providers
}
}Benefits:
- Decouples provider creation from usage
- Enables dynamic provider selection
- Supports dependency injection
Multi-provider strategies are implemented using Strategy pattern:
interface StrategyFunction<TRequest = unknown> {
(providers: Provider<TRequest>[]): (request: TRequest) => Promise<ProviderSendResult>
}
// Fallback strategy implementation
const strategyFallback: StrategyFunction = (providers) => {
return async (request) => {
// Try providers in sequence until success
}
}Benefits:
- Interchangeable provider selection algorithms
- Easy to add new strategies
- No modification to existing code
CommsSdk class provides a facade to complex subsystem:
class CommsSdk {
private sender: Sender
constructor(config: CommsSdkConfig) {
// Initialize complex subsystem
const providers = providerFactory(config.channels)
const strategies = strategyProvidersFactory(config.channels)
this.sender = new Sender(channels, providers, strategies)
}
// Simple public API
send(request: NotificationRequest) {
return this.sender.send(request)
}
}Provider instances are cached in a registry:
class Registry {
private static instances = new Map<string, unknown>()
static getInstance<T>(key: string, factory: () => T): T {
if (!this.instances.has(key)) {
this.instances.set(key, factory())
}
return this.instances.get(key) as T
}
}Benefits:
- Prevents duplicate provider instances
- Memory efficiency
- Centralized instance management
Framework adapters adapt SDK for specific environments:
// Next.js adapter
export function createNextJSComms(config: CommsSdkConfig): CommsSdk {
// Verify server environment
if (typeof window !== 'undefined') {
throw new Error('Must run server-side')
}
return new CommsSdk(config)
}
// React Native adapter
export function createReactNativeComms(config: CommsSdkConfig): CommsSdk {
// Adapt for React Native environment
return new CommsSdk(config)
}NotificationRequest
├── EmailRequest
├── SmsRequest
├── PushRequest
├── VoiceRequest
├── WebpushRequest
├── SlackRequest
└── WhatsappRequest
ProviderConfig
├── EmailProvider
│ ├── EmailProviderSmtp
│ ├── EmailProviderSendGrid
│ ├── EmailProviderSes
│ └── ...
├── SmsProvider
│ ├── SmsProviderTwilio
│ ├── SmsProviderNexmo
│ └── ...
└── ...
Provider types use discriminated unions for type safety:
type EmailProvider =
| { type: 'smtp'; host: string; port: number; ... }
| { type: 'sendgrid'; apiKey: string }
| { type: 'ses'; region: string; ... }
function createEmailProvider(config: EmailProvider) {
switch (config.type) {
case 'smtp':
// TypeScript knows config has host, port, etc.
return new SmtpProvider(config)
case 'sendgrid':
// TypeScript knows config has apiKey
return new SendGridProvider(config)
}
}Strategy functions use generics for type safety:
type StrategyFunction<TRequest = unknown> = (
providers: Provider<TRequest>[]
) => (request: TRequest) => Promise<ProviderSendResult>
const emailStrategy: StrategyFunction<EmailRequest> = (providers) => {
return async (request: EmailRequest) => {
// request is strongly typed as EmailRequest
}
}All providers implement a common interface:
interface Provider<TRequest = unknown> {
id: string
send(request: TRequest): Promise<string>
}Configuration → Factory → Registration → Execution
1. User provides config:
{ type: 'sendgrid', apiKey: 'xxx' }
2. Factory creates instance:
const provider = new SendGridProvider(config)
3. Registry stores instance:
Registry.set('sendgrid-default', provider)
4. Strategy uses provider:
const id = await provider.send(request)
{
type: 'custom',
id: 'my-provider',
send: async (request: EmailRequest): Promise<string> => {
// Custom implementation
return messageId
}
}The custom provider is wrapped in an adapter:
class CustomProviderAdapter implements Provider<EmailRequest> {
id: string
private sendFn: (request: EmailRequest) => Promise<string>
constructor(config: CustomProviderConfig<EmailRequest>) {
this.id = config.id
this.sendFn = config.send
}
async send(request: EmailRequest): Promise<string> {
return this.sendFn(request)
}
}type StrategyFunction<TRequest = unknown> = (
providers: Provider<TRequest>[]
) => (request: TRequest) => Promise<ProviderSendResult>const strategyFallback: StrategyFunction = (providers) => {
return async (request) => {
for (const provider of providers) {
try {
const id = await provider.send(request)
return { id, providerId: provider.id }
} catch (error) {
// Try next provider
if (provider === providers[providers.length - 1]) {
throw error // Last provider, rethrow
}
}
}
}
}let currentIndex = 0
const strategyRoundRobin: StrategyFunction = (providers) => {
return async (request) => {
const startIndex = currentIndex
while (true) {
const provider = providers[currentIndex]
currentIndex = (currentIndex + 1) % providers.length
try {
const id = await provider.send(request)
return { id, providerId: provider.id }
} catch (error) {
if (currentIndex === startIndex) {
throw error // Tried all providers
}
}
}
}
}function getStrategy(name: MultiProviderStrategyType | StrategyFunction): StrategyFunction {
if (typeof name === 'function') {
return name // Custom strategy
}
switch (name) {
case 'fallback':
return strategyFallback
case 'roundrobin':
return strategyRoundRobin
case 'no-fallback':
return strategyNoFallback
}
}Error
└── CommsError
├── ProviderError
├── ConfigurationError
├── ValidationError
└── NetworkError
Provider sends request
↓
┌─────────────┐
│ Success? │
└─────┬───────┘
│
Yes │ No
│ ↓
│ Error caught by strategy
│ ↓
│ Fallback to next provider (if strategy supports)
│ ↓
│ All providers failed?
│ ↓
│ Throw ProviderError
↓
Return { id, providerId }
Errors are enriched with context as they bubble up:
try {
await provider.send(request)
} catch (error) {
if (error instanceof Error) {
;(error as ProviderError).providerId = provider.id(error as ProviderError).channel = channel
}
throw error
}┌─────────────────────────────────────┐
│ Framework-Specific Layer │
│ (Next.js, React Native, etc.) │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Adapter Layer │
│ - Environment detection │
│ - Framework-specific utilities │
│ - Validation │
└──────────────┬──────────────────────┘
│
┌──────────────▼──────────────────────┐
│ Core SDK Layer │
│ - CommsSdk │
│ - Sender │
│ - Providers │
│ - Strategies │
└─────────────────────────────────────┘
export function createNextJSComms(config: CommsSdkConfig): CommsSdk {
// 1. Validate environment
if (typeof window !== 'undefined') {
throw new Error('Must run server-side')
}
// 2. Create SDK instance
return new CommsSdk(config)
}
export function withComms<TArgs, TReturn>(
handler: (sdk: CommsSdk, ...args: TArgs[]) => Promise<TReturn>,
config: CommsSdkConfig
) {
// 3. Lazy initialization pattern
let instance: CommsSdk | null = null
return async (...args: TArgs[]): Promise<TReturn> => {
if (!instance) {
instance = createNextJSComms(config)
}
return handler(instance, ...args)
}
}- Winston: Logging (abstracted via logger interface)
- Nodemailer: SMTP email sending
- Undici: Modern HTTP client
- Web-push: Web push notifications
- Node-pushnotifications: Mobile push (APNs, FCM, etc.)
Providers use dependency injection for testability:
class SendGridProvider implements Provider<EmailRequest> {
constructor(
private config: EmailProviderSendGrid,
private httpClient = fetch // Injectable for testing
) {}
async send(request: EmailRequest): Promise<string> {
const response = await this.httpClient(...)
return response.messageId
}
}The SDK is structured for tree shaking:
// Only import what you need
import CommsSdk from '@webventures/comms'
import { createNextJSComms } from '@webventures/comms/adapters'
// Unused providers and strategies are not bundledProviders are only created when needed:
const comms = new CommsSdk(config) // Providers created here
// Provider instances cached in registry
await comms.send(request) // Uses cached instancesSender functions are memoized per channel:
class Sender {
private senders: Record<ChannelType, SenderFunction>
constructor(...) {
// Pre-compute senders once during initialization
this.senders = Object.keys(strategies).reduce((acc, channel) => {
acc[channel] = strategies[channel](providers[channel])
return acc
}, {})
}
}Channels are processed in parallel:
async sendOnEachChannel(request): Promise<ChannelSendResult[]> {
return Promise.all(
channels.map(async (channel) => {
// Send on each channel in parallel
})
)
}SMTP providers support connection pooling:
{
type: 'smtp',
pool: true,
maxConnections: 5,
maxMessages: 100
}- Never log API keys
- Support for environment variables
- Validation of required credentials
- Type checking via TypeScript
- Runtime validation of critical fields
- Sanitization of user inputs
- SMTP secure connections
- HTTPS for API calls
- Certificate validation
- Avoid exposing sensitive data in errors
- Sanitize error messages for client consumption
- Detailed logging server-side only
// Mock providers
const mockProvider: Provider<EmailRequest> = {
id: 'mock-provider',
send: vi.fn().mockResolvedValue('message-id'),
}
// Test strategy
const result = await strategyFallback([mockProvider])(request)
expect(mockProvider.send).toHaveBeenCalledWith(request)// Use notification catcher
const comms = new CommsSdk({
useNotificationCatcher: true
})
// Verify notification in catcher
const result = await comms.send({...})
expect(result.status).toBe('success')- Plugin System: Allow third-party plugins for providers and strategies
- Event System: Emit events for monitoring and debugging
- Middleware: Support for request/response middleware
- Batch Operations: Efficient bulk notification sending
- Webhooks: Built-in webhook handling for delivery status
The architecture supports horizontal scaling:
- Stateless design (no shared state between instances)
- Connection pooling for efficiency
- Queue integration support (Bull, RabbitMQ)
- Distributed tracing ready (via logging hooks)