Design Patterns / Article

Factory Method: let export workflows create their encoders

Export KitDesk's selected pickups as text or CSV through concrete creator subclasses, with explicit snapshot, artifact and product-lifetime contracts.

Shared case study: KitDesk community workshop pickups

KitDesk has selected the reservations to prepare. A volunteer now wants a plain-text manifest for the desk or a CSV manifest for an approved downstream tool. Both exports follow the same workflow: take a stable snapshot, create the appropriate encoder, encode it, and return an artifact. This lesson uses Factory Method at one creation point inside that workflow.

The problem: a reusable workflow needs a variable product

A first version can have if (format === "csv") inside an export function. That is often sufficient. Suppose KitDesk already has separate export job classes because screen actions select distinct export workflows, and both must preserve the same snapshot and artifact conventions. If the shared workflow constructs TextEncoder directly, a CSV job must either duplicate the workflow or override too much of it.

The forces are reuse of the workflow, independence from a concrete product class, and a construction model small enough to understand. We want the base job to use the encoder interface. We want a specialized job to decide which encoder to create. We do not want the pickup session to know about export formats. This is a creation problem among collaborating classes, not a service factory or a deployment decision.

We call the product ManifestEncoder. It exposes mediaType and encode(rows). An export job exposes render(rows), which returns { mediaType, text, notes }. Notes are presentation metadata outside the payload. That distinction will let the final lesson add optional annotations without corrupting CSV.

Put the overridable creation method inside the creator

function snapshotRows(rows) {
  const seen = new Set();
  return Object.freeze(rows.map(row => {
    if (typeof row.id !== "string" || !/^R[0-9]+$/.test(row.id) ||
        typeof row.kit !== "string" || !/^[A-Z][A-Z0-9-]*$/.test(row.kit) ||
        seen.has(row.id)) {
      throw new TypeError("manifest needs unique IDs and safe kit codes");
    }
    seen.add(row.id);
    return Object.freeze({ id: row.id, kit: row.kit });
  }));
}

class TextManifestEncoder {
  mediaType = "text/plain";
  encode(rows) {
    return rows.map(row => `${row.id} ${row.kit}`).join("\n");
  }
}

function csvCell(value) {
  return `"${value.replaceAll('"', '""')}"`;
}

class CsvManifestEncoder {
  mediaType = "text/csv";
  encode(rows) {
    return ["reservation,kit", ...rows.map(row =>
      [row.id, row.kit].map(csvCell).join(","))].join("\r\n");
  }
}

class ExportJob {
  createEncoder() {
    throw new Error("subclass must supply a manifest encoder");
  }

  render(rows) {
    const snapshot = snapshotRows(rows);
    const encoder = this.createEncoder();
    return Object.freeze({
      mediaType: encoder.mediaType,
      text: encoder.encode(snapshot),
      notes: Object.freeze([])
    });
  }
}

class TextExportJob extends ExportJob {
  createEncoder() { return new TextManifestEncoder(); }
}

class CsvExportJob extends ExportJob {
  createEncoder() { return new CsvManifestEncoder(); }
}

const selected = [{ id: "R32", kit: "SAW" }, { id: "R34", kit: "CLAMP" }];
const job = new CsvExportJob(); // selected at application composition
console.log(job.render(selected).text);
// reservation,kit
// "R32","SAW"
// "R34","CLAMP"

ExportJob is the creator. Its createEncoder() is the factory method. CsvExportJob and TextExportJob are concrete creators; their encoders are concrete products. The base creator calls the hook and then uses the returned product through a common contract. This collaboration, rather than a name beginning with “create,” identifies Factory Method here.

The snapshot narrows the export input to safe internal codes and prevents an encoder from changing those copied records. It is intentionally shallow because the records contain only strings. The allowed character grammar excludes commas, line breaks and formula prefixes from user-controlled fields; this is a narrow teaching export, not a general spreadsheet sanitization scheme. If names or free-form descriptions are added, revisit the output contract instead of assuming CSV quoting alone solves every consumer concern.

A primary API example of an overridable product-creation method is DocumentBuilderFactory.newDocumentBuilder(): the abstract method produces a DocumentBuilder using configured parameters. We use that documented method as an analogy for the creation hook, without claiming that KitDesk reproduces the entire Java API's design.

Live model · Architecture view

Locate the creation hook and encoder roles

The shared KitDesk model places the export workflow and encoder responsibilities inside the manifest collaboration. Use this responsibility view alongside the code for the formal inheritance and product-interface contracts; the sequence below then shows creation followed by use.

Locate the export job, its creation hook and the CSV encoder. Compare those roles with the inherited render implementation before tracing the selected CSV sequence. Open the public diagram ↗

Open this viewer on its own ↗ if the frame is unavailable. The explanation remains readable without the viewer.

Follow construction separately from use

The structural view has two relationships that should remain distinct. Concrete export jobs inherit from ExportJob. Concrete encoders satisfy ManifestEncoder. The creator depends on the product interface, while each concrete creator constructs a specific encoder. Drawing only “CSV job → CSV encoder” misses the shared operation that motivates the pattern.

The interaction starts when composition selects CsvExportJob. A caller invokes the inherited render(selected). It snapshots the rows, dispatches createEncoder() to the subclass, receives a new CSV encoder, and calls encode(snapshot). Only after encoding succeeds does it return an artifact. A validation or encoder failure propagates to the caller; the code does not return a success-shaped empty artifact.

The method creates a fresh encoder per render call. That is a lifetime choice in this example, not a requirement of the pattern. A stateless encoder could be shared. An encoder holding open resources would need an explicit ownership and cleanup contract. Never infer resource safety merely from the fact that construction happens behind a factory method.

Live model · Sequence view

Separate construction from encoding

The KitDesk model shows ExportJob.render calling its createEncoder hook, concrete jobs creating their products, and the inherited workflow encoding an immutable row snapshot.

Trace CSV export from render through product creation and encode. Mark the one method that a JSON job would override and explain when encoder injection would be simpler. Open the public diagram ↗
Explore scenarios in this model

These links open the same public model in a selected view. They do not change its source.

Open this viewer on its own ↗ if the frame is unavailable. The explanation remains readable without the viewer.

Exploration is local to your viewer. Reload or Reset view returns to the source layout.

Compare the simpler alternatives before keeping inheritance

A standalone makeEncoder(format) switch returning a product is a simple factory function. It may be the best design when there is no creator hierarchy. It is not the subclass-overridden creation hook demonstrated here. An Abstract Factory would supply a coordinated family of related products, such as an encoder and a matching preview renderer; this lesson needs only one variable product.

Constructor injection is another strong option: new ExportJob(encoder) can reuse the workflow without concrete job subclasses. If the only difference between jobs is the encoder, prefer considering that composition approach first. Factory Method becomes persuasive when a creator hierarchy already has a real role or intentionally exposes this extension hook to subclass authors.

The cost is visible in the example: two product classes and two creator subclasses for two formats. More formats mean more classes, and subclasses can accidentally override the workflow rather than the intended hook. Languages with protected or final methods can constrain that extension surface; JavaScript relies on convention and review here.

Do not add this pattern for one stable text export, merely to postpone a constructor call, or to eliminate a small composition switch. It also does not replace the pickup ordering Strategy: that policy chooses row order, whereas this factory method creates an encoder after row selection is complete.

Practice: extend one hook and challenge the design

  1. Add a JSON manifest encoder with application/json and a concrete job overriding only createEncoder(). Preserve row order and the artifact envelope. The inherited render operation should remain unchanged.
  2. Use an encoder that throws. Confirm render propagates the failure and the caller's rows are unchanged. Render an empty list and define its result: text is empty, CSV contains its header, and JSON should contain an empty array.
  3. Refactor the example to constructor injection. Count the concepts a new contributor must learn in each version. Write one requirement that would justify retaining creator subclasses; if you cannot, keep the simpler variant in a real application.

The useful outcome is an explicit decision about who constructs a product and who owns its lifetime. Next, Observer: update a pickup board with explicit notification rules separates state changes from optional reactions. The KitDesk path and Design Patterns hub provide the series context.