Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 031 / 100

Template method: Design Patterns lessons from production

Define the skeleton, let subclasses fill the steps.

Expona Admin8 min read

Template method: Design Patterns lessons from production

When you're building large data pipelines or multi-step workflows, you often find yourself repeating the same sequence of operations with only the details changing between implementations. The template method pattern solves this by defining the skeleton of an algorithm in a base class while letting subclasses override specific steps. We've used this pattern extensively in import jobs, payment reconciliation flows, and notification dispatch systems, and it consistently reduces boilerplate while keeping the core logic in one place.

Base class controls the algorithm

The template method pattern centers on a base class that orchestrates the overall process. This class defines the sequence of operations—the algorithm's structure—as a series of method calls. The key insight is that the base class doesn't implement every detail; instead, it calls abstract or virtual methods that subclasses will fill in.

Think about an import job that handles external data. You always need to read the input, validate it, transform it, and save it to your database. Rather than duplicating this sequence in every import handler, you define it once in a base class:

typescript
abstract class ImportJob {
  async execute(): Promise<void> {
    const rawData = await this.read();
    this.validate(rawData);
    const transformed = await this.transform(rawData);
    await this.save(transformed);
  }

  protected abstract read(): Promise<unknown[]>;
  protected abstract validate(data: unknown[]): void;
  protected abstract transform(data: unknown[]): Promise<Record<string, unknown>[]>;
  protected abstract save(data: Record<string, unknown>[]): Promise<void>;
}

The execute method is your template. It defines the order and flow—read, then validate, then transform, then save. The base class doesn't know what kind of data you're importing or where it comes from; it just orchestrates the steps. This ensures every subclass follows the same process, eliminating the risk that someone forgets a validation step or saves before transforming.

Subclasses override specific steps

Once the template is in place, creating a new import handler is straightforward. You subclass the base and implement only the steps that differ. For a CSV product import, you read from a file, validate product codes, transform rows into objects, and save to the products table. For an API vendor sync, you read from an HTTP endpoint, validate the JSON schema, apply currency conversions, and upsert into another table.

Here's a concrete example for a vendor feed import:

typescript
class VendorFeedImport extends ImportJob {
  constructor(private db: Database, private vendorId: string) {
    super();
  }

  protected async read(): Promise<unknown[]> {
    const response = await fetch(`https://vendor-api.com/feed/${this.vendorId}`);
    return response.json();
  }

  protected validate(data: unknown[]): void {
    if (!Array.isArray(data)) {
      throw new Error('Vendor feed must return an array');
    }
    data.forEach((item, idx) => {
      if (!item.sku || !item.price) {
        throw new Error(`Item ${idx} missing sku or price`);
      }
    });
  }

  protected async transform(data: unknown[]): Promise<Record<string, unknown>[]> {
    return data.map((item: any) => ({
      sku: item.sku.toUpperCase(),
      vendorId: this.vendorId,
      price: parseFloat(item.price),
      lastSyncedAt: new Date(),
    }));
  }

  protected async save(data: Record<string, unknown>[]): Promise<void> {
    await this.db.query(
      'INSERT INTO vendor_inventory (sku, vendor_id, price, last_synced_at) VALUES ($1, $2, $3, $4) ' +
      'ON CONFLICT (sku, vendor_id) DO UPDATE SET price = EXCLUDED.price, last_synced_at = EXCLUDED.last_synced_at',
      data
    );
  }
}

A different import—say, a CSV of customer records—would look like this:

typescript
class CustomerCSVImport extends ImportJob {
  constructor(private db: Database, private filePath: string) {
    super();
  }

  protected async read(): Promise<unknown[]> {
    const records = [];
    return new Promise((resolve, reject) => {
      fs.createReadStream(this.filePath)
        .pipe(parse())
        .on('data', (row) => records.push(row))
        .on('end', () => resolve(records))
        .on('error', reject);
    });
  }

  protected validate(data: unknown[]): void {
    const seenEmails = new Set<string>();
    data.forEach((row: any, idx) => {
      if (!row.email || !row.name) {
        throw new Error(`Row ${idx} missing email or name`);
      }
      if (seenEmails.has(row.email)) {
        throw new Error(`Row ${idx}: duplicate email ${row.email}`);
      }
      seenEmails.add(row.email);
    });
  }

  protected async transform(data: unknown[]): Promise<Record<string, unknown>[]> {
    return data.map((row: any) => ({
      email: row.email.toLowerCase(),
      name: row.name.trim(),
      createdAt: new Date(),
    }));
  }

  protected async save(data: Record<string, unknown>[]): Promise<void> {
    await this.db.query('INSERT INTO customers (email, name, created_at) VALUES ($1, $2, $3)', data);
  }
}

Both subclasses follow the same contract and the same process order. The base class ensures nothing is skipped, and each subclass only needs to focus on the logic unique to its data source.

Real-world: Import jobs with consistent error handling

In production, we've extended the pattern to add error handling and logging at the template level. This is where the template method really shines—you write cross-cutting concerns once, in the base class, and they apply to every subclass automatically:

typescript
abstract class ImportJob {
  constructor(protected logger: Logger) {}

  async execute(): Promise<void> {
    const startTime = Date.now();
    try {
      this.logger.info('Starting import job');
      const rawData = await this.read();
      this.logger.debug(`Read ${rawData.length} records`);

      this.validate(rawData);
      this.logger.debug('Validation passed');

      const transformed = await this.transform(rawData);
      this.logger.debug(`Transformed to ${transformed.length} records`);

      await this.save(transformed);
      this.logger.info(`Import completed in ${Date.now() - startTime}ms`);
    } catch (error) {
      this.logger.error('Import failed', { error });
      throw error;
    }
  }

  protected abstract read(): Promise<unknown[]>;
  protected abstract validate(data: unknown[]): void;
  protected abstract transform(data: unknown[]): Promise<Record<string, unknown>[]>;
  protected abstract save(data: Record<string, unknown>[]): Promise<void>;
}

Now every import job logs consistently, reports timing, and handles errors in the same way. You don't need to duplicate error handling logic across 10 different import implementations.

Composition with callbacks is often simpler

While inheritance-based template methods work well for tightly coupled, multi-step processes, we've found that composition with callbacks often provides more flexibility, especially when steps are truly optional or when you need to swap implementations frequently.

Instead of inheritance, you can pass in functions that represent each step:

typescript
interface ImportConfig {
  read: () => Promise<unknown[]>;
  validate: (data: unknown[]) => void;
  transform: (data: unknown[]) => Promise<Record<string, unknown>[]>;
  save: (data: Record<string, unknown>[]> => Promise<void>;
}

async function executeImport(config: ImportConfig): Promise<void> {
  const rawData = await config.read();
  config.validate(rawData);
  const transformed = await config.transform(rawData);
  await config.save(transformed);
}

Then you wire up a specific import by providing implementations:

typescript
const vendorImport: ImportConfig = {
  read: async () => {
    const response = await fetch(`https://vendor-api.com/feed/${vendorId}`);
    return response.json();
  },
  validate: (data) => {
    if (!Array.isArray(data)) throw new Error('Expected array');
  },
  transform: async (data) => {
    return data.map((item: any) => ({
      sku: item.sku.toUpperCase(),
      price: parseFloat(item.price),
    }));
  },
  save: async (data) => {
    await db.query('INSERT INTO inventory...', data);
  },
};

await executeImport(vendorImport);

The composition approach avoids the class hierarchy and is often easier to test. You can mock or swap any step independently. However, it does lose the self-contained nature of subclasses—your configuration object becomes the contract instead of a class definition. Both approaches have their place; inheritance works better when you have a stable set of steps and want all implementations to follow the same structure, while composition shines when you're combining steps dynamically or want to reuse logic across unrelated workflows.

Common mistakes

  • Forgetting protection levels: Template methods should call protected abstract methods, not public ones. Public methods invite subclasses to call steps out of order or skip them.
  • Too many template methods: If you find yourself creating separate template methods for different workflows within the same class, you've conflated two different patterns. Split into separate base classes.
  • Making the template too flexible: Adding conditional branches or optional steps in the template method defeats the purpose. If step B is optional, that's a sign you need a different pattern (like Strategy or Chain of Responsibility).
  • Not documenting step contracts: Each abstract method should have clear documentation about what it receives, what it should return, and what exceptions it can throw. Subclasses need to know the expectations.
  • Ignoring composition as an alternative: Not every multi-step process needs inheritance. If your steps are loosely coupled or frequently recombined, callbacks or dependency injection often lead to cleaner code.

Key takeaways

  • Define the algorithm skeleton in the base class; let subclasses fill in the specifics. This keeps the overall process logic in one place.
  • Use protected abstract methods to force subclasses to implement required steps and prevent accidental skips or reordering.
  • Put cross-cutting concerns—logging, error handling, metrics—in the template method so they apply uniformly across all subclasses.
  • Consider composition with callbacks when steps are optional, frequently recombined, or when you want to avoid deep class hierarchies.
  • Document the contract between template and subclass clearly. Each step should have defined inputs, outputs, and failure modes.
  • The template method pattern shines for repetitive workflows like imports, batch jobs, and multi-stage processing pipelines in production systems.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles