Loading content...
Consider how complex physical systems are built. An airplane isn't manufactured as a single piece—it's assembled from fuselage sections, wings, engines, landing gear, avionics systems, and thousands of smaller components. Each component is itself assembled from simpler parts. This hierarchical construction allows specialization, parallel manufacturing, quality testing at multiple levels, and the ability to upgrade individual subsystems without rebuilding the entire aircraft.
Software composition follows the same principle. Complex objects are constructed by combining simpler objects, which are themselves constructed from even simpler objects. This hierarchical assembly enables the same benefits: specialization, parallel development, isolated testing, and incremental evolution.
By the end of this page, you will master the techniques for building complex objects from simple ones—including compositional hierarchies, the Builder pattern, fluent construction interfaces, and factory-based assembly. You'll understand how to design objects that are easy to construct, test, and evolve.
At its core, compositional construction is based on a simple but powerful principle:
The key insight: Instead of building one monolithic object that understands everything, build a network of focused objects that collaborate. Each object is simple enough to understand, test, and maintain individually—complexity emerges from composition, not from bloated classes.
Let's contrast the approaches:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// MONOLITHIC: One massive class doing everythingclass MonolithicECommerceSystem { // 2000+ lines handling: // - User management // - Product catalog // - Shopping cart // - Checkout // - Payment processing // - Order fulfillment // - Notifications // - Analytics // - Reporting // All tightly coupled, impossible to test in isolation} // COMPOSITIONAL: Focused components assembled togetherclass ECommerceSystem { private users: UserService; private catalog: ProductCatalog; private cart: ShoppingCart; private checkout: CheckoutService; private payments: PaymentGateway; private fulfillment: FulfillmentService; private notifications: NotificationService; private analytics: AnalyticsTracker; constructor(/* all components injected */) { // Each component ~100-200 lines, single responsibility // Easy to understand, test, replace, or extend individually } // Methods coordinate between components async purchaseItem(userId: string, productId: string): Promise<OrderConfirmation> { const user = await this.users.getById(userId); const product = await this.catalog.getById(productId); await this.cart.addItem(user.cartId, product); const order = await this.checkout.process(user.cartId); await this.payments.charge(user, order.total); await this.fulfillment.scheduleShipment(order); await this.notifications.sendConfirmation(user, order); this.analytics.trackPurchase(user, order); return order.confirmation; }}Real-world composites aren't flat—they're hierarchical. A component at one level can itself be a composite of smaller components. This nesting creates a tree structure of composition, where complexity is managed at each level.
Consider a typical web application structure:
12345678910111213141516171819202122232425262728293031323334353637
// Level 1: Top-level Applicationclass Application { private server: HttpServer; private apiLayer: ApiLayer; private services: ServiceLayer; private dataSources: DataLayer;} // Level 2: Each layer is itself a compositeclass ApiLayer { private router: Router; private middleware: MiddlewareStack; private controllers: Map<string, Controller>; private validators: RequestValidators;} class ServiceLayer { private userService: UserService; private orderService: OrderService; private paymentService: PaymentService; private inventoryService: InventoryService;} // Level 3: Services contain their own collaboratorsclass OrderService { private repository: OrderRepository; private eventPublisher: EventPublisher; private priceCalculator: PriceCalculator; private discountEngine: DiscountEngine;} // Level 4: Even these have internal structureclass PriceCalculator { private taxCalculator: TaxCalculator; private shippingCalculator: ShippingCalculator; private currencyConverter: CurrencyConverter;}The benefits of hierarchical composition:
| Benefit | Description |
|---|---|
| Abstraction at each level | Each layer hides the complexity of its components. Application knows it has an ApiLayer, not that ApiLayer has routers, middleware, controllers, etc. |
| Manageable cognitive load | Developers only need to understand the level they're working at. Modifying the TaxCalculator doesn't require understanding the entire application. |
| Isolated testing | Each level can be tested independently. Unit test TaxCalculator, integration test PriceCalculator, system test OrderService. |
| Parallel development | Different teams can work on different branches of the hierarchy simultaneously. |
| Incremental replacement | Replace a subtree without affecting siblings. Swap PaymentService implementations without touching OrderService. |
The Composite design pattern formalizes one aspect of hierarchical composition: when individual objects and compositions of objects should be treated uniformly. A FileSystemEntry might be a File (leaf) or a Directory (composite containing other entries). Both implement the same interface, allowing recursive operations.
When building complex composite objects, you can approach construction from two directions:
Bottom-Up Example:
1234567891011121314151617181920212223
// Bottom-Up: Start with primitives, compose upward // Step 1: Build primitive componentsconst taxCalc = new TaxCalculator(taxRates);const shipCalc = new ShippingCalculator(shippingRules);const currencyConv = new CurrencyConverter(exchangeRates); // Step 2: Compose into mid-level componentsconst priceCalc = new PriceCalculator(taxCalc, shipCalc, currencyConv);const discountEngine = new DiscountEngine(promoRules); // Step 3: Compose into higher-level componentsconst orderService = new OrderService( orderRepo, priceCalc, discountEngine, eventPublisher); // Step 4: Compose into top-levelconst apiLayer = new ApiLayer(router, middleware, { orders: orderController });const serviceLayer = new ServiceLayer(orderService, userService, paymentService);const app = new Application(httpServer, apiLayer, serviceLayer, dataLayer);Top-Down Example:
1234567891011121314151617181920212223242526272829
// Top-Down: Start with structure, define components as needed // Step 1: Define the high-level structure (may use interfaces initially)interface IOrderService { createOrder(cart: Cart): Promise<Order>; getOrder(id: string): Promise<Order>;} class Application { constructor( private orderService: IOrderService, private userService: IUserService, // ... other high-level dependencies ) {}} // Step 2: Implement high-level components, discover sub-needsclass OrderService implements IOrderService { constructor( private priceCalculator: IPriceCalculator, // Need this! private discountEngine: IDiscountEngine, // And this! // ... ) {}} // Step 3: Implement sub-components as their responsibilities become clearclass PriceCalculator implements IPriceCalculator { // Implementation driven by OrderService's needs}Real projects often use both approaches. Top-down thinking helps you understand what you need to build; bottom-up building lets you create reusable, tested components. Experienced engineers mentally sketch the hierarchy (top-down) while building components that can stand alone (bottom-up).
When an object has many components—especially when some are optional or there are multiple valid configurations—constructor parameters become unwieldy. The Builder pattern solves this by separating construction from representation, allowing step-by-step object construction with a fluent interface.
1234567891011121314151617181920212223242526
// The problem: Constructor with many parametersclass HttpClientBadConstructor { constructor( baseUrl: string, timeout: number, retries: number, retryDelay: number, maxConnections: number, headers: Record<string, string>, interceptors: RequestInterceptor[], logger: Logger | null, cache: CacheService | null, metrics: MetricsCollector | null, certificatePath: string | null, proxy: ProxyConfig | null ) { // 12 parameters is unreadable, error-prone }} // Which of these is the retry delay? Which is maxConnections?const client = new HttpClientBadConstructor( 'https://api.example.com', 5000, 3, 1000, 10, // What do these numbers mean? {}, [], null, null, null, null, null);The Builder solution:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
// Builder pattern: Step-by-step construction with clarityclass HttpClientBuilder { private baseUrl: string = ''; private timeout: number = 30000; private retries: number = 0; private retryDelay: number = 1000; private maxConnections: number = 5; private headers: Record<string, string> = {}; private interceptors: RequestInterceptor[] = []; private logger: Logger | null = null; private cache: CacheService | null = null; private metrics: MetricsCollector | null = null; private certificate: string | null = null; private proxy: ProxyConfig | null = null; // Required configuration setBaseUrl(url: string): this { this.baseUrl = url; return this; } // Optional configuration with descriptive names withTimeout(milliseconds: number): this { this.timeout = milliseconds; return this; } withRetries(count: number, delayMs: number = 1000): this { this.retries = count; this.retryDelay = delayMs; return this; } withMaxConnections(count: number): this { this.maxConnections = count; return this; } withHeaders(headers: Record<string, string>): this { this.headers = { ...this.headers, ...headers }; return this; } addInterceptor(interceptor: RequestInterceptor): this { this.interceptors.push(interceptor); return this; } withLogging(logger: Logger): this { this.logger = logger; return this; } withCaching(cache: CacheService): this { this.cache = cache; return this; } withMetrics(metrics: MetricsCollector): this { this.metrics = metrics; return this; } withCertificate(path: string): this { this.certificate = path; return this; } withProxy(config: ProxyConfig): this { this.proxy = config; return this; } // Final construction with validation build(): HttpClient { if (!this.baseUrl) { throw new Error('Base URL is required'); } // Compose the final object from accumulated configuration return new HttpClient( new ConnectionPool(this.maxConnections), new RetryHandler(this.retries, this.retryDelay), new RequestPipeline(this.interceptors), { baseUrl: this.baseUrl, timeout: this.timeout, headers: this.headers, logger: this.logger, cache: this.cache, metrics: this.metrics, certificate: this.certificate, proxy: this.proxy } ); }} // Usage: Clear, self-documenting, impossible to get wrongconst client = new HttpClientBuilder() .setBaseUrl('https://api.example.com') .withTimeout(5000) .withRetries(3, 1000) .withMaxConnections(10) .withHeaders({ 'Authorization': 'Bearer token' }) .addInterceptor(loggingInterceptor) .withLogging(console) .withCaching(redisCache) .build();Builders make construction readable (method names self-document), safe (required vs optional is clear), flexible (different configurations from same builder), and testable (you can build partially configured objects for tests). Use builders when an object has 4+ parameters, especially with optional ones.
When objects have complex construction logic—creating components, wiring them together, configuring based on environment—factory methods and classes centralize this logic, preventing it from spreading throughout the codebase.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Factory class: Centralized construction logicclass ApplicationFactory { private config: ApplicationConfig; constructor(config: ApplicationConfig) { this.config = config; } // Build the entire application with proper wiring createApplication(): Application { // Create data layer components const database = this.createDatabase(); const cache = this.createCache(); const dataLayer = new DataLayer(database, cache); // Create service layer components const services = this.createServices(dataLayer); // Create API layer components const apiLayer = this.createApiLayer(services); // Create server const server = this.createServer(apiLayer); return new Application(server, apiLayer, services, dataLayer); } private createDatabase(): Database { switch (this.config.databaseType) { case 'postgres': return new PostgresDatabase(this.config.postgresConfig); case 'mysql': return new MySqlDatabase(this.config.mysqlConfig); case 'memory': return new InMemoryDatabase(); // For testing default: throw new Error(`Unknown database type: ${this.config.databaseType}`); } } private createCache(): CacheService { if (!this.config.cacheEnabled) { return new NoOpCache(); } return new RedisCache(this.config.redisConfig); } private createServices(dataLayer: DataLayer): ServiceLayer { const userService = new UserService(dataLayer.userRepository); const orderService = new OrderService( dataLayer.orderRepository, this.createPriceCalculator(), this.createPaymentGateway() ); return new ServiceLayer(userService, orderService); } private createPriceCalculator(): PriceCalculator { return new PriceCalculator( new TaxCalculator(this.config.taxRates), new ShippingCalculator(this.config.shippingRules) ); } private createPaymentGateway(): PaymentGateway { switch (this.config.paymentProvider) { case 'stripe': return new StripeGateway(this.config.stripeConfig); case 'paypal': return new PayPalGateway(this.config.paypalConfig); case 'mock': return new MockPaymentGateway(); default: throw new Error(`Unknown payment provider: ${this.config.paymentProvider}`); } } // ... more private factory methods} // Usage: Clean application bootstrapconst config = loadConfig(process.env.NODE_ENV);const factory = new ApplicationFactory(config);const app = factory.createApplication();await app.start();Factory patterns for different scenarios:
| Pattern | Use Case | Example |
|---|---|---|
| Factory Method | Single product type, variations based on input | createLogger(type): Logger |
| Abstract Factory | Family of related products that work together | UiFactory.createButton(), createDialog(), etc. |
| Factory Class | Complex wiring of many components | The ApplicationFactory above |
| DI Container | Framework-managed dependency injection | Spring, Nest.js, Angular DI |
Builders and factories are complementary. Builders configure a single complex object step-by-step. Factories create and wire together multiple objects (potentially using builders for individual objects). A factory might use builders internally for complex component construction.
When applications grow large, manually wiring together hundreds of components becomes tedious and error-prone. Dependency Injection (DI) containers automate this process—you declare what each class needs, and the container figures out how to construct and wire everything together.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// Without DI container: Manual wiring (tedious at scale)const logger = new ConsoleLogger();const database = new PostgresDatabase(dbConfig);const userRepo = new UserRepository(database, logger);const orderRepo = new OrderRepository(database, logger);const taxCalc = new TaxCalculator(taxConfig);const shipCalc = new ShippingCalculator(shippingConfig);const priceCalc = new PriceCalculator(taxCalc, shipCalc);const paymentGateway = new StripeGateway(stripeConfig);const userService = new UserService(userRepo, logger);const orderService = new OrderService(orderRepo, priceCalc, paymentGateway, logger);const userController = new UserController(userService, logger);const orderController = new OrderController(orderService, logger);const app = new Application(userController, orderController, logger); // With DI container (conceptual, syntax varies by framework)// Just declare dependencies, container wires automatically @Injectable()class UserRepository { constructor( @Inject(Database) private database: Database, @Inject(Logger) private logger: Logger ) {}} @Injectable()class UserService { constructor( @Inject(UserRepository) private repo: UserRepository, @Inject(Logger) private logger: Logger ) {}} @Injectable()class UserController { constructor( @Inject(UserService) private service: UserService ) {}} // Container configurationcontainer.register(Logger, ConsoleLogger);container.register(Database, PostgresDatabase);// ... register all implementations // Container creates entire object graph automaticallyconst app = container.resolve(Application);How DI containers work:
Registration: You tell the container how to create each dependency (which concrete class implements each interface)
Resolution: When you request an object, the container examines its dependencies, recursively resolves each one, creates instances, and wires everything together
Lifecycle Management: Containers can manage singleton vs per-request instances, disposing of resources when appropriate
DI containers are powerful but add complexity. They can make debugging harder (construction happens "magically"), create performance overhead, and require learning curved syntax. Use them when manual wiring becomes burdensome (50+ components), but understand that simple dependency injection (constructor injection) works without any framework.
One of composition's greatest practical benefits is testability. Because components are injected, you can substitute test doubles (mocks, stubs, fakes) to test each level of the composition hierarchy in isolation.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// Testing a composite with mock components class OrderService { constructor( private priceCalculator: PriceCalculator, private paymentGateway: PaymentGateway, private inventory: InventoryService ) {} async createOrder(cart: Cart): Promise<Order> { const total = this.priceCalculator.calculateTotal(cart); await this.inventory.reserve(cart.items); await this.paymentGateway.charge(cart.userId, total); return new Order(cart.items, total); }} // Unit testing OrderService with mock componentsdescribe('OrderService', () => { let orderService: OrderService; let mockPriceCalc: jest.Mocked<PriceCalculator>; let mockPayment: jest.Mocked<PaymentGateway>; let mockInventory: jest.Mocked<InventoryService>; beforeEach(() => { // Create mock components mockPriceCalc = { calculateTotal: jest.fn().mockReturnValue(100) } as any; mockPayment = { charge: jest.fn().mockResolvedValue({ success: true }) } as any; mockInventory = { reserve: jest.fn().mockResolvedValue(true) } as any; // Compose with mocks orderService = new OrderService( mockPriceCalc, mockPayment, mockInventory ); }); it('should calculate price, reserve inventory, then charge', async () => { const cart = { userId: 'user1', items: [{ productId: 'p1', qty: 2 }] }; await orderService.createOrder(cart); // Verify correct component interactions expect(mockPriceCalc.calculateTotal).toHaveBeenCalledWith(cart); expect(mockInventory.reserve).toHaveBeenCalledWith(cart.items); expect(mockPayment.charge).toHaveBeenCalledWith('user1', 100); }); it('should not charge if inventory reservation fails', async () => { mockInventory.reserve.mockRejectedValue(new Error('Out of stock')); const cart = { userId: 'user1', items: [{ productId: 'p1', qty: 2 }] }; await expect(orderService.createOrder(cart)).rejects.toThrow('Out of stock'); expect(mockPayment.charge).not.toHaveBeenCalled(); });});We've explored the art and science of building complex objects from simple components. Let's consolidate the key insights:
What's next:
With construction techniques covered, we'll examine composition from a conceptual angle—understanding composition as assembly. This perspective views object composition as the assembly of interchangeable parts, drawing lessons from manufacturing and exploring how this metaphor guides design decisions.
You now understand how to build complex objects from simple components—using hierarchical composition, builders, factories, and dependency injection. These techniques let you construct systems that are both sophisticated in capability and manageable in complexity. Next, we'll explore the assembly metaphor for understanding composition.