Skip to content

Philosophy — Why Type-Declared Dependencies

This document explains the why behind the type-declared dependency pattern used for the extraction pipeline sample — specifically the Axis/Icici bank variation. It is, together with Design, self-sufficient: a fresh agent should be able to pick up the codebase from here and reason about (and extend) the pattern without needing the original session context.


One Idea

A concrete class's annotated slot type is the dependency decision.

Bank variation is expressed entirely as class annotations — never as imperative wiring, never as __init__ parameters, never as a composition-root that assembles objects by hand. The tree of ports a pipeline needs is declared once, and a bank differs from the generic pipeline only by which concrete types are pinned onto which slots.


The Three Layers

The pattern is built from three fixed layers. Understanding which layer something belongs to is the whole mental model.

Layer 1 — ABC contracts (samples/extraction_pipeline/extraction_pipeline_abc.py)

Authoritative declaration of need ("WHAT a stage requires").

  • Each port is an ABC.
  • It declares attributes as class-level annotated defaults (e.g. TransactionParserPort.min_numbers: int = 3), optionally grouped as a nested struct via a @dataclass (e.g. source: ExtractionSource).
  • It declares port slots as annotated attributes whose type is another ABC (e.g. TransactionParserPort.number: NumberPort, amount_balance: AmountBalancePort).
  • It declares @abstractmethod bodies — the method signatures each concrete implementation must provide.

The ABC never runs, never holds an instance, and never says which implementation to use. It only says what the shape is.

Layer 2 — Generic concurrent implementations (samples/extraction_pipeline/extraction_pipeline_impl.py)

The common default behavior ("Impl* — the shared adaptor").

  • class ImplTransactionParserPort(TransactionParserPort) subclasses the ABC, implements every @abstractmethod, and re-pins its port slots to concrete Impl* types: number: ImplNumberPort, desc: ImplDescPort, amount_balance: ImplAmountBalancePort.
  • ImplAmountBalancePort in turn pins number: ImplNumberPort and ambiguity: ImplAmbiguityPort.
  • The root, ImplExtractionPipeline, pins its five stage slots to Impl* ports: ingest, raw_lines, txn_blocks, txn_dicts, raw_expense.

Impl* is the default that shared, bank-agnostic behavior lives in. Any slot a bank does not override falls back to Impl* at runtime.

Layer 3 — Bank specializations (samples/extraction_pipeline/banks/{axis,icici}/pdf.py, samples/extraction_pipeline/extraction_pipeline_banks.py)

The variation ("what differs per bank").

  • Bank classes subclass the concrete Impl* classes, never the ABC — they extend, they never reinvent the contract.
  • Each Axis*/Icici* class overrides only the members that differ from the generic Impl*; everything shared is inherited.
  • A bank root is a thin annotated subclass of ImplExtractionPipeline that re-pins only the slots that differ:
Python
class AxisExtractionPipeline(ImplExtractionPipeline):
    txn_dicts: AxisTxnDictsPort

The Re-Pinning Cascade

Bank-specific wiring is hierarchical and cascading. Each level narrows exactly one slot, and its narrowed type drags the next level's narrowing along with it:

Text Only
AxisExtractionPipeline
  └─ txn_dicts: AxisTxnDictsPort              (only override on the root)
      └─ parser: AxisTransactionParserPort
          ├─ number:  AxisNumberPort          (get_numbers → all clean nums)
          ├─ desc:    AxisDescPort            (STARTTERS)
          └─ amount_balance: AxisAmountBalancePort
              └─ number: AxisNumberPort       (re-pin: same bank's number port)

Siblings that are already correct in Impl* are inherited, not redeclared. E.g. the parser's date slot comes from ImplTransactionParserPort; the amount-balance port's ambiguity comes from ImplAmountBalancePort. Re-declaring them adds mental load on the extender with zero behavior change.

Rule — only redeclare the slot you are actually changing. If a slot's behavior is already correct from Impl*, leave it out.


The Two Fidelity Guarantees This Pattern Preserves

  1. Shared behavior stays shared. Slots like ingest, raw_lines, txn_blocks, raw_expense, parser date, and ambiguity are bank-agnostic. Leaving them at the Impl* default is deliberate, not an omission.
  2. Banks never reinvent. Axis*/Icici* only extend Impl*. The ABC contract in samples/extraction_pipeline/extraction_pipeline_abc.py is defined once and shared by every bank.

What This Pattern Is NOT

  • No imperative assembly. No build_axis_pipeline() wiring objects by hand.
  • No constructor injection. Impl* classes have no __init__; wiring is done by assigning concrete port instances onto annotated slots.
  • YAML and ABCs are interchangeable, and both optional. The .yaml spec and the _abc.py module are two representations of the same contract. hexa is agnostic of the extraction pipeline sample and never imposes a workflow: a user can hand-write the ABC file, start from YAML (ABC generated), start from an ABC file (YAML emitted), or use both as a spec. The optional utilities in Design § Utilities translate between them and can verify they agree — but the bank Impl*/specialization files are always hand-written (their slots are a manual choice) and never generated.

Runtime Instantiation

The annotations are the decision; hexa.build(cls) (see Design § Container) turns a root class into a wired instance by reading __annotations__ + MRO, recursively instantiating each pinned concrete type, and setattring the child onto the parent slot. This document is scoped to the notation — the rest of the tree can be expressed purely as class annotations and filled at runtime by the container.