TypeScript Design Patterns I Use Daily at SAP
- Published on
- • 8 mins read•--- views
Introduction
After completing the TypeScript Design Patterns certification and applying these concepts in production at SAP, I've learned which patterns actually matter in real-world applications.
This isn't an exhaustive catalog - it's the patterns I use almost daily when building cloud microservices.
1. Repository Pattern
Problem: Direct database access scattered throughout your code makes testing and swapping data sources difficult.
Solution: Abstract data access behind an interface.
// Domain model
interface User {
id: string;
email: string;
name: string;
createdAt: Date;
}
// Repository interface
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(user: Omit<User, 'id' | 'createdAt'>): Promise<User>;
update(id: string, data: Partial<User>): Promise<User>;
delete(id: string): Promise<void>;
}
// Concrete implementation
class PostgresUserRepository implements UserRepository {
constructor(private db: DatabaseClient) {}
async findById(id: string): Promise<User | null> {
const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return result.rows[0] || null;
}
async create(userData: Omit<User, 'id' | 'createdAt'>): Promise<User> {
const result = await this.db.query('INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *', [
userData.email,
userData.name,
]);
return result.rows[0];
}
// ... other methods
}
Why it's useful:
- Easy to mock for testing
- Can swap PostgreSQL for MongoDB without changing business logic
- Centralizes data access patterns
2. Factory Pattern
Problem: Complex object creation logic spreads across your codebase.
Solution: Centralize creation logic in factory functions/classes.
// Different service configurations for different environments
interface ServiceConfig {
timeout: number;
retries: number;
baseURL: string;
auth: AuthConfig;
}
class ServiceFactory {
static createForEnvironment(env: 'dev' | 'staging' | 'prod'): APIService {
const config = this.getConfig(env);
const logger = this.createLogger(env);
const metrics = this.createMetrics(env);
return new APIService(config, logger, metrics);
}
private static getConfig(env: string): ServiceConfig {
switch (env) {
case 'dev':
return {
timeout: 5000,
retries: 1,
baseURL: 'http://localhost:3000',
auth: { type: 'none' },
};
case 'prod':
return {
timeout: 3000,
retries: 3,
baseURL: process.env.API_URL!,
auth: {
type: 'oauth',
tokenUrl: process.env.TOKEN_URL!,
},
};
default:
throw new Error(`Unknown environment: ${env}`);
}
}
// ... helper methods
}
// Usage
const service = ServiceFactory.createForEnvironment(process.env.NODE_ENV);
Why it's useful:
- Environment-specific configuration in one place
- Easier testing with dev/test factories
- Consistent object initialization
3. Strategy Pattern
Problem: Different algorithms for the same operation, selected at runtime.
Solution: Define a family of algorithms and make them interchangeable.
// Strategy interface
interface PricingStrategy {
calculatePrice(basePrice: number, quantity: number): number;
}
// Concrete strategies
class StandardPricing implements PricingStrategy {
calculatePrice(basePrice: number, quantity: number): number {
return basePrice * quantity;
}
}
class BulkPricing implements PricingStrategy {
constructor(
private discountThreshold: number,
private discount: number
) {}
calculatePrice(basePrice: number, quantity: number): number {
const total = basePrice * quantity;
if (quantity >= this.discountThreshold) {
return total * (1 - this.discount);
}
return total;
}
}
class SeasonalPricing implements PricingStrategy {
constructor(private seasonMultiplier: number) {}
calculatePrice(basePrice: number, quantity: number): number {
return basePrice * quantity * this.seasonMultiplier;
}
}
// Context
class ShoppingCart {
constructor(private pricingStrategy: PricingStrategy) {}
setPricingStrategy(strategy: PricingStrategy): void {
this.pricingStrategy = strategy;
}
calculateTotal(items: Array<{ price: number; quantity: number }>): number {
return items.reduce((total, item) => total + this.pricingStrategy.calculatePrice(item.price, item.quantity), 0);
}
}
// Usage
const cart = new ShoppingCart(new StandardPricing());
const total = cart.calculateTotal(items);
// Switch to bulk pricing for large orders
if (totalItems > 100) {
cart.setPricingStrategy(new BulkPricing(50, 0.1));
}
Why it's useful:
- Easy to add new pricing models
- Testable in isolation
- Runtime behavior change without conditionals
4. Decorator Pattern
Problem: You need to add behavior to objects without modifying their class.
Solution: Wrap objects in decorator classes that add functionality.
// Base interface
interface Logger {
log(message: string): void;
}
// Base implementation
class BasicLogger implements Logger {
log(message: string): void {
console.log(message);
}
}
// Decorators
class TimestampLogger implements Logger {
constructor(private logger: Logger) {}
log(message: string): void {
const timestamp = new Date().toISOString();
this.logger.log(`[${timestamp}] ${message}`);
}
}
class ErrorLogger implements Logger {
constructor(
private logger: Logger,
private errorTracker: ErrorTrackingService
) {}
log(message: string): void {
this.logger.log(message);
// If message contains error keywords, send to error tracker
if (message.toLowerCase().includes('error')) {
this.errorTracker.track(message);
}
}
}
class MetricsLogger implements Logger {
constructor(
private logger: Logger,
private metrics: MetricsService
) {}
log(message: string): void {
this.logger.log(message);
this.metrics.increment('logs.count');
}
}
// Usage - stack decorators
const logger = new MetricsLogger(new ErrorLogger(new TimestampLogger(new BasicLogger()), errorTracker), metrics);
logger.log('User logged in'); // Logged with timestamp, counted in metrics
logger.log('Database error'); // Also sent to error tracker
Why it's useful:
- Add functionality without changing existing code
- Compose behaviors dynamically
- Each decorator has single responsibility
5. Singleton Pattern (with caveats)
Problem: You need exactly one instance of a class (database connection, config).
Solution: Ensure a class has only one instance and provide global access.
class DatabaseConnection {
private static instance: DatabaseConnection;
private connection: Connection;
private constructor() {
// Private constructor prevents direct instantiation
this.connection = this.createConnection();
}
static getInstance(): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
private createConnection(): Connection {
return {
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT!),
// ... connection setup
};
}
query(sql: string, params: any[]): Promise<any> {
return this.connection.query(sql, params);
}
}
// Usage
const db = DatabaseConnection.getInstance();
Caveat: Singletons make testing harder. Better approach:
// Better: Use dependency injection
class UserService {
constructor(private db: DatabaseConnection) {}
// ... methods
}
// In production
const db = DatabaseConnection.getInstance();
const userService = new UserService(db);
// In tests
const mockDb = new MockDatabase();
const userService = new UserService(mockDb);
6. Observer Pattern
Problem: Multiple parts of your application need to react to state changes.
Solution: Define a subscription mechanism.
type EventCallback<T = any> = (data: T) => void;
class EventEmitter {
private events: Map<string, Set<EventCallback>> = new Map();
on(event: string, callback: EventCallback): void {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event)!.add(callback);
}
off(event: string, callback: EventCallback): void {
this.events.get(event)?.delete(callback);
}
emit(event: string, data: any): void {
this.events.get(event)?.forEach((callback) => callback(data));
}
}
// Real-world usage
class OrderService extends EventEmitter {
async createOrder(orderData: CreateOrderDTO): Promise<Order> {
const order = await this.repository.create(orderData);
// Emit event - other services can react
this.emit('order:created', order);
return order;
}
}
// Subscribers
const orderService = new OrderService();
orderService.on('order:created', (order) => {
emailService.sendConfirmation(order);
});
orderService.on('order:created', (order) => {
inventoryService.reserveStock(order.items);
});
orderService.on('order:created', (order) => {
analyticsService.trackPurchase(order);
});
Why it's useful:
- Decouples event producers from consumers
- Easy to add new reactions to events
- Core service doesn't need to know about email/inventory/analytics
7. Builder Pattern
Problem: Objects with many optional parameters lead to messy constructors.
Solution: Build complex objects step by step.
interface EmailOptions {
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
body: string;
attachments?: Attachment[];
priority?: 'low' | 'normal' | 'high';
replyTo?: string;
}
class EmailBuilder {
private options: Partial<EmailOptions> = {};
to(addresses: string | string[]): this {
this.options.to = Array.isArray(addresses) ? addresses : [addresses];
return this;
}
cc(addresses: string | string[]): this {
this.options.cc = Array.isArray(addresses) ? addresses : [addresses];
return this;
}
subject(subject: string): this {
this.options.subject = subject;
return this;
}
body(body: string): this {
this.options.body = body;
return this;
}
attach(attachment: Attachment): this {
if (!this.options.attachments) {
this.options.attachments = [];
}
this.options.attachments.push(attachment);
return this;
}
priority(level: 'low' | 'normal' | 'high'): this {
this.options.priority = level;
return this;
}
build(): EmailOptions {
if (!this.options.to || !this.options.subject || !this.options.body) {
throw new Error('Required fields missing');
}
return this.options as EmailOptions;
}
}
// Usage - much cleaner than constructor with 8 parameters
const email = new EmailBuilder()
.to(['user@example.com'])
.cc(['manager@example.com'])
.subject('Your order has shipped')
.body('Your order #12345 is on the way')
.priority('high')
.attach(trackingPDF)
.build();
Why it's useful:
- Fluent, readable API
- Enforces required fields at build time
- Easy to add new optional parameters
8. Result Pattern (for Error Handling)
Problem: Exceptions make control flow hard to follow and errors easy to miss.
Solution: Explicitly return success or failure.
type Result<T, E = Error> = { success: true; value: T } | { success: false; error: E };
class UserService {
async findUser(id: string): Promise<Result<User, string>> {
try {
const user = await this.repository.findById(id);
if (!user) {
return {
success: false,
error: `User ${id} not found`,
};
}
return {
success: true,
value: user,
};
} catch (error) {
return {
success: false,
error: 'Database error',
};
}
}
}
// Usage - explicit error handling
const result = await userService.findUser('123');
if (result.success) {
console.log(result.value.name);
} else {
console.error(result.error);
}
Why it's useful:
- Forces error handling at compile time
- No surprises from uncaught exceptions
- Clear success/failure paths
Conclusion
These patterns aren't academic exercises - they solve real problems I face daily:
- Repository - abstracts data access
- Factory - manages complex creation
- Strategy - swaps algorithms
- Decorator - adds behavior flexibly
- Singleton - controls instantiation
- Observer - decouples events
- Builder - constructs complex objects
- Result - handles errors explicitly
The key is knowing when to use them. Over-engineering is real - start simple and refactor to patterns when complexity justifies it.
What design patterns do you use most? Any I missed that you find essential? Let's discuss on LinkedIn!