Skip to main content

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:

Traced symbols■ stopoptimizeRoute - to its parent, then stopsShipmentPlan - to its descendants onlyoptimizeRouteappapp rootowns(nothing owned)shippingowns▼ShipmentPlanreceives·optimizeRoutefrom routingOptimizationroutingOptimizationowns▲optimizeRoutereceivesShipmentPlanfrom shippingNode▲exposed to parent▼exposed to descendantsNeither symbol reaches app: reach ends where decisions end.Files inside one module import each other freely; those imports are not drawn.
drag to pan · scroll to zoom

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.

Traced symbols■ stopInvoiceModel - owner → parent → that subtreeInvoiceModelInvoiceModelappapp rootowns(nothing owned)invoicingreceives▼InvoiceModelfrom invoicingLibraryinvoicingLibraryowns▲InvoiceModelinvoiceComputationreceivesInvoiceModelfrom invoicinginvoicePDFreceivesInvoiceModelfrom invoicingNode▲exposed to parent▼exposed to descendantsInvoiceModel travels a chain of two decisions: up to invoicing, then down to its subtree.invoicing exposed it to its descendants only, so it never reaches app.Files inside one module import each other freely; those imports are not drawn.
drag to pan · scroll to zoom

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.

Traced symbols■ stopcomputeTotal - owner → parent → root → everywhereInvoiceModel - owner → parent → that subtreeoptimizeRoute - exposed to the parent, composed, stopped: parent onlyShipmentPlan - owner → descendants: never leaves the subtreecomputeTotalcomputeTotalcomputeTotalcomputeTotalInvoiceModelInvoiceModeloptimizeRouteappapp rootreceives▼computeTotalfrom globalLibraryglobalLibraryreceives▲computeTotalfrom moneyUtilsmoneyUtilsowns▲computeTotalinvoicingreceives▼InvoiceModelfrom invoicingLibrarycomputeTotalfrom appinvoicingLibraryowns▲InvoiceModelreceivescomputeTotalfrom appinvoiceComputationreceivescomputeTotalfrom appInvoiceModelfrom invoicinginvoicePDFreceivescomputeTotalfrom appInvoiceModelfrom invoicingshippingowns▼ShipmentPlanreceives·optimizeRoutefrom routingOptimizationcomputeTotalfrom approutingOptimizationowns▲optimizeRoutereceivescomputeTotalfrom appShipmentPlanfrom shippingNode▲exposed to parent▼exposed to descendantsThe three owners that exposed to their parent made the same decision; the three reaches were decided entirely above them.Exposing to descendants is final and bounded: one decision, whole subtree, no way out. Exposing to the parent cedes onward exposure.There is no sibling channel: exposing a symbol to the parent gives siblings nothing.Files inside one module import each other freely; those imports are not drawn.
drag to pan · scroll to zoom

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.

Traced symbols■ stopPriceModel - both channels at once: parent + own subtree, nothing moresubmitOrder - exposed to the parent, composed, stopped: parent onlyPriceModelPriceModelsubmitOrderappapp rootreceives·PriceModelfrom pricing·submitOrderfrom checkoutpricingowns▲▼PriceModeldiscountsreceivesPriceModelfrom pricingtaxesreceivesPriceModelfrom pricingcheckoutowns▲submitOrderNode▲exposed to parent▼exposed to descendants▲▼ is not a third channel: two independent one-hop decisions sharing a row.Both channels at once buy exactly the owner's subtree plus its parent - the most any owner can reach alone.Reach ends where decisions end: app made none, and both symbols stop there.
drag to pan · scroll to zoom

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.