Loading content...
In the previous page, we established that composition is about building complex objects from simpler ones. But how exactly does this work in practice? How do objects "contain" other objects? What are the patterns for creating, connecting, and managing these relationships?
This page answers these questions by examining the structural mechanics of composition. We'll explore the various ways containers hold references to components, the patterns for establishing these relationships, and the design considerations that lead to robust, flexible compositions.
By the end of this page, you will understand the different structural patterns for composing objects—including direct containment, dependency injection, and factory-based composition. You'll learn how to design the "wiring" between containers and components, and you'll understand the lifecycle implications of each approach.
When we say an object "contains" another object, we're making a statement about reference relationships in memory. Let's be precise about what this means:
Key clarifications:
Containment is reference-based, not physical embedding. In most object-oriented languages, when we say A contains B, A actually holds a reference (pointer) to B, not a literal copy embedded within A's memory. Both A and B are separate objects in memory, linked by a reference.
Containment can be singular or plural. A container may hold a single reference to one component, or it may hold a collection of references to multiple components of the same type.
References can be mandatory or optional. Sometimes a container must always have a certain component (a Car must have an Engine). Sometimes the component is optional (an Order may or may not have a DiscountCode).
References can be stable or mutable. Some compositions are fixed at construction time (the engine is never changed). Others allow replacement (the phone's battery can be swapped).
123456789101112131415161718192021222324252627282930313233343536
// Illustrating different containment patterns class Order { // Singular, mandatory containment: every order has exactly one customer private customer: Customer; // Plural containment: an order has zero or more line items private lineItems: LineItem[] = []; // Singular, optional containment: an order may or may not have a discount private discount: Discount | null = null; // Mutable containment: shipping address can be changed private shippingAddress: Address; constructor(customer: Customer, shippingAddress: Address) { // Mandatory references established at construction this.customer = customer; this.shippingAddress = shippingAddress; } addLineItem(item: LineItem): void { // Plural containment grows over time this.lineItems.push(item); } applyDiscount(discount: Discount): void { // Optional containment added later this.discount = discount; } updateShippingAddress(newAddress: Address): void { // Mutable containment allows replacement this.shippingAddress = newAddress; }}In languages like Java, TypeScript, JavaScript, Python, and C#, object references are essentially pointers to heap-allocated objects. When Order holds a Customer reference, both objects exist separately in heap memory—the Order just knows where to find the Customer. This is why the same Customer can be referenced by multiple Orders.
There are several distinct patterns for establishing the relationship between a container and its components. Each pattern has different implications for flexibility, testability, and lifecycle management.
| Pattern | How It Works | Best For |
|---|---|---|
| Constructor Injection | Components passed to container's constructor | Mandatory dependencies that don't change |
| Property/Setter Injection | Components set via setter methods after construction | Optional or mutable dependencies |
| Internal Construction | Container creates components internally | Components that are implementation details |
| Factory-Based | Container uses a factory to obtain components | Dynamic component selection or complex creation |
| Service Locator | Container requests components from a central registry | Legacy systems or framework integration |
Let's examine each pattern in detail:
Pattern 1: Constructor Injection
The most common and generally preferred pattern for mandatory dependencies. The container receives its components as constructor parameters, ensuring it's never in an invalid state (missing required components).
1234567891011121314151617181920212223242526272829303132
// Constructor Injection: Dependencies provided at construction time interface Logger { log(message: string): void;} interface Database { query(sql: string): Promise<any[]>;} class UserService { private logger: Logger; private database: Database; // Both dependencies are required - must be provided at construction constructor(logger: Logger, database: Database) { this.logger = logger; this.database = database; } async findUser(id: string): Promise<User | null> { this.logger.log(`Looking up user: ${id}`); const results = await this.database.query(`SELECT * FROM users WHERE id = '${id}'`); return results.length > 0 ? results[0] as User : null; }} // Usage: Caller provides dependenciesconst consoleLogger: Logger = { log: console.log };const postgresDb: Database = new PostgresDatabase(config); const userService = new UserService(consoleLogger, postgresDb);Pattern 2: Property/Setter Injection
Useful for optional dependencies or dependencies that might change. The container can work without the component (or with a default), and injection happens after construction.
12345678910111213141516171819202122232425262728293031323334353637383940
// Setter Injection: Dependencies provided after construction interface CacheService { get(key: string): any; set(key: string, value: any, ttl?: number): void;} class ProductService { private database: Database; private cache: CacheService | null = null; // Optional component constructor(database: Database) { this.database = database; } // Setter injection for optional caching setCache(cache: CacheService): void { this.cache = cache; } async getProduct(id: string): Promise<Product | null> { // Use cache if available, otherwise go to database if (this.cache) { const cached = this.cache.get(`product:${id}`); if (cached) return cached; } const product = await this.database.query(`SELECT * FROM products WHERE id = '${id}'`); if (product && this.cache) { this.cache.set(`product:${id}`, product, 3600); } return product; }} // Usage: Cache is optional enhancementconst productService = new ProductService(database);productService.setCache(redisCache); // Can add caching laterPattern 3: Internal Construction
The container creates its own components internally. This is appropriate when components are true implementation details that callers shouldn't (or needn't) know about.
1234567891011121314151617181920212223242526272829303132
// Internal Construction: Container creates its own components class HttpClient { private connectionPool: ConnectionPool; private retryHandler: RetryHandler; private metricsCollector: MetricsCollector; constructor(config: HttpClientConfig) { // Internal construction - these are implementation details this.connectionPool = new ConnectionPool(config.maxConnections); this.retryHandler = new ExponentialBackoffRetry(config.maxRetries); this.metricsCollector = new PrometheusMetrics(config.metricsPrefix); } async get(url: string): Promise<Response> { const connection = await this.connectionPool.acquire(); try { return await this.retryHandler.execute(async () => { const start = Date.now(); const response = await connection.get(url); this.metricsCollector.recordLatency(Date.now() - start); return response; }); } finally { this.connectionPool.release(connection); } }} // Usage: Caller doesn't provide or know about internal componentsconst client = new HttpClient({ maxConnections: 10, maxRetries: 3 });const response = await client.get('https://api.example.com/data');Internal construction is simple but reduces testability—you can't easily substitute mock components. Use constructor injection if you need to test the container with controlled components. Use internal construction only when the components are truly private implementation details that don't affect external behavior.
The relationship between a container and its components can take various structural forms. Understanding these variations helps you choose the right structure for your design requirements.
Variation 1: Single Required Component
The simplest form—container holds exactly one instance of a required component. The component cannot be null, and the relationship is typically established at construction.
12345678910111213
// Single required componentclass Subscription { private plan: Plan; // Exactly one plan, always required constructor(plan: Plan) { if (!plan) throw new Error("Subscription must have a plan"); this.plan = plan; } getMonthlyPrice(): number { return this.plan.getPrice(); }}Variation 2: Multiple Required Components of Different Types
Container holds several different components, each serving a distinct role. This is the most common pattern in service-oriented designs.
1234567891011121314151617181920212223242526272829
// Multiple required components of different typesclass OrderProcessor { private paymentGateway: PaymentGateway; private inventoryService: InventoryService; private notificationService: NotificationService; private shippingCalculator: ShippingCalculator; constructor( paymentGateway: PaymentGateway, inventoryService: InventoryService, notificationService: NotificationService, shippingCalculator: ShippingCalculator ) { this.paymentGateway = paymentGateway; this.inventoryService = inventoryService; this.notificationService = notificationService; this.shippingCalculator = shippingCalculator; } async process(order: Order): Promise<OrderResult> { // Each component handles its aspect of the process const shippingCost = this.shippingCalculator.calculate(order); await this.inventoryService.reserve(order.items); const paymentResult = await this.paymentGateway.charge(order.total + shippingCost); await this.notificationService.sendConfirmation(order.customer); return { success: true, orderId: order.id }; }}Variation 3: Collection of Components (Same Type)
Container holds zero or more components, all implementing the same interface. Useful for pluggable systems, event handlers, or processing pipelines.
123456789101112131415161718192021222324252627282930313233343536373839
// Collection of components (polymorphic)interface ValidationRule { validate(data: any): ValidationResult; getName(): string;} class Validator { private rules: ValidationRule[] = []; addRule(rule: ValidationRule): void { this.rules.push(rule); } removeRule(ruleName: string): void { this.rules = this.rules.filter(r => r.getName() !== ruleName); } validate(data: any): ValidationResult { const errors: string[] = []; for (const rule of this.rules) { const result = rule.validate(data); if (!result.valid) { errors.push(...result.errors); } } return { valid: errors.length === 0, errors }; }} // Usage: Add/remove rules dynamicallyconst validator = new Validator();validator.addRule(new RequiredFieldsRule(['name', 'email']));validator.addRule(new EmailFormatRule());validator.addRule(new MinLengthRule('password', 8));Variation 4: Optional Component
Container may or may not have a component. The container works either way, but behavior changes based on presence. This pattern enables graceful degradation and optional features.
1234567891011121314151617181920212223242526272829303132333435
// Optional component patterninterface AnalyticsTracker { track(event: string, properties: Record<string, any>): void;} class BlogPost { private author: Author; private tracker: AnalyticsTracker | null; private viewCount: number = 0; constructor(author: Author, tracker?: AnalyticsTracker) { this.author = author; this.tracker = tracker ?? null; } view(): void { this.viewCount++; // Only track if analytics component is present if (this.tracker) { this.tracker.track('page_view', { postId: this.id, author: this.author.name }); } } getViewCount(): number { return this.viewCount; }} // Usage: Works with or without analyticsconst postWithTracking = new BlogPost(author, googleAnalytics);const postWithoutTracking = new BlogPost(author);One of the most important aspects of composing objects is managing their lifecycles. When is a component created? Who creates it? When is it destroyed? Who owns it? These questions have significant implications for resource management, memory usage, and system behavior.
| Model | Who Creates | Who Destroys | When to Use |
|---|---|---|---|
| Full Ownership | Container creates component | Container destroys component | Components are private implementation details |
| Shared Ownership | External creator | Last user or reference counter | Components used by multiple containers |
| Borrowed Reference | External creator | External owner | Container temporarily uses component |
| Dependency Injection | DI container/framework | DI container/framework | Framework-managed applications |
Full Ownership: Container Manages Everything
In full ownership, the container is responsible for both creating and destroying its components. This is common when components are implementation details that shouldn't be shared.
12345678910111213141516171819202122232425262728293031323334353637
// Full ownership: Container manages component lifecycleclass DatabaseConnection { private pool: ConnectionPool; private isOpen: boolean = false; async open(config: DatabaseConfig): Promise<void> { // Container creates the component this.pool = new ConnectionPool(config.maxConnections); await this.pool.initialize(); this.isOpen = true; } async close(): Promise<void> { if (this.isOpen) { // Container destroys the component await this.pool.drain(); await this.pool.shutdown(); this.isOpen = false; } } async query(sql: string): Promise<any[]> { if (!this.isOpen) throw new Error("Connection not open"); const conn = await this.pool.acquire(); try { return await conn.execute(sql); } finally { await this.pool.release(conn); } }} // Usage: Caller doesn't manage pool lifecycleconst db = new DatabaseConnection();await db.open(config);// ... use db ...await db.close(); // Automatically cleans up poolBorrowed Reference: Container Uses But Doesn't Own
In this model, the component exists independently of the container. The container receives a reference to use, but doesn't control the component's lifecycle.
123456789101112131415161718192021222324252627
// Borrowed reference: Container uses but doesn't ownclass ReportGenerator { private database: Database; // Borrowed, not owned constructor(database: Database) { // Just stores reference, doesn't create this.database = database; } async generateReport(query: string): Promise<Report> { // Uses the database, but doesn't manage its lifecycle const data = await this.database.query(query); return new Report(data); } // No cleanup needed - database is owned elsewhere} // Usage: Database lifecycle managed externallyconst sharedDb = await DatabasePool.create(config); const reportGenerator = new ReportGenerator(sharedDb);const exportService = new ExportService(sharedDb);const analyticsEngine = new AnalyticsEngine(sharedDb); // .. later ..await sharedDb.close(); // Owner cleans upLifecycle mismatches cause serious bugs. If a container tries to use a component that's been destroyed by its real owner, you get null references or use-after-free errors. If a container destroys a component that other objects still reference, you get similar failures. Always be clear about ownership semantics.
The most powerful form of composition uses interfaces rather than concrete types. When a container depends on an interface, it can work with any implementation—enabling substitution, testing, and extension without modifying the container.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
// Interface-based composition: Maximum flexibility // Define what the container needs, not specific implementationsinterface MessageSender { send(to: string, subject: string, body: string): Promise<boolean>;} interface TemplateEngine { render(templateName: string, data: Record<string, any>): string;} class NotificationService { // Depends on interfaces, not concrete classes private sender: MessageSender; private templates: TemplateEngine; constructor(sender: MessageSender, templates: TemplateEngine) { this.sender = sender; this.templates = templates; } async sendWelcome(user: User): Promise<boolean> { const body = this.templates.render('welcome', { name: user.name, signupDate: user.createdAt }); return this.sender.send(user.email, 'Welcome!', body); } async sendPasswordReset(user: User, resetLink: string): Promise<boolean> { const body = this.templates.render('password-reset', { name: user.name, resetLink }); return this.sender.send(user.email, 'Password Reset', body); }} // Now we can use different implementations without changing NotificationService // Production: Real email and HandleBars templatesconst prodNotifier = new NotificationService( new SmtpMailer(smtpConfig), new HandlebarsEngine('./templates')); // Testing: Mock implementationsconst testNotifier = new NotificationService( { send: async () => { sentEmails.push(...arguments); return true; } }, { render: (name, data) => `Mock: ${name} with ${JSON.stringify(data)}` }); // Different environment: SMS instead of emailconst smsNotifier = new NotificationService( new TwilioSmsAdapter(twilioConfig), new SimplePlainTextTemplates());Interface-based composition is closely related to the Dependency Inversion Principle (DIP)—high-level modules shouldn't depend on low-level modules; both should depend on abstractions. When containers depend on interfaces rather than concrete classes, you're applying DIP through composition.
Benefits of Interface-Based Composition:
One of composition's greatest strengths is the ability to assemble objects with multiple, distinct capabilities by composing components for each role. Unlike inheritance (where a class can typically extend only one parent), composition allows unlimited component integration.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// Composing multiple roles: A rich, flexible object interface Storable { save(): Promise<void>; load(id: string): Promise<void>;} interface Cacheable { getCacheKey(): string; invalidateCache(): void;} interface Auditable { getAuditLog(): AuditEntry[]; logChange(description: string): void;} interface Versionable { getVersion(): number; incrementVersion(): void; getPreviousVersions(): Promise<Document[]>;} // A Document gains all these capabilities through compositionclass Document { // Multiple components, each providing a distinct capability private storage: Storable; private cache: Cacheable; private audit: Auditable; private versioning: Versionable; // Core document state private content: string; private title: string; constructor( storage: Storable, cache: Cacheable, audit: Auditable, versioning: Versionable ) { this.storage = storage; this.cache = cache; this.audit = audit; this.versioning = versioning; } async save(): Promise<void> { this.versioning.incrementVersion(); this.audit.logChange(`Document saved (v${this.versioning.getVersion()})`); await this.storage.save(); this.cache.invalidateCache(); } async load(id: string): Promise<void> { await this.storage.load(id); this.audit.logChange(`Document loaded`); } edit(newContent: string): void { this.audit.logChange(`Content edited`); this.content = newContent; } // Expose capabilities to callers who need them getFullAuditTrail(): AuditEntry[] { return this.audit.getAuditLog(); } async getHistory(): Promise<Document[]> { return this.versioning.getPreviousVersions(); }}Compare this to an inheritance-based approach:
Inheritance: Document extends StorableVersionedCacheableAuditable — but such a class would be impossibly complex, and you can't mix and match capabilities.
Composition: Each capability is a separate, testable, replaceable component. Want a document without caching? Just pass a no-op cache. Need different versioning logic? Substitute a different Versionable implementation.
This flexibility is why composition scales better than inheritance in complex systems.
Based on the patterns we've explored, here are practical guidelines for designing effective compositional relationships:
If you find yourself constantly passing components through multiple layers just to reach where they're needed ("prop drilling" in UI frameworks), consider whether your compositional structure needs adjustment. Deep component passing often indicates that the responsibility structure needs refactoring.
We've explored the mechanics of how objects contain and collaborate with other objects. Let's consolidate the key concepts:
What's next:
Now that we understand how objects contain other objects, we'll explore how to build complex objects from simple ones. This next page examines compositional construction patterns—how to assemble sophisticated systems from primitive building blocks, including the Builder pattern, fluent interfaces, and hierarchical assembly techniques.
You now understand the structural mechanics of composition—how containers hold references to components, the various patterns for establishing these relationships, and the lifecycle considerations that affect design quality. Next, we'll explore techniques for building complex objects from simpler components.