Back to Insights
compositionJuly 25, 20268 min read

Composition Over Inheritance: Building Behavior From Smaller Parts

Composition turns independently changing behavior into explicit collaborators, avoiding fragile class trees while preserving inheritance for genuine subtype relationships.

John Mather, Founder of Prime Axiom AI
John Mather

Founder, Prime Axiom AI

Published July 25, 2026

Small interchangeable software components assembled into an export pipeline
Composition models independent behavior as explicit collaborators rather than subclass combinations.

Composition Over Inheritance: Building Behavior From Smaller Parts

“Favor composition over inheritance” is not a ban on base classes. It is a warning about using an is-a relationship merely to reuse code. Inheritance binds a child to the state, lifecycle, protected methods, and assumptions of its parent. Composition binds an object to smaller capabilities that can be selected, tested, and replaced independently. The right choice depends on which behavior varies and which promises must remain stable.

This article uses a case walkthrough rather than a catalog of patterns. A reporting product begins with one export type, acquires several customers, and then discovers that format, destination, compression, and audit behavior vary on different schedules.

Act I: the convenient base class

The first exporter is direct:

ts
abstract class ReportExporter {
  constructor(protected readonly fileName: string) {}

  abstract render(rows: ReportRow[]): Buffer;

  export(rows: ReportRow[]): void {
    const bytes = this.render(rows);
    fs.writeFileSync(this.fileName, bytes);
  }
}

class CsvExporter extends ReportExporter {
  render(rows: ReportRow[]): Buffer {
    return Buffer.from(toCsv(rows));
  }
}

This block demonstrates reasonable inheritance. The abstract class defines a template—render, then write—and CsvExporter supplies the varying rendering step. If every exporter really writes one local file with the same lifecycle, the hierarchy may remain coherent.

Then requirements split. One customer needs CSV uploaded to object storage. Another needs encrypted JSON sent over SFTP. A third wants local PDF plus an audit record. Teams often respond by adding subclasses such as S3CsvExporter, EncryptedSftpJsonExporter, and AuditedLocalPdfExporter. The class names reveal a Cartesian product.

Format: CSV | JSON | PDF Destination: local | object store | SFTP Protection: plain | compressed | encrypted Audit: off | on

Even this small set permits 3 × 3 × 3 × 2 = 54 combinations. A class tree can represent some combinations, but it cannot express four independent axes cleanly. Multiple inheritance would add ambiguity, not clarity.

Locate the reasons to vary

The first design task is decomposition. Rendering transforms report rows into bytes. Encoding compresses or encrypts bytes. Delivery transports bytes. Auditing records an outcome. They have different inputs, errors, owners, and test strategies.

CapabilityInput and outputTypical failureChange pressure
Rendererrows → bytesunsupported valuefile-format rules
Transformerbytes → byteskey unavailablesecurity/compression policy
Destinationbytes + name → receipttimeout or conflictinfrastructure/vendor
Audit sinkevent → acknowledgementstorage unavailablecompliance policy

The table demonstrates why these behaviors should not share a parent lifecycle by accident. A renderer is deterministic; a destination is I/O. Encryption needs key management; auditing may be best-effort or mandatory. Their contracts deserve different semantics.

Act II: extract collaborators

ts
interface Renderer {
  render(rows: ReportRow[]): Promise<RenderedReport>;
}

interface Transformer {
  apply(report: RenderedReport): Promise<RenderedReport>;
}

interface Destination {
  deliver(report: RenderedReport): Promise<DeliveryReceipt>;
}

interface AuditSink {
  record(event: ExportAuditEvent): Promise<void>;
}

This code demonstrates interfaces shaped around capabilities rather than technologies. RenderedReport can contain bytes, media type, and suggested extension. DeliveryReceipt can contain a provider-neutral identifier. Each promise includes failure semantics that documentation and tests must define.

The composed workflow is intentionally boring:

ts
class ExportReport {
  constructor(
    private readonly renderer: Renderer,
    private readonly transformers: readonly Transformer[],
    private readonly destination: Destination,
    private readonly audit: AuditSink,
  ) {}

  async execute(rows: ReportRow[]): Promise<DeliveryReceipt> {
    let report = await this.renderer.render(rows);

    for (const transformer of this.transformers) {
      report = await transformer.apply(report);
    }

    try {
      const receipt = await this.destination.deliver(report);
      await this.audit.record({ outcome: 'delivered', receipt });
      return receipt;
    } catch (error) {
      await this.audit.record({ outcome: 'failed', reason: classify(error) });
      throw error;
    }
  }
}

This block demonstrates orchestration through explicit dependencies. A factory can assemble CsvRenderer + GzipTransformer + ObjectStoreDestination without creating a subclass. Ordering is visible: compression before encryption can be enforced at construction, and auditing wraps delivery outcomes.

The architecture now looks like:

mermaid
flowchart LR
  UseCase[ExportReport] --> Renderer
  UseCase --> Transformer
  UseCase --> Destination
  UseCase --> AuditSink
  Renderer --> Csv[CsvRenderer]
  Renderer --> Pdf[PdfRenderer]
  Destination --> S3[ObjectStoreDestination]
  Destination --> Sftp[SftpDestination]

The diagram demonstrates fan-out from one coordinator to independent strategy families. New combinations are configuration, while genuinely new behavior is a focused implementation.

Where inheritance still earns its place

Inheritance is appropriate when the child is behaviorally substitutable for the parent and shares a stable conceptual model. Framework components may inherit a lifecycle contract. Domain subtypes may inherit when every subtype preserves invariants. A sealed expression hierarchy—Number, Add, Multiply—can work well because the variant set and operations are intentionally controlled.

Ask three questions:

  1. Is the relationship true in domain language, not merely in code?
  2. Can every child honor all parent operations without disabling, weakening, or surprising?
  3. Does behavior vary along one stable axis rather than several independent axes?

If yes, inheritance can communicate. If a subclass throws “not supported,” overrides a method only to undo parent behavior, or depends on protected implementation details, composition is safer.

The Java tutorials describe inheritance and subclasses, including access and overriding mechanics. Those mechanics are useful, but they cannot prove the semantic is-a relationship. Liskov and Wing's behavioral subtyping supplies the deeper constraint: inherited types must preserve properties expected by callers.

Shared code without a superclass

Teams sometimes keep inheritance because two classes share fifteen lines. Reuse can come from plain functions or composed helpers:

ts
function sanitizeFileName(input: string): string {
  return input.normalize('NFKC').replaceAll(/[^a-zA-Z0-9._-]/g, '_');
}

class ObjectStoreDestination implements Destination {
  constructor(
    private readonly client: ObjectStoreClient,
    private readonly bucket: string,
  ) {}

  async deliver(report: RenderedReport): Promise<DeliveryReceipt> {
    const key = sanitizeFileName(report.name);
    return this.client.put(this.bucket, key, report.bytes, report.mediaType);
  }
}

This demonstrates reuse without sharing lifecycle or mutable state. The pure sanitizer can be tested independently and used by destinations that need it. There is no abstract UtilityDestination whose future changes affect every child.

Composition can itself become excessive. Wrapping every two-line calculation in a strategy produces a dependency graph that is harder to follow than a conditional. Extract behavior when it varies independently, needs an alternate implementation, crosses an architectural boundary, or deserves isolated policy tests. Leave stable, cohesive details local.

Construct combinations in one place

Composition works best with a visible composition root:

ts
function buildMonthlyExport(config: ExportConfig): ExportReport {
  const renderer = config.format === 'csv'
    ? new CsvRenderer()
    : new JsonRenderer();

  const transformers: Transformer[] = [];
  if (config.compress) transformers.push(new GzipTransformer());
  if (config.encryptionKeyId) {
    transformers.push(new EnvelopeEncryptionTransformer(keyService, config.encryptionKeyId));
  }

  const destination = new ObjectStoreDestination(storageClient, config.bucket);
  return new ExportReport(renderer, transformers, destination, complianceAudit);
}

The code demonstrates centralized assembly while keeping behavior out of configuration logic. Validation should reject impossible combinations before construction. Do not pass a general service locator into ExportReport; hidden lookups defeat composition's main benefit: visible dependencies. The companion guide Dependency Injection Without Framework Magic develops this technique.

Test the seams and the whole pipeline

Renderer tests should use representative rows, Unicode, formula-like spreadsheet values, large numbers, and empty datasets. Transformer tests should verify round trips and metadata updates. Destination contract tests should run against each actual protocol adapter, checking duplicate names, retries, and partial upload cleanup.

At the workflow level, use small hand-written fakes:

ts
it('audits a failed delivery without claiming success', async () => {
  const audit = new RecordingAuditSink();
  const useCase = new ExportReport(
    new FixedRenderer(bytes('report')),
    [],
    new FailingDestination(new DestinationUnavailable()),
    audit,
  );

  await expect(useCase.execute(rows)).rejects.toBeInstanceOf(DestinationUnavailable);
  expect(audit.events).toEqual([{ outcome: 'failed', reason: 'unavailable' }]);
});

This test demonstrates observable coordination rather than mocking private calls. An integration test should still cover a real object-store adapter because the fake cannot prove credentials, content types, multipart behavior, or network timeouts.

In production, attach one correlation ID to the whole export and child spans to render, transform, deliver, and audit. Measure bytes and duration per stage. Record the selected implementation names as low-cardinality attributes. Do not log report contents or encryption material. Alerts should distinguish a renderer defect from destination unavailability; composition gives the telemetry natural boundaries.

Failure patterns during migration

The most dangerous migration keeps the old base class while adding composed collaborators to it. Subclasses then override some hooks and inject others, creating two extension mechanisms. Move one axis at a time and delete the corresponding protected hook.

Another failure is shared mutable context. A PipelineContext passed through every component becomes a hidden global object. Prefer immutable values returned from each stage, and expose only fields a capability needs. Decorator chains can also obscure order; name security-critical pipelines or validate transformer ordering.

Decision callout: when the number of subclasses grows because names combine adjectives—Cached, Retrying, Encrypted, Audited—those adjectives are likely composable behaviors.

A migration checklist

  • [ ] Inventory subclass overrides and the assumptions they change.
  • [ ] Group variation by independent business or technical capability.
  • [ ] Define narrow contracts from the coordinator's needs.
  • [ ] Characterize current outputs and failures before moving behavior.
  • [ ] Introduce explicit factories for supported combinations.
  • [ ] Run contract tests against real destinations and test doubles.
  • [ ] Remove obsolete hooks and protected state.
  • [ ] Trace each stage in production before expanding combinations.

The ending of the exporter story

The exporter no longer needs a subclass for every customer combination. Formats remain focused on serialization, destinations on transport, transformers on bytes, and the use case on order and policy. Inheritance was not “wrong” at the first stage; it became mismatched when variation became multidimensional.

Favor composition when behavior changes independently or must be selected at runtime. Keep inheritance when a stable subtype relationship carries genuine shared semantics. The objective is not smaller classes by itself. It is a design in which the dimensions of change are represented directly, assembled visibly, and tested according to their actual risks. More boundary-design material is available in Interfaces as Software Contracts and /resources.

Sources

Apply the idea

Turn technical clarity into a working system.

Talk with Prime Axiom about architecture, AI automation, integrations, and the operational workflow behind your next build.

Continue reading

Decision map connecting software problems to candidate design patterns
Patterns are selected by forces and consequences, not vocabulary.
design patterns

July 25, 2026 · 8 min read

How to Choose a Design Pattern by the Problem, Not the Name

A decision-first method for selecting patterns from concrete forces—variation, coordination, lifecycle, failure, and scale—without turning pattern vocabulary into architecture theater.

By John Mather
Read article →