hor-strategy-pattern · git:20260905.5a04969 · 2026-09-05 · sha256 b320dd0755ea6af3

hor-strategy-pattern git:20260905.5a04969A

Immutable. This exact content never changes and is served at /api/v1/blob/b320dd0755ea6af3.

---
name: hor-strategy-pattern
description: >
  Implement the strategy pattern as a trio of a base processor class, one concrete subclass per
  variant, and a bulk loader that auto-discovers the subclasses from a directory and picks one by
  a dispatch getter. Use this skill whenever the user asks to replace an else-if / switch chain
  that dispatches on a "type" string with pluggable processor classes, or to add a new event /
  webhook / message handler that must be auto-discovered.
---

# Strategy Pattern (processor classes + a bulk loader)

A skill for dispatching on a string "type" value to one of several behaviors **without an
`else if` / `switch` chain**. Each branch becomes a class; a loader auto-discovers the classes
from a directory and picks one by a dispatch **getter**. The class skeleton each piece follows
(one file / one class, `static create()` factory, JSDoc typedefs) comes from `class-design` —
this skill covers how the three pieces fit together.

Reach for it when:

- You would otherwise write `if (type === 'a') ... else if (type === 'b') ...`.
- New variants get added over time and you want to drop a file in a folder, not edit a switch.
- Each branch has non-trivial logic worth testing in isolation.

Do **not** reach for it for a 2-branch boolean or a value map with no behavior — a plain
lookup hash is enough there.

## Grand principle: adding a variant means adding a file, never editing a dispatch chain

Every rule in this skill serves one point: **the set of variants is discovered from the
filesystem, not listed in code**. A new variant is one new subclass file dropped into the
processor directory; no existing file changes.

- **Why not a switch**: an `else if` / `switch` chain concentrates every variant's wiring in one
  function, so every new variant edits (and risks breaking) shared code, and each branch's logic
  can't be tested in isolation. One-class-per-variant keeps each branch independently testable
  and reviewable.
- **Why auto-discovery**: if the loader imported each subclass by name, the "chain" would just
  move into the import list. Loading every class in the directory
  ([4](#4-bulk-loader-auto-discover-and-pick-by-getter)) makes the directory itself the
  registry.
- **Why dispatch by getter**: the key a processor handles is part of that processor's contract,
  so it lives **on the subclass** as an overridden getter — not in a name field passed from
  outside, and not in a mapping table kept next to the loader.

```js
// Good: the caller asks the loader; variants stay out of the call site
const processor = loader.resolveProcessor(eventCategory)

if (!processor) {
  return null
}

return processor.process({
  event,
})
```

```js
// Avoid: an else-if chain enumerating every variant at the call site
//   → every new variant edits this shared function; branches can't be tested in isolation
if (eventCategory === 'created') {
  return handleCreated(event)
} else if (eventCategory === 'updated') {
  return handleUpdated(event)
} else if (eventCategory === 'deleted') {
  return handleDeleted(event)
}
```

## Comment language in the sample code

Comments in the `js` examples in this SKILL.md follow the prose language (here, **English**),
because the examples are *explanation* of the skill. Comments inside the **artifact this skill
produces** (the real processor / loader code) follow the **codebase** language — English — to
match the surrounding source.

## 1. The three pieces

| Piece | Role | Count |
| --- | --- | --- |
| Base class | Declares the `static create()` factory, the abstract dispatch **getter** (throws until overridden), and the abstract work method (throws until overridden) | 1 |
| Concrete subclasses | One file per variant; each overrides the dispatch getter to return its key and overrides the work method | 1 per variant |
| Bulk loader | `static async createAsync()` loads every class in the processor directory via `DeepBulkClassLoader`, instantiates each; `resolveProcessor(key)` finds the one whose dispatch getter matches, `?? null` | 1 |

Typical layout (a payment-gateway webhook example):

```
app/tools/eventProcessors/
├── BaseEventProcessor.js
├── concretes/
│   ├── CreateEventProcessor.js
│   ├── UpdateEventProcessor.js
│   └── DeleteEventProcessor.js
app/tools/bulkLoaders/
└── BulkEventProcessorsLoader.js
```

## 2. Base class: an abstract getter + an abstract work method

Dispatch is **by getter**, not by a `processorName` field or a `static getName()`. The getter
throws until a subclass overrides it. `static create()` is the factory; the work method is
abstract and also throws. Throwing abstract members are the whole "interface" — there is no
TypeScript interface to implement.

```js
/**
 * Base class for event processors.
 */
export default class BaseEventProcessor {
  /**
   * Factory method.
   *
   * @template {X extends typeof BaseEventProcessor ? X : never} T, X
   * @param {BaseEventProcessorFactoryParams} params - Parameters for the factory.
   * @returns {InstanceType<T>} An instance of the class.
   * @this {T}
   */
  static create (params) {
    return /** @type {InstanceType<T>} */ (
      new this(params)
    )
  }

  /**
   * get: Dispatch key this processor handles.
   *
   * @abstract
   * @returns {string}
   * @throws {Error} - When not overridden in a subclass.
   */
  get eventCategory () {
    throw new Error('eventCategory must be implemented in subclass')
  }

  /**
   * Process the event.
   *
   * @abstract
   * @param {EventProcessorParams} params - Parameters for processing.
   * @returns {Promise<*>}
   * @throws {Error} - When not overridden in a subclass.
   */
  async process ({
    event,
  }) {
    throw new Error('process must be implemented in subclass')
  }
}

/**
 * @typedef {object} EventProcessorParams
 * @property {*} event - The event to process.
 */

/**
 * @typedef {{
 *   [key: string]: *
 * }} BaseEventProcessorFactoryParams
 */
```

- When a processor needs collaborators, take them as constructor deps and default them in
  `create()` (e.g. `static create ({ randomTextGenerator = this.createRandomTextGenerator() } = {})`),
  following `class-design`. Keep that shape rather than `new`-ing collaborators inside the work
  method.

## 3. Concrete subclass: one file per variant

Override the dispatch getter (mark it `@override`) to return the key **from a shared constant
hash, not a bare string literal**, and override the work method. `static create()` is inherited
from the base — subclasses only redefine it when they need extra defaults.

```js
import BaseEventProcessor from '../BaseEventProcessor.js'

import CONSTANT from '../../../globals/constants.js'

const {
  EVENT_TYPE,
} = CONSTANT

/**
 * Event processor: handle "create" events.
 */
export default class CreateEventProcessor extends BaseEventProcessor {
  /**
   * get: Dispatch key this processor handles.
   *
   * @override
   * @returns {string}
   */
  get eventCategory () {
    return EVENT_TYPE.CREATE_RECORD
  }

  /**
   * Process the event.
   *
   * @override
   * @param {{
   *   event: *
   * }} params - Parameters for processing.
   * @returns {Promise<*>}
   */
  async process ({
    event,
  }) {
    return this.buildResult({
      event,
    })
  }
}
```

- **Why a constant hash for the key**: the same key string appears on the producer side (the
  webhook / message payload) and the consumer side (the getter). A shared constant makes a typo
  a reference error instead of a silent dispatch miss.
- Suffix concrete classes by role (`~Processor`, `~Handler`, `~Converter`), aligned with the
  base class name.

## 4. Bulk loader: auto-discover and pick by getter

The loader resolves the processor directory with `rootPath.to(...)`, loads every class in it via
`DeepBulkClassLoader.create({ poolPath }).loadClasses()`, and instantiates each with
`.create()`. `resolveProcessor(key)` matches on the dispatch getter and returns **`?? null`** on a
miss — there is no default / fallback processor and no throw inside the loader.

```js
import {
  DeepBulkClassLoader,
} from '@openreachtech/renchan'

import {
  rootPath,
} from '../../globals/_.js'

const eventProcessorPath = rootPath.to('app/tools/eventProcessors/concretes')

/**
 * Bulk loader for event processors.
 */
export default class BulkEventProcessorsLoader {
  /**
   * Constructor.
   *
   * @constructor
   * @param {BulkEventProcessorsLoaderConstructorParams} params - Parameters of this constructor.
   */
  constructor ({
    processors,
  }) {
    this.processors = processors
  }

  /**
   * Factory method as async.
   *
   * @param {string} [path] - The path to load processors from.
   * @returns {Promise<BulkEventProcessorsLoader>}
   */
  static async createAsync (path = eventProcessorPath) {
    const processorClasses = await this.loadProcessorClasses(path)

    const processors = processorClasses.map(ProcessorClass =>
      ProcessorClass.create()
    )

    return new this({
      processors,
    })
  }

  /**
   * Factory method.
   *
   * @template {X extends typeof BulkEventProcessorsLoader ? X : never} T, X
   * @param {BulkEventProcessorsLoaderFactoryParams} params - Parameters for the factory.
   * @returns {InstanceType<T>} An instance of the class.
   * @this {T}
   */
  static create (params) {
    return /** @type {InstanceType<T>} */ (
      new this(params)
    )
  }

  /**
   * Load processor classes from the specified path.
   *
   * @param {string} path - The path to load processor classes from.
   * @returns {Promise<Array<typeof import('../eventProcessors/BaseEventProcessor.js').default>>}
   */
  static async loadProcessorClasses (path) {
    return /** @type {*} */ (
      DeepBulkClassLoader.create({
        poolPath: path,
      })
        .loadClasses()
    )
  }

  /**
   * Resolve a processor by dispatch key.
   *
   * @param {string} eventCategory - The dispatch key.
   * @returns {import('../eventProcessors/BaseEventProcessor.js').default | null}
   */
  resolveProcessor (eventCategory) {
    return this.processors.find(processor => processor.eventCategory === eventCategory)
      ?? null
  }
}

/**
 * @typedef {object} BulkEventProcessorsLoaderConstructorParams
 * @property {Array<import('../eventProcessors/BaseEventProcessor.js').default>} processors - The event processors.
 */

/**
 * @typedef {{
 *   processors: Array<import('../eventProcessors/BaseEventProcessor.js').default>
 * }} BulkEventProcessorsLoaderFactoryParams
 */
```

- **Why `?? null` and no fallback processor**: what a miss means (skip? log? error response?)
  is the caller's decision, and it differs per call site. A `'default_processor'` chain inside
  the loader hides unknown keys from the caller.
- Build the loader once (at startup / in a shared instance) and reuse it — `createAsync()` reads
  the filesystem, so don't re-run it per event.

## 5. Caller side

The caller asks the loader for a processor and decides what a `null` means:

```js
const loader = await BulkEventProcessorsLoader.createAsync()

const processor = loader.resolveProcessor(eventCategory)

if (!processor) {
  return null
}

return processor.process({
  event,
})
```

## 6. Testing

Following the `hoc-jest` skill, split files per class.

- **Base class**: each abstract member throws (`eventCategory`, the work method); `static create()`
  returns an instance of the called class.
- **Each concrete subclass**: inheritance (extends the base); the dispatch getter returns the
  expected constant; the work method's behavior with `test.each()` over its variable inputs.
- **Bulk loader**: `resolveProcessor()` returns the matching processor for each known key
  (`test.each()` over the keys), and **`null` for an unknown key** — don't skip the miss case.

## Finishing checklist

- [ ] Did the change add a variant as **one new subclass file**, without editing a dispatch chain or an import list?
- [ ] Is dispatch a **getter** overridden per subclass (no `processorName` field, no `static getName()`)?
- [ ] Do the abstract base members throw `new Error('... must be implemented in subclass')`?
- [ ] Does the dispatch getter return its key from a **shared constant hash**, not a bare string literal?
- [ ] Does the loader auto-discover via `DeepBulkClassLoader` from a `rootPath.to(...)` directory, and does `resolveProcessor()` return `?? null` on a miss (no fallback processor, no throw)?
- [ ] Is `static create()` the factory everywhere, with constructor deps defaulted in `create()` when needed (per `class-design`)?
- [ ] One class per file, suffixed by role (`~Processor`, `~Handler`, `~Converter`)?
- [ ] Do tests cover the abstract throws, each subclass's getter + behavior, and the loader's known-key / unknown-key cases?