Lesson 28 — Packages: namespaces, fns.edn, impls.clj, deps
Goal: by the end of this lesson you can write your own package — namespaces, base-fns, and fn-defs — that loads alongside graphden at startup, and you understand how the first-party packages are layered.
Concepts introduced: package, module, fns.edn,
impls.clj, package.edn, :dependencies, :package-names,
defbase, the impls map, namespace tree, optional package.
What's a package
A package is a directory under resources/packages/ that groups
related fn-defs and base-fn impls. Graphden's first-party
packages are LAYERED — each depends only on the ones below it:
| Package | What lives there | Depends on |
|---|---|---|
core |
Arithmetic, logic, HOF, collections, strings — the "standard library" | — |
storage |
Postgres + versioned/branch storage primitives | core |
web |
HTTP server, reitit routing, hiccup HTML, CRUD, Vault | core storage |
app-base |
The reitit route-building vocabulary (:route, method/auth route templates) |
core web |
app |
The editor UI, /api routes, branches, services, secrets |
core web storage app-base |
registry |
In-graph package publish / install / fork / export — optional | core web storage app-base |
mcp |
The /mcp JSON-RPC AI endpoint — optional |
core web app |
tenancy-admin is a further package loaded only when the
multi-tenant addon is active. You can add your own — say
mycorp — and the loader picks it up at startup, no code change
beyond writing the files.
The shape on disk
resources/packages/mycorp/
├── package.edn ; metadata + dependencies
├── auth/ ; a "module"
│ ├── fns.edn ; fn-defs declared in this module
│ └── impls.clj ; Clojure impls for the module's base-fns
└── billing/
├── fns.edn
└── impls.clj
Two levels of grouping:
- Package (
mycorp/) — what loads at startup; the unit of dependency. - Module (
auth/,billing/) — fns and their impls co-located; the unit of grouping by topic. Maps to a graphden NAMESPACE the editor's explorer shows.
package.edn
{:name "mycorp"
:version "1.0.0"
:description "MyCorp application code"
:dependencies ["core" "web" "app"]
:modules ["auth" "billing"]}
| Key | Meaning |
|---|---|
:name |
Package name — a string, matches the directory |
:version |
Package version (semver string) |
:description |
Sidebar tooltip for the top-level namespace |
:dependencies |
Package names (strings) this one needs. The loader topo-sorts so deps load first, and pulls them TRANSITIVELY |
:modules |
Which subdirectories to load (strings). Order matters only if one module's :namespace aliases another's types |
:services |
Optional — a package can declare a fn to keep running (the app package declares :web-server this way). See Lesson 32 |
The loader fails fast on a missing dependency.
fns.edn — declaration
Each module's fns.edn is a map with a :namespace, a
:description, and a :fns vector:
{:namespace "mycorp.auth"
:description "Authentication primitives — bearer tokens, password hashes."
:fns
[{:name :hash-password
:description "Argon2 hash of the password. Slow on purpose."
:args {:password {:type :text} :salt {:type :text}}
:return-type :text
:effects #{}}
;; A fn-def composing the base-fn above
{:name :login-route
:parent :json-handler
:args {:body :build-login-response}}]}
:namespace is a STRING and is the FULL namespace path — it does
NOT have to match the package name. Identity is
uuid-v5(:namespace, :name), so the namespace, not the directory,
is a fn's stable identity. (That's how app-base can hold fns
whose namespace is app.routes.auth: the routing vocabulary
moved packages without changing any fn's id.)
Inside :fns, each map is either:
- a base-fn declaration —
:args+:return-type, impl inimpls.clj, or - a fn-def —
:parent/:parents+:argsbinding the parent's slots. Pure composition, no Clojure. (Lessons 01–04.)
impls.clj — Clojure code
Two parts: defbase fns, and an impls map that links each
fn-name keyword to its impl.
(ns graphden.packages.mycorp.auth.impls
(:require
[graphden.executor.defbase :refer [defbase]]
[my.lib.argon2 :as argon2]))
(defbase hash-password-fn [password salt]
(argon2/hash password salt)) ; ONE library call — see below
(def impls
{:hash-password {:impl hash-password-fn}})
Two things are required, and forgetting the second is the most common mistake:
defbaseturns a Clojure fn into a base-fn impl. Its body should be a SINGLE library call (the "Base Function Philosophy" rule in CLAUDE.md — anything longer belongs in the graph as a composed fn-def).- The
implsmap (read by the loader) links the fn-def's:namekeyword to the impl. Without an entry, the fn-def infns.ednloads with no impl and its consumers fail with a silentunknown-parentat sync.
Side note: :effects on base-fns
Base-fns DECLARE the effect categories they touch — :effects #{}
for a pure fn like the hash, :effects #{:db} for :pg-query.
The type-checker propagates them transitively so editor effect
strips show the full set. See Lesson 13.
Sync at startup
When graphden boots, packages.loader/load-packages reads every
package.edn, topo-sorts by :dependencies, and for each
package reads its modules' fns.edn + impls.clj, wiring each
declaration to its impl by name. Then graphden.packages.sync
writes every fn-def to the DB (idempotent, deterministic UUIDs),
runs the topological type-check sweep, and reconciles declared
:services. You write no code for any of this.
Which packages load is the :package-names list in
resources/system-*.edn (deps are pulled in transitively, so
listing "app" also loads core/web/storage/app-base).
Because registry and mcp are OPTIONAL, dropping either from
:package-names omits it — the app still boots, the editor hides
its packages affordances (the packages chip on the Build
surface, the per-namespace ⬆ publish action, and the
governance section on Organization), and /mcp 404s. (Full distribution flow —
publish / install / update / fork — is Lesson 29.)
Try it
-
Create
resources/packages/mycorp/package.edn:{:name "mycorp" :version "1.0.0" :description "MyCorp tutorial package" :dependencies ["core"] :modules ["hello"]} -
Create
resources/packages/mycorp/hello/fns.edn:{:namespace "mycorp.hello" :description "Tutorial hello fns." :fns [{:name :greet :parent :str-join :args {:coll [{:value "Hello, "} {:as :name} {:value "!"}] :separator {:value ""}}}]} -
Add
"mycorp"to:package-namesinresources/system-dev.edn, then runbb rebuild. After the JVM restarts, the editor's explorer showsmycorp.hello.greet. Open⋯→▶ Run, supply:name = "world", get"Hello, world!".No
impls.cljneeded —:greetis a pure fn-def parented from:str-join(acorebase-fn, brought into scope by:dependencies ["core"]).{:as :name}is a free arg (Lesson 04); the{:value …}items are literal strings joined with an empty separator.
What we glossed over
- Cross-package references — ref a fn-def from another
package by NAME as long as that package is in your
:dependencies. Names are per-namespace: a bare ref resolves when unambiguous across the loaded set (your own module wins); a name defined in several namespaces must be qualified —:other.ns/name— or sync throws:packages/ambiguous-ref. - Naming guidelines — docs/PACKAGES.md § Naming.
- Distributing a package as data (not on disk) — publish it into the graph and install it on a branch: Lesson 29.