skip to content

When should a dict dispatch table in an ETL export give way to objects with methods instead?

level: seniorimportance: should knowfreq 40%

answer

  1. Count the verbs each branch actually needs
  2. Two parallel dicts keyed on the same strings
  3. Where the staged state lives between calls
  4. The lookup stays, the values change
  5. `apply` and `rollback` from the same implementation

basics

~20 s

When each branch stops being one stateless verb. Once a record kind needs setup, staged state, a commit and a rollback that belong together, a dict of functions has nowhere to hold the pairing; a table of classes whose instances carry that lifecycle does.

solid answer

~60 s

A dict of callables is the right structure while every branch is one stateless function of its input. It stops fitting when a branch grows a lifecycle: an export handler that stages a 2.4 GB working set needs to open a target, stream, commit, and undo what it staged if a later kind fails — four related operations plus intermediate state, which a flat mapping of names to functions cannot express. Keep the table, but map the key to a **class** rather than a function: the caller instantiates the handler and calls `apply` and `rollback` on it, so the do and the undo are guaranteed to come from the same implementation and share the staged state. Note what does not change: the key is still data arriving from outside — a kind string in a manifest — so you cannot make it a method on someone else's type, and dispatch has to stay a lookup at the boundary. That is the important asymmetry: the table survives, the values change from functions to constructors.

code

python · 19 lines
python
class RowsExport:
    def __init__(self):
        self.staged = []

    def apply(self, record):
        self.staged.append(record)
        return len(self.staged)

    def rollback(self):
        self.staged.clear()

EXPORTS = {"rows": RowsExport}

job = EXPORTS["rows"]()
job.apply({"id": 1})
job.apply({"id": 2})
print("staged:", job.staged)
job.rollback()
print("after rollback:", job.staged)

go deeper

for a junior

Know that a dispatch table maps a key to something callable, and that a class is callable too — looking one up and instantiating it works the same way as looking up a function. You are not expected to make the design call yet.

for a middle

Explain the mechanics of the switch: the lookup stays, the values become classes, and the instance holds the state that functions had nowhere to keep. Be ready to describe why two parallel dicts of do and undo functions drift apart.

for a senior

Argue it from the partial-failure case. Show that a table of classes makes it structurally impossible to register a handler that can act but not undo, that the staged state lives on the instance, and that each implementation's rollback becomes testable without running the pipeline.

for a principal

Own the boundary rule: external keys resolve through a lookup, behaviour lives on the object behind it, and neither absorbs the other. Decide when the ceremony is worth it, how the handler contract is stated and enforced, and who may register a new kind.

## What a dict of functions actually assumes A dispatch table encodes a specific assumption: each branch is a single, stateless operation with a uniform signature. `kind -> callable(record) -> result`. Where that assumption holds, the table is excellent — the mapping is data, the handlers are independently testable, and adding a kind is a one-line edit next to a new function. The assumption breaks in three recognisable ways. **The branch is more than one verb.** An export to a warehouse per record kind is rarely a single call. It opens a target, validates a schema, streams rows, commits, and — when something later in the job fails — undoes what it staged. Once a kind owns two or more operations that must come from the *same* implementation, a flat dict has nowhere to say so. Two parallel dicts, `APPLIERS` and `ROLLBACKS`, keyed on the same strings, is the classic symptom: nothing enforces that both have the same keys, and the day someone adds a kind to one and not the other, a partial-failure rollback silently skips it. **The branch carries state.** A handler streaming a 2.4 GB working set holds an open target, a staged batch and a byte count. Functions have no place for that between calls, so it ends up in module globals or is threaded through every signature. An instance holds it naturally, and the state's lifetime becomes the object's lifetime rather than the process's. **The branches share a lifecycle.** If every kind needs the same open/close discipline, that discipline wants to live in one place — a base class, or a context-manager protocol implemented once and inherited — rather than being repeated at the top and bottom of each function. ## What replaces it, and what does not The move is smaller than it sounds, and the common mistake is to overshoot. You do **not** delete the table. The key still arrives as data — a kind string from a manifest, a message field, a config entry — and you cannot attach a method to a string that arrives over the wire. What changes is what the values are: ```python EXPORTS = {"rows": RowsExport, "blobs": BlobExport} job = EXPORTS[kind]() # or EXPORTS[kind](config) job.apply(record) ... job.rollback() ``` The lookup is unchanged; the value is a constructor rather than a function. `apply` and `rollback` are now guaranteed to come from the same implementation and to see the same staged state, which is exactly the guarantee two parallel dicts could not give. If the objects need a defined scope, implementing `__enter__` and `__exit__` puts the commit-or-rollback decision in a `with` statement instead of in every caller. A `typing.Protocol` — or an abstract base class if you want the check at definition time — states what an export must provide, so a new kind that forgets `rollback` fails a type check rather than a production run. This is the boundary that keeps the two mechanisms honest: **the mapping from external data to implementation is a lookup; the behaviour behind it is the object's.** Pushing behaviour into the table gives you the two-parallel-dicts problem; pushing the lookup into the objects gives you a base class that has to know every subclass, which is worse. ## When to keep the plain functions Do not make this move by reflex. Keep functions when the handlers really are one-shot and stateless — a formatter per output kind, a parser per field type, a metric per name. Objects there add a constructor call, an extra file of ceremony, and a layer to step through in a traceback, in exchange for a lifecycle nobody needs. The honest test is whether you can name a second operation that must come from the same implementation as the first. If you cannot, the dict of functions is not a compromise, it is the right answer. Also resist the third option that looks tidy and is not: making the record classes themselves know how to export. That works only when the kinds are your own types *and* exporting is intrinsic to them. In an ETL job the record shapes usually come from upstream and exporting is a concern of your pipeline, so bolting the behaviour onto the data types couples them to a destination they should know nothing about. ## The failure this prevents The reason this shows up in senior interviews is the partial-failure case. A job exports several kinds, the fourth one fails, and the first three must be undone. With paired functions the undo path is the least-exercised code in the system and the easiest to leave incomplete; with a table of classes, every implementation that can be dispatched necessarily carries both halves, the staged state is right there on the instance, and the rollback path can be tested per implementation without running the pipeline. That is the whole argument: not elegance, but making it structurally impossible to register a handler that can do the work and cannot undo it.

  • What is wrong with keeping two dicts, one of apply functions and one of rollback functions, keyed on the same strings?
    Nothing enforces that the key sets match or that a pair belongs together. Someone adds a kind to the apply table and forgets the rollback table, and the gap only shows up during a partial-failure rollback — the least-exercised path in the system. There is also nowhere to keep the state the undo needs, so it gets threaded through arguments or parked in a global. One table of classes makes the pairing structural.
  • If the handlers become objects, why keep the dict at all?
    Because the dispatch key is external data — a kind string from a manifest or a message — and you cannot attach a method to a value that arrives over the wire. The lookup is the boundary between untrusted input and your implementations, and it stays a table you can print, validate against an enum at startup, and test for completeness. Only the values change from functions to constructors.
  • When would you leave the dispatch table as plain functions despite a lot of branches?
    When each branch is genuinely one stateless operation — a formatter per output kind, a parser per field type. The test is whether you can name a second operation that must come from the same implementation as the first. If you cannot, objects add a constructor, a file of ceremony and an extra frame in every traceback in exchange for a lifecycle nothing uses.
  • Why not put the export behaviour on the record classes themselves?
    Because in a pipeline the record shapes usually come from upstream and exporting is your concern, not theirs. Attaching it couples data types to a destination they should know nothing about, and it is not even available when the types are third-party or plain dicts. Behaviour belongs on the record only when the types are yours and the operation is intrinsic to what they are.

A dict of functions is a rack of single-use tools; once a job needs a tool that remembers what it has already done and can put it back, you hand out kits instead of tools, but you still pick the kit off the same rack.

saying these in an interview costs you the question

  • Keeps parallel dicts of apply and rollback functions keyed alike
  • Deletes the lookup entirely and expects data from the wire to have methods
  • Parks per-export state in module globals to keep the handlers functions
  • Converts every dispatch table to classes as a matter of style
  • Bolts export behaviour onto record types that come from upstream
  • Assumes the rollback path works because the apply path is tested

context