Lesson 03 — Parents and inheritance
Goal: by the end of this lesson you understand what :parent
actually does at the data level, when to use :parents (plural),
and why graphden's inheritance model is "BFS through parent-ids,
closer-wins on bindings."
Concepts introduced: parent-ids, single inheritance,
multiple inheritance (MI), BFS closure, closest-wins binding,
final value, inheritance chain.
A :parent is a row pointer
When you write:
{:name :hello-handler
:parent :const
:args {:value "Hello!"}}
…graphden stores :hello-handler as a :fn row whose
:parent-ids field contains exactly one entry: :const's row id.
That's it. The field is called parent-ids (plural) even when
you wrote :parent (singular) — both forms write into the same
column. :parent :const is shorthand for :parents [:const].
Two consequences:
- A fn with
:parent-ids []has NO ancestors. It's a base function or a primitive type-row. - A fn can have more than one parent (multiple inheritance). We'll see when you'd want that.
Single inheritance — the common case
{:name :base
:parent :const
:args {:value {:status 200 :body "ok"}}}
{:name :authed
:parent :base}
:authed inherits from :base, which inherits from :const.
The inheritance chain of :authed is:
:authed → :base → :const
When the executor runs :authed, it walks this chain to find:
- Which slots are visible (the union of slots declared by every ancestor).
- Which bindings apply per slot (the CLOSEST binding to the current fn wins).
For :authed.:value — :const declares the slot, :base binds it
to a literal map, :authed adds nothing. The closest binding is
:base's, so :authed returns the same map.
Could :authed carry its own :value and win? At run time the
closest binding does win — but a value an ancestor already set is
final in graphden: the editor never offers a + for it, and the
API refuses the write (value-override). The rule is the LEGO one —
arguments aren't overridden, a different behaviour is a different
fn-def. Want a 401 variant? Extend :const (or a sibling that
carries the rest of the shape) and set the value there:
{:name :denied
:parent :const
:args {:value {:status 401 :body "denied"}}}
Lesson 07 goes through what is final, what is optional, and how you seal a slot on purpose.
Multiple inheritance (MI) — when you'd use it
Sometimes a fn cleanly belongs to two parents at once — two
orthogonal axes you want to combine. This is exactly how
graphden builds its HTTP response matrix (see the real
web.response module). One axis sets the status code; the other
sets the content-type header; a concrete response is the two
combined:
{:name :ok-response ; STATUS axis: binds :status
:parent :ring-response
:args {:status 200}}
{:name :json-content-type ; CONTENT-TYPE axis: binds :headers
:parent :ring-response
:args {:headers {"Content-Type" "application/json"}}}
{:name :json-ok-response ; combine both axes
:parents [:json-content-type :ok-response]}
:json-ok-response inherits slots from BOTH :json-content-type
and :ok-response. The inheritance chain becomes a BFS closure:
:json-ok-response
↓ ↓
:json-content-type :ok-response
↓ ↓
:ring-response
This is diamond inheritance — both parents themselves inherit
from :ring-response. That's fine here because each axis binds a
DIFFERENT arg (:headers vs :status), so there's no conflict;
the remaining :body slot stays unbound and propagates to the
child as a free argument. Bindings still resolve closest-wins.
MI restrictions
Two failure modes guard an MI parent set:
- Arg-name collision — two parents expose DIFFERENT slots under the same user-visible name. This is a structural gate: the save itself is rejected, in the editor and at package sync alike. The name has to be disambiguated (rename one side) before the row lands.
- Conflicting contracts on a SHARED slot — both parents bind the same inherited slot to incompatible values, or pin it to incompatible types (neither a subtype of the other). This is a TYPE error: at package sync it fails the load; in the editor it is a recorded diagnostic — the fn saves, gets a ⚠ badge and shows under the Explorer's ⚠ type errors filter, and refuses to execute until one side backs off (the doctrine is in Lesson 08, the filter in Lesson 19).
The fn-card as a chain visualizer
Open any composed fn in the editor. The card shows multiple ROWS, each labeled with an ancestor's name. The TOP row is the fn itself; the rows below are its ancestors in BFS order. Each row carries:
- The ancestor's name.
- Any binding that ancestor contributed for the slot you're looking at.
Clicking an ancestor row unfolds the card to that level (lesson 02):
the ancestor's own bindings and edges appear under it, so you can walk
the chain visually without leaving the card. To OPEN the ancestor as
the selected fn instead, use its row in the Explorer, or ⋯ on the
card → ↗ (open in a new tab).
Try it
In the editor:
-
Type
addin the Explorer filter and click theaddrow (incore.arithmetic). Its card shows ONE row — a base function has no parents. -
Click
⋯on theaddrow, choose Extend, typeadd-10, then Save. That creates a child whose:parentis:add— infns.ednterms:{:name :add-10 :parent :add :args {:nums [10]}}The popover's in line picks the child's namespace — the parent's by default, so a child lands next to what it extends. ↑ moves the choice one level up (
core.arithmetic→core→ root); + opens a one-segment field for a NEW sub-namespace under the current choice, created together with the fn on Save. A fn can also be moved later via ⋯ → Namespace → Move to another namespace…, and renamed via ⋯ → ✎ Rename (both are always safe, callers included — everyone references the fn by identity, not by its name or namespace). Slipped — wrong name, wrong parent? A toast offers Undo, and for 30 seconds the undo stays a keystroke away — Ctrl+Z (⌘Z on a Mac) or Space, u: it deletes the fn you just made, writes the old name back, or brings back a fn you deleted a moment ago. It is an inverse write, not an erased one — the Versions tab keeps both — and it refuses, with the reason, once something else already depends on the slip. -
The editor opens
add-10: its card shows TWO rows,add-10on top andaddbelow it.add-10inheritedadd's:numsslot — a sequence. Click the+placeholder on:nums, choose Append literal, enter10, then Save. The seed becomes the list's first item. -
Click
⋯on theadd-10row, then ▶ Run, then Run. The bound seed makes the sum10— the child runs the parent's implementation with its own bindings. -
Now go the other way. Extend went DOWN — a child under
add. ⋯ → ⬆ Wrap in new fn goes UP: a new fn that CALLSadd-10and does something with its result. Click⋯on theadd-10row, choose ⬆ Wrap in new fn, typeto-strin the picker and pickcore.strings.to-str— the wrapper's parent. Name itadd-10-text, leave the into slot on:value, then Save. The editor creates the wrapper withadd-10already bound into it and opens it: your sum, stringified. (The same form offers take over the name: the wrapper becomesadd-10and the fn you wrapped is renamed_add-10-impl— the refactoring for "everyone who callsadd-10should now get the wrapped result".)Compatible free slots sort first with a ✓; a slot marked "(bound in the parent — final)" cannot take the fn — the parent already valued it, and a value, once set, is final (above). That is how you add a step on top of existing logic without re-assembling it by hand.
And Extend has an in-place form for building the other way, from the outside in. On
add-10-text's canvas,add-10sits as a card because:valuebinds it.⋯ → + Extendon THAT card (not on the root) creates the child and puts it in:valuein place ofadd-10— you stay onadd-10-text's canvas, and the new card's own+placeholders are right there to bind. So a pipeline is built top-down: bind the base fn a slot needs, extend it where it sits, bind the child's slots on its card, repeat. Lessons 12, 18 and 35 build their fns this way. -
Now give a fn TWO parents, over the two real response axes. Filter for
ok-response(web.response),⋯→ Extend →tutorial-json-ok→ Save. On the new card click⋯on theok-responserow — the parent row, one level down — and choose + (add another parent); typejson-content-typein the picker and click its row. Infns.ednthe same fn is:{:name :tutorial-json-ok :parents [:json-content-type :ok-response]}The card now branches: your fn on top, the two axes (
:json-content-typeand:ok-response) below it, and their shared:ring-responseancestor beneath both — the diamond you saw above. Each axis row is clickable, so you can walk into either branch.:tutorial-json-okleaves:bodyfree; bind it (e.g."{}") and Run to get a200 application/jsonresponse. The same parent-row⋯also has × — remove a parent; the bindings on slots only that parent brought go with it (the editor asks first). The picker offers any fn that shares your fn's base and sets args of its own; the server refuses a cycle or a slot-name collision (MI restrictions above) and says why.
What we glossed over
- Slots as DB entities — when two parents declare a slot with the same name, how does the slot identity work? Lesson 04.
- Free arguments — what if a slot is declared but NEITHER ancestor binds it? Lesson 05.
- HOF-typed slots —
:fn-typed slots behave differently during inheritance. Lesson 09.