The core model
The ramify core model is a small set of rules for managing a hierarchical, multi-file module system in TypeScript, as explained in why hierarchical modularity. The concepts are formally defined in the glossary.
NB These examples use ordinary src/ and ignore tags. Tag restrictions
and the separate src/tests/ source area are explained in the tags page.
Example 1: Decomposing a simple app
The example app has two different domains: invoicing and shipping. They
share some concepts, but otherwise they're mostly independent and deserve
their own modules. Rather than showing the finished app at once, we'll build
it up in three steps, starting from its simplest corner. (Symbols are
self-explanatory, with PascalCase for types and camelCase for values.)
Step 1: shipping - sharing between parent and child
shipping delegates its hardest problem to a routingOptimization
sub-module, which implements optimizeRoute against the ShipmentPlan type
that shipping itself defines. Here is that fragment alone, under the app's
(so far empty) root:
Every box is a module: a directory declared as
a module root. The module hierarchy is a tree - app contains
shipping, which contains routingOptimization - and every source file
belongs to exactly one module.
Inside a module, ramify changes nothing: files export and import exactly as
TypeScript always did. When a file exports a symbol, the module it belongs to
owns that symbol - shipping owns
ShipmentPlan, and routingOptimization owns optimizeRoute: the first
thing each box lists.
Crossing a module boundary is where the model begins, and its whole content
is one rule: a file may import a symbol exactly when the symbol is
available in the file's module. A
module's own symbols are always available in it; everything else is closed
by default, and becomes available only if some module
exposes it. Here, shipping is
exposing ShipmentPlan to its descendants, and that's why ShipmentPlan is
available in routingOptimization.
Exposure comes in exactly two forms - a module may expose any symbol it owns
or receives to its parent (available one level up, marked ▲) or to
its descendants (available in its whole subtree, marked ▼). Those are
the only decisions the model knows, and each one is drawn as a filled
dot. This fragment has one of each: the ShipmentPlan exposure just
described, and routingOptimization exposing optimizeRoute to its parent.
The second thing each box lists is what the module
receives: the symbols exposed to it
by a direct child or by a proper ancestor, each row naming the module it
came from. optimizeRoute arrives in shipping as from routingOptimization, and ShipmentPlan arrives in routingOptimization as
from shipping.
And notice app: it receives nothing. Neither symbol was exposed any
further, so neither reaches the root. The default everywhere, for every
symbol, is "goes no further" - reach ends where
decisions end.
The two channels are also asymmetric, and ShipmentPlan is the witness.
Exposing to descendants is final and bounded: one decision, whole subtree,
nothing ceded - and no way out, because only an exposure to the parent could
ever carry a symbol out of its subtree. So app is not allowed to import
ShipmentPlan while shipping's descendants are: the parent is the
outsider of an exposure to descendants, root included.
The diagrams on this page are live. Click any traced symbol, in the legend or on a row that lists it, to isolate its chain: everything else dims, the symbol's flows move the way its exposures travel, and its arrivals blink in every box it reached. Click the background to clear the selection. Each figure also pans and zooms: drag to move around, scroll over it to zoom about the pointer, and the buttons in the top-right corner step the zoom or restore the whole picture.
Step 2: invoicing - sharing with siblings
invoicing keeps its own shared vocabulary in an invoicingLibrary module,
whose InvoiceModel is used by both invoiceComputation and invoicePDF.
But look at what that requires: invoicingLibrary cannot hand a symbol to
its siblings, because no decision can. A decision never names a sibling,
never the root, never "everyone": a module only addresses its parent or its
own subtree, about symbols it already has. Every decision is local - so
long reach can only exist as a chain of one-hop decisions, each made by a
module the symbol had reached.
That chain is exactly what invoicing builds. invoicingLibrary exposes
InvoiceModel to its parent; invoicing, which now has the symbol
available, exposes it to its descendants - and the exposure reaches
invoiceComputation and invoicePDF, the owner's own siblings. Two
decisions, and invoicingLibrary works as a domain library: its type is
shared across the domain and invisible outside it.
Read invoiceComputation's box: InvoiceModel arrives as from invoicing -
the row names the module that exposed it, not the owner. And read what is
not there: InvoiceModel never arrives in app, because invoicing
exposed it to its descendants only. A domain keeps its vocabulary to itself
unless it also decides to expose it to its parent. Select InvoiceModel and the whole story
plays out inside the domain: one hop up, a turn, and every arrival blinks
under invoicing.
Step 3: globalLibrary - sharing globally
One concept is shared by both domains: Money. That's why we create a
globalLibrary module containing a moneyUtils module, with a
computeTotal function which we want to make globally available. By now the
pattern is predictable - there is no "global" switch, only a longer chain:
moneyUtils exposes computeTotal to its parent, globalLibrary re-exposes
it to its parent, and app exposes it to its descendants, turning the chain
downward into every branch. Here is the complete
app: nine modules, four symbols, and seven dots that are its entire access
policy - complexity is measured in decisions, not boxes.
Now the punchline. Three leaf modules made the identical decision - expose
one symbol to their parent - and ended up with three different reaches.
computeTotal is re-exposed to the parent once more and then to descendants
at app, so every module may import it. InvoiceModel is exposed to
descendants one level lower, at invoicing, so its two consumers share it
and nothing outside that subtree can. optimizeRoute is composed by
shipping and exposed no further, so it goes one hop and stops. None of the
three owners decided that; the modules above them did. Exposing a symbol to
the parent cedes its onward exposure.
The full picture also shows what never happens. shipping may not import
InvoiceModel: an exposure to descendants never leaves the exposing module's
subtree, and invoicing did not expose the symbol to its parent, so nothing
outside its subtree ever sees it. The mirror case is the same rule read the
other way - invoicing may not import optimizeRoute, because shipping
exposed it nowhere. There is no sibling channel. Select either symbol and
the absence is the picture: the flow stops, and no arrival ever blinks in the
other domain.
A "global library" and a "domain library" are the same mechanism at different
altitudes. app exposes computeTotal to its descendants and invoicing
exposes InvoiceModel to its descendants; the difference between
application-wide and domain-wide reach is which module made the identical
decision. The root is an ordinary module
whose subtree happens to be everything - it owns no code at all here, and
still carries the application's vocabulary.
Example 2: Both channels at once
In this smaller app, we keep the pricing rules in a pricing module, with
order submission in a checkout module beside it - and PriceModel is
wanted on both sides of pricing's boundary: by its own sub-modules and by
the app around it.
One owner, one symbol, both exposure decisions. pricing owns PriceModel
and exposes it to its descendants and to its parent - the first ▲▼
marker in the series, and it is not a third channel: two independent one-hop
decisions that happen to share a row. The exposure to descendants reaches
discounts and taxes; the exposure to the parent reaches app, which
receives the type and stops it.
The lesson is the ceiling. Both channels at once buy exactly the owner's
subtree plus its parent - structurally the most any owner can reach alone.
checkout, the sibling that would most plausibly want PriceModel, is not
allowed to import it: crossing to a sibling was never pricing's decision
to make, and app made none. Select PriceModel and both flows march out
of a single row - up one hop, down one subtree - and stop.
Reach ends where decisions end. There are three dots in this picture, all of
them on pricing and checkout; app has none, and both symbols stop
there. (submitOrder plays the role known from Example 1's optimizeRoute -
exposed to the parent, composed, stopped - and wears that symbol's color.)
Two footnotes
Exposure to descendants is uniform. It reaches every branch of the
exposing module's subtree - including the branch the symbol originally came
up through: app's exposure of computeTotal lands in globalLibrary and
moneyUtils too, the very branch that sent it up (the figure omits those two
arrival rows, because the exposure changes nothing there - both boxes already
had the symbol). Re-exposed symbols retain their original identity and owner;
their path creates no exception to the uniform descendant audience. A family
that does not want its symbol coming back from an ancestor has exactly one
option: not exposing it to the parent at all.
Ancestor exposures survive subdivision. Exposure to descendants addresses
a subtree, not a shape. Splitting routingOptimization into two sub-modules
would leave ShipmentPlan visible in both: shipping's exposure still reaches
its whole subtree. Former same-owner access does change, however. Imports
between the new owners need exposure through the ordinary channels.
Where the precision lives
The tree rules on this page are half the complete rule; the other half is on the tags page, where two more example universes keep the tree trivial and let a tag be the only variable.
This page teaches the model. The complete, authoritative rules are in
Cross-Module Importability Principles at
docs/model/cross-module-importability.principles.md in the repository. Its
companion, docs/model/glossary.md, defines the vocabulary reproduced in
the glossary. The website must conform to these two internal
documents.