terfaces. Implementation details are injected via dependency injection or strategy patterns based on the resolved matrix configuration.
4. Compile-Time Safety: Using TypeScript generics and Zod validation ensures that configuration errors are caught during development, not in production.
Step-by-Step Implementation
1. Define the Matrix Schema
Create a strict schema that defines what constitutes a product variant. This includes branding, feature toggles, pricing limits, and integration endpoints.
// src/matrix/schema.ts
import { z } from 'zod';
export const FeatureFlagSchema = z.record(z.boolean());
export const VariantConfigSchema = z.object({
id: z.string(),
name: z.string(),
tier: z.enum(['free', 'pro', 'enterprise']),
features: FeatureFlagSchema,
limits: z.object({
maxUsers: z.number(),
maxStorageMB: z.number(),
apiRateLimit: z.number(),
}),
theme: z.object({
primaryColor: z.string(),
logoUrl: z.string(),
}),
integrations: z.object({
stripePlanId: z.string(),
webhookUrl: z.string().url(),
}),
});
export type VariantConfig = z.infer<typeof VariantConfigSchema>;
export type ProductMatrix = Record<string, VariantConfig>;
2. Implement the Resolver
The resolver determines which variant is active. In a SaaS context, this often depends on the tenant ID or user subscription.
// src/matrix/resolver.ts
import { VariantConfig, ProductMatrix } from './schema';
export class MatrixResolver {
private matrix: ProductMatrix;
constructor(matrix: ProductMatrix) {
this.matrix = matrix;
}
resolve(context: { tenantId?: string; tier?: string }): VariantConfig {
if (context.tenantId) {
const variant = this.matrix[context.tenantId];
if (!variant) {
// Fallback to default or throw error based on strategy
return this.matrix['default'] || this.getDefaultConfig();
}
return variant;
}
if (context.tier) {
// Find first variant matching tier or return default
const match = Object.values(this.matrix).find(v => v.tier === context.tier);
return match || this.getDefaultConfig();
}
return this.getDefaultConfig();
}
private getDefaultConfig(): VariantConfig {
return this.matrix['default'] || {
id: 'default',
name: 'Default',
tier: 'free',
features: {},
limits: { maxUsers: 1, maxStorageMB: 100, apiRateLimit: 60 },
theme: { primaryColor: '#000000', logoUrl: '' },
integrations: { stripePlanId: '', webhookUrl: '' },
};
}
}
3. Integrate with Business Logic
Refactor services to consume the matrix configuration rather than hardcoded values.
// src/services/storage.service.ts
import { VariantConfig } from '../matrix/schema';
export class StorageService {
constructor(private config: VariantConfig) {}
async upload(file: Buffer): Promise<string> {
const currentUsage = await this.getCurrentUsage();
const limit = this.config.limits.maxStorageMB * 1024 * 1024;
if (currentUsage + file.length > limit) {
throw new Error(`Storage limit exceeded for ${this.config.tier} tier.`);
}
// Upload logic...
return 'file-url';
}
private async getCurrentUsage(): Promise<number> {
// Fetch usage from DB
return 0;
}
}
4. Build-Time Variant Generation (Optional)
For static sites or highly optimized builds, use a generator to create variant-specific bundles.
// tools/generate-variants.ts
import { ProductMatrix } from '../src/matrix/schema';
import matrix from '../config/matrix.json';
import fs from 'fs';
const outputDir = './dist/variants';
Object.entries(matrix).forEach(([key, config]) => {
const variantConfig = {
...config,
buildId: Date.now().toString(36),
};
fs.writeFileSync(
`${outputDir}/${key}.env.json`,
JSON.stringify(variantConfig, null, 2)
);
console.log(`Generated config for variant: ${key}`);
});
Pitfall Guide
- Over-Engineering Single Products: Applying a matrix architecture to a single-product application adds unnecessary complexity. Only implement this when managing two or more variants, tiers, or white-label instances.
- Hardcoding Logic in Resolvers: The resolver should only return configuration. Never embed business logic, such as "if tier is pro, do X," inside the resolver. Business logic must live in services that consume the config.
- Ignoring Runtime Performance: Deep configuration lookups in hot paths can degrade performance. Cache resolved configurations at the request or session level. Avoid resolving the matrix on every database query.
- Lack of Type Safety: Using plain JSON without validation risks runtime crashes due to missing fields. Always use a schema validator like Zod or Yup to parse matrix configurations, especially when configs are loaded from external sources.
- Matrix Sprawl: As variants increase, the matrix file can become unmanageable. Use composition and inheritance patterns. Define a
base config and have variants extend it to avoid duplication of common settings.
- Missing Fallback Strategies: If a variant config is missing a field, the system should fail gracefully. Define strict defaults and ensure the resolver handles undefined keys without throwing unhandled exceptions.
- Data Silos: Ensure your database schema supports the matrix. If variants require different data structures, use a flexible schema design (e.g., JSONB columns in Postgres) rather than creating separate tables for each variant, which complicates analytics and migrations.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| SaaS with Tiered Features | Runtime Resolution | Allows dynamic switching and granular control per user. | Low infrastructure, higher dev time. |
| White-Label Agency Tools | Build-Time Generation | Optimizes bundle size and security per client. | Low runtime cost, higher build complexity. |
| Portfolio of Micro-Tools | Monorepo with Shared Core | Reuses logic while keeping deployment independent. | Moderate setup, high long-term efficiency. |
| A/B Testing Variants | Feature Flags Matrix | Enables rapid experimentation without code deploys. | Low risk, requires robust flag management. |
Configuration Template
Copy this structure to initialize your product matrix. Save as matrix.config.ts.
import { ProductMatrix } from './src/matrix/schema';
export const matrix: ProductMatrix = {
default: {
id: 'default',
name: 'Starter',
tier: 'free',
features: {
advancedAnalytics: false,
customDomain: false,
prioritySupport: false,
},
limits: {
maxUsers: 3,
maxStorageMB: 500,
apiRateLimit: 100,
},
theme: {
primaryColor: '#3b82f6',
logoUrl: '/assets/logo-default.svg',
},
integrations: {
stripePlanId: 'price_starter',
webhookUrl: 'https://api.yourdomain.com/webhooks/starter',
},
},
pro: {
id: 'pro',
name: 'Professional',
tier: 'pro',
features: {
advancedAnalytics: true,
customDomain: true,
prioritySupport: false,
},
limits: {
maxUsers: 25,
maxStorageMB: 10240,
apiRateLimit: 1000,
},
theme: {
primaryColor: '#8b5cf6',
logoUrl: '/assets/logo-pro.svg',
},
integrations: {
stripePlanId: 'price_pro',
webhookUrl: 'https://api.yourdomain.com/webhooks/pro',
},
},
enterprise: {
id: 'enterprise',
name: 'Enterprise',
tier: 'enterprise',
features: {
advancedAnalytics: true,
customDomain: true,
prioritySupport: true,
sso: true,
},
limits: {
maxUsers: -1, // Unlimited
maxStorageMB: -1,
apiRateLimit: -1,
},
theme: {
primaryColor: '#1e293b',
logoUrl: '/assets/logo-enterprise.svg',
},
integrations: {
stripePlanId: 'price_enterprise',
webhookUrl: 'https://api.yourdomain.com/webhooks/enterprise',
},
},
};
Quick Start Guide
- Initialize Project: Create a new TypeScript project and install dependencies:
npm install zod.
- Setup Schema: Create
src/matrix/schema.ts and paste the schema definition from the Core Solution.
- Create Config: Add
matrix.config.ts with the template above, adjusting values for your needs.
- Wire Resolver: Import
MatrixResolver and the matrix config in your app entry point. Instantiate the resolver and pass it to your services.
- Validate: Run a test script that resolves a variant and checks a limit. Verify that type errors are caught if you introduce a typo in the config.
This architecture transforms product management into a scalable engineering discipline. By treating your product portfolio as a matrix, you reclaim development velocity, reduce maintenance overhead, and position your indie operation for sustainable growth.