diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2e28943..cd53b769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,10 @@ jobs: - name: Generate tutorial markdown run: ./script/tutorial-to-md.sh - - name: Substitute %TAG% in Readme.md - run: sed -i "s/%TAG%/${GITHUB_REF_NAME}/g" Readme.md + - name: Substitute %TAG% and %SHA% in Readme.md + run: | + sed -i "s/%TAG%/${GITHUB_REF_NAME}/g" Readme.md + sed -i "s/%SHA%/${GITHUB_SHA}/g" Readme.md - name: Commit generated docs (detached HEAD) env: @@ -63,7 +65,7 @@ jobs: GIT_COMMITTER_NAME: ${{ github.actor }} GIT_COMMITTER_EMAIL: ${{ github.actor }}@users.noreply.github.com run: | - git add -f doc/tutorial + git add -f doc/tutorial doc/how_to doc/reference git add Readme.md git commit -m "Generate cljdoc docs for ${GITHUB_REF_NAME}" diff --git a/.gitignore b/.gitignore index 6476b4cc..f02ff5e9 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,8 @@ /target /pom.xml /doc/tutorial/ +/doc/how_to/*_test.md +/doc/reference/*_test.md +#/doc/_*.md .clj-kondo/ .lsp/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 66606a2d..736af7db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased ++ Add `di/prepend-side-dependency`: a side dependency that is built + before the root and its dependencies. `di/add-side-dependency` + keeps building them after the root, as before. + Docs: Backtick code references in public API docstrings so cljdoc renders them as inline code. diff --git a/CLAUDE.md b/CLAUDE.md index 270817c9..ceee95ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,111 +2,54 @@ ## Workflow -- **Do not `git push origin master` automatically.** Commit locally and - wait for the user to push. `master` is protected (PRs required) and - direct pushes only work via admin bypass, so each one is a deliberate - choice the user makes. -- Always include the `Co-Authored-By: Claude Opus 4.7 (1M context) - ` trailer in commits authored together. +- **Commit only when the user asks** (amends included). Finish the + work, leave changes in the working tree, and don't announce this + default — mention git state only when surprising or asked. +- **Never `git push origin master` yourself.** `master` is protected + (PRs required); direct pushes work only via the user's admin bypass, + so each one is their deliberate choice. +- Commit trailer: `Co-Authored-By: <% ACTUAL MODEL %> `. ## cljdoc -Docs are published on cljdoc.org. Source articles live in `doc/`: - -- `doc/cljdoc.edn` — navigation tree (`{:cljdoc.doc/tree ...}`). -- `doc/integrant.md`, `doc/example.md` — checked-in articles. -- `doc/tutorial/*.md` — **generated** from `test/darkleaf/di/tutorial/*.clj` - by `script/tutorial-to-md.sh`. Path is in `.gitignore`; the files only - exist in CI-built release commits, never on `master`. - -### Release flow - -`git push origin X.Y.Z` triggers `.github/workflows/ci.yml` → `release` job: - -1. Runs the tutorial-to-md script. -2. Substitutes `%TAG%` in `Readme.md` with the tag name. -3. Commits the result on a detached HEAD. -4. Tags that commit `cljdoc-X.Y.Z` and pushes the tag (only the tag; the - commit itself is not on any branch). -5. Builds the jar with `RELEASE_VERSION=X.Y.Z` and deploys to Clojars. - -The deployed pom's `` is the SHA of the detached commit, so -cljdoc fetches docs from there (not from `master`). - -### Updating docs without a new release - -cljdoc supports a `cljdoc-` tag override: it imports articles and -`cljdoc.edn` from that tag instead of the SCM commit. This affects only -articles and TOC — docstrings still come from the published jar. - -**Critical gotcha:** the new commit you tag MUST be a descendant of the -original CI-generated `cljdoc-` commit (whose SHA is hard-coded -in the deployed pom). If you make the new commit on top of `master`, -GitHub eventually GCs the orphaned SCM SHA, cljdoc clone fails with -`unknown-revision`, and the build silently produces a docs page with no -articles. - -Correct procedure: - -``` -# 1. Make the article changes on master (so they live in repo history) -git commit -m "..." -git push origin master - -# 2. Rebuild the cljdoc- commit on top of the prior one -git worktree add --detach /tmp/wt cljdoc-X.Y.Z # or the prior SHA -cp doc/ /tmp/wt/doc/ -(cd /tmp/wt && git add . && \ - git -c commit.gpgsign=false commit -m "..." && \ - git -c tag.gpgsign=false tag -f cljdoc-X.Y.Z) - -# 3. Force-push the moved tag -git push origin :refs/tags/cljdoc-X.Y.Z -git push origin cljdoc-X.Y.Z -git worktree remove /tmp/wt -``` - -Then trigger a rebuild on `https://cljdoc.org/d/org.clojars.darkleaf/di/X.Y.Z`. - -### Article cross-links - -cljdoc rewrites markdown links between articles. Use either: - -- relative to the source file: `tutorial/a_intro_test.md` (from `doc/example.md`) -- root-relative: `/doc/tutorial/a_intro_test.md` - -A bare `doc/tutorial/a_intro_test.md` (no leading slash) is **not** -recognised and renders as a broken external link. - -## Release/build gotchas (not cljdoc-specific but related) - -- `build.clj` reads version from `RELEASE_VERSION` env var. Local builds - produce a `DEV-SNAPSHOT` jar. -- The published pom must declare `org.clojure/clojure` explicitly in root - `:deps` of `deps.edn`. Otherwise tools.build's basis inherits Clojure - 1.10.3 from the system `deps.edn`, the pom advertises 1.10.3, and the - cljdoc analyzer launches with 1.10.3 — which does not understand - `:as-alias` and fails with a fake cyclic-load error. -- The published pom must include a `` block (Clojars rejects - uploads without one with `403 Forbidden`). Done via `:pom-data` in - `b/write-pom`; requires `tools.build` ≥ 0.10. -- `deps-deploy` reads the pom from the filesystem, not from inside the - jar. The `:deploy` alias passes `:pom-file - "target/classes/META-INF/maven/org.clojars.darkleaf/di/pom.xml"` so it - finds the one `b/write-pom` produced. -- Local git config has `tag.gpgsign=true`. To make a release-style - lightweight tag, override per-command: - `git -c tag.gpgsign=false tag X.Y.Z`. -- Branch `master` is protected (PRs required). Direct pushes work - because the user has admin bypass, but each one logs a "Bypassed rule - violations" entry. +Docs live on cljdoc.org. Tutorial/how-to articles are generated from +the matching test files by `script/tutorial-to-md.sh`; the generated +`.md` are gitignored and built in CI. + +- **Release flow:** pushing tag `X.Y.Z` makes CI generate the + articles, commit them on a detached HEAD, tag that commit + `cljdoc-X.Y.Z`, and deploy to Clojars. The pom's `` points + cljdoc at that detached commit, not at `master`. +- **Updating docs without a new release:** cljdoc re-imports articles + from the `cljdoc-X.Y.Z` tag. The new tagged commit MUST be a + descendant of the existing cljdoc commit — its SHA is hard-coded in + the deployed pom, and orphaning it makes the build silently lose + all articles. So: rebuild the commit on top of the old tag, + force-move the tag, trigger a rebuild on cljdoc.org. +- **Cross-links:** between articles use root-relative `/doc/...` + paths (a bare `doc/...` renders as a broken external link). + Article → API var: full cljdoc URL with the `CURRENT` version + placeholder, e.g. + `https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#->memoize`. + +## Release/build gotchas + +- `build.clj` reads the version from `RELEASE_VERSION`; local builds + produce `DEV-SNAPSHOT`. +- Root `:deps` of `deps.edn` must declare `org.clojure/clojure` + explicitly — otherwise the pom inherits 1.10.3 from the system + deps.edn and the cljdoc analyzer fails on `:as-alias` with a fake + cyclic-load error. +- The pom must include a `` block (Clojars rejects with 403). + Done via `:pom-data` in `b/write-pom`; needs `tools.build` ≥ 0.10. +- `deps-deploy` reads the pom from the filesystem: the `:deploy` alias + passes `:pom-file "target/classes/META-INF/maven/org.clojars.darkleaf/di/pom.xml"`. +- Local git has `tag.gpgsign=true`; for release-style lightweight tags + use `git -c tag.gpgsign=false tag X.Y.Z`. ## Clojars -Once a non-snapshot version is deployed, it cannot be re-deployed even -if the previous attempt failed validation. A burned version (`3.6.1` -was an example: missing license blew up after the upload) requires -bumping to a fresh version, not re-attempting the same tag. - -A deploy token is required (not the account password). Stored in -GitHub Actions secrets as `CLOJARS_USERNAME` / `CLOJARS_PASSWORD`. +- A non-snapshot version can never be re-deployed, even after a failed + validation (`3.6.1` was burned this way) — bump to a fresh version. +- Deploys need a token (not the account password), stored in GitHub + Actions secrets `CLOJARS_USERNAME` / `CLOJARS_PASSWORD`. diff --git a/Readme.md b/Readme.md index b1ecdd44..6bba0efc 100644 --- a/Readme.md +++ b/Readme.md @@ -40,7 +40,7 @@ to define a graph of functions and stateful objects. "PORT" "9090"}) ``` -It is just a short snippet, please see [example app](https://github.com/darkleaf/di/tree/master/example). +For a complete application, see the [example app](https://github.com/darkleaf/di/tree/master/example). ## Install @@ -68,4 +68,4 @@ starting with [user.clj](https://github.com/darkleaf/di/blob/master/example/dev/ Copyright © 2022 Mikhail Kuzmin -Licensed under Eclipse Public License v2.0 (see [LICENSE](LICENSE)). +Licensed under Eclipse Public License v2.0 (see [LICENSE](https://github.com/darkleaf/di/blob/master/LICENSE)). diff --git a/doc/cljdoc.edn b/doc/cljdoc.edn index 745d7a31..049d5859 100644 --- a/doc/cljdoc.edn +++ b/doc/cljdoc.edn @@ -1,26 +1,39 @@ {:cljdoc.doc/tree [["Readme" {:file "Readme.md"}] ["Changelog" {:file "CHANGELOG.md"}] - ["Tutorial" - ["Base" - ["Intro" {:file "doc/tutorial/a_intro_test.md"}] - ["Dependencies" {:file "doc/tutorial/b_dependencies_test.md"}] - ["Stop" {:file "doc/tutorial/c_stop_test.md"}] - ["Registries" {:file "doc/tutorial/l_registries_test.md"}] - ["Abstractions" {:file "doc/tutorial/m_abstractions_test.md"}] - ["Env" {:file "doc/tutorial/n_env_test.md"}] - ["Data DSL" {:file "doc/tutorial/o_data_dsl_test.md"}] - ["Derive" {:file "doc/tutorial/p_derive_test.md"}] - ["Starting many keys" {:file "doc/tutorial/q_starting_many_keys_test.md"}] - ["Multimethods" {:file "doc/tutorial/r_multimethods_test.md"}]] - ["Advanced" - ["Add a side dependency" {:file "doc/tutorial/x_add_side_dependency_test.md"}] - ["Update key" {:file "doc/tutorial/x_update_key_test.md"}] - ["Log" {:file "doc/tutorial/x_log_test.md"}] - ["Inspect" {:file "doc/tutorial/x_inspect_test.md"}] - ["Graceful stop" {:file "doc/tutorial/y_graceful_stop_test.md"}] - ["Multi arity service" {:file "doc/tutorial/y_multi_arity_service_test.md"}] - ["Multi system" {:file "doc/tutorial/z_multi_system_test.md"}] - ["Two Databases" {:file "doc/tutorial/z_two_databases_test.md"}]]] + ["Why DI?" {:file "doc/why_di.md"}] + ["Design" {:file "doc/design.md"}] + ["Integrant vs DI" {:file "doc/integrant.md"}] ["Example app" {:file "doc/example.md"}] - ["Integrant vs DI" {:file "doc/integrant.md"}]]} + ["Tutorial" + ["Your first system" {:file "doc/tutorial/a_your_first_system_test.md"}] + ["Dependencies" {:file "doc/tutorial/b_dependencies_test.md"}] + ["Stopping components" {:file "doc/tutorial/c_stopping_components_test.md"}] + ["Interactive development" {:file "doc/tutorial/d_interactive_development_test.md"}] + ["Registries" {:file "doc/tutorial/e_registries_test.md"}] + ["Abstractions" {:file "doc/tutorial/f_abstractions_test.md"}] + ["Environment variables" {:file "doc/tutorial/g_environment_variables_test.md"}] + ["Starting many keys" {:file "doc/tutorial/h_starting_many_keys_test.md"}] + ["Wiring inside data" {:file "doc/tutorial/i_wiring_inside_data_test.md"}] + ["Transforming values" {:file "doc/tutorial/j_transforming_values_test.md"}] + ["Composition with update-key" {:file "doc/tutorial/k_composition_with_update_key_test.md"}] + ["Handling start failures" {:file "doc/tutorial/l_handling_start_failures_test.md"}]] + ["How-to" + ["Tips" {:file "doc/how_to/tips_test.md"}] + ["Side dependencies" {:file "doc/how_to/side_dependencies_test.md"}] + ["Feature flags" {:file "doc/how_to/feature_flags_test.md"}] + ["Startup checks" {:file "doc/how_to/startup_checks_test.md"}] + ["Graceful shutdown" {:file "doc/how_to/graceful_shutdown.md"}] + ["Logging system lifecycle" {:file "doc/how_to/log_test.md"}] + ["Multimethods" {:file "doc/how_to/multimethods_test.md"}] + ["All public vars as a component" {:file "doc/how_to/ns_publics_test.md"}] + ["Multi-arity services" {:file "doc/how_to/multi_arity_service_test.md"}] + ["Multiple systems" {:file "doc/how_to/multiple_systems_test.md"}] + ["Reusing components between tests" {:file "doc/how_to/reusing_components_between_tests_test.md"}] + ["One factory, many keys" {:file "doc/how_to/one_factory_many_keys_test.md"}] + ["Visualizing your system" {:file "doc/how_to/visualizing_test.md"}] + ["Documenting environment variables" {:file "doc/how_to/env_docs.md"}] + ["Instrumenting services" {:file "doc/how_to/instrumenting_services.md"}]] + ["Reference" + ["Inspect" {:file "doc/reference/inspect_test.md"}] + ["The middleware argument" {:file "doc/reference/middleware_argument.md"}]]]} diff --git a/doc/design.md b/doc/design.md new file mode 100644 index 00000000..cd6e61b9 --- /dev/null +++ b/doc/design.md @@ -0,0 +1,337 @@ +# Design + +DI is built on one idea: a **registry middleware** — a plain function +that takes one registry and returns another. Resolving a symbol to its +var, overriding a key for a test, adding logging — each is a middleware +that reshapes how keys resolve. Learn that one shape, and the rest of +the library follows. + +Before middleware, there are two smaller terms. + +A **factory** is what a key resolves to — it makes one object and +declares its dependencies. + +A **registry** is a function from a key to a factory. + +A middleware then transforms one registry into another. That is the +unit you write and compose, and `di/start` folds your middlewares over +the default registry. + +What all this gives you is **dependency wiring**. You write +components and declare what each one depends on. DI works out the order +and builds every component the root needs — only those, lazily — +passing each the dependencies it asked for. Nothing is wired by hand. +The wiring is computed once, when +[`di/start`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#start) +runs, and what you get back is the running system: the object built for +the root. + +## Keys + +A key names a component of the system. It is one of three things, and +the kind signals intent: + +- a **symbol** points at a var, e.g. `` `my.app/server ``; +- a **keyword** names an abstraction, e.g. `:my.app/clock`; +- a **string** names an environment variable, e.g. `"PORT"`. + +These are conventions, not hard rules, and a middleware can add more. +`di/env-parsing`, for example, reads a qualified keyword like +`:env.long/PORT`: the namespace selects a parser and the name is the +environment variable. + +## The factory + +A factory is what a key resolves to. It knows three things — what it +depends on, how to build the object, and how to describe itself — one +method each on the +[`Factory`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.protocols#Factory) +protocol: + +```clojure +(defprotocol Factory + (dependencies [this]) + (build [this deps add-stop]) + (description [this])) +``` + +- **`dependencies`** returns the keys the factory needs, each marked + `:required` or `:optional`. A map will do. When build order matters — + as it does for side effects like migrations — return an ordered + sequence of `[key dep-type]` pairs instead: keys are built in the + order they are listed. It may compute the answer, but it must be pure + and stable, because DI asks more than once per `di/start`. +- **`build`** takes the resolved dependencies and returns the object. + Its `add-stop` argument registers cleanup. See + [Building and stopping](#building-and-stopping). +- **`description`** returns a diagnostic map and carries no behaviour. + Inspection and logging read it. + +### Most things are already factories + +You almost never need to implement the protocol yourself. Most things +you write are factories already: + +- a **`defn`** becomes a factory when DI resolves its symbol — + [a component or a service](/doc/tutorial/a_your_first_system_test.md); +- [`di/ref`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#ref), + [`di/template`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#template) + and + [`di/derive`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#derive) + return factories; +- **any plain value is a factory** — it builds to itself. + +That last rule is why a map of overrides works. When you start a system +with `{:my.app/clock a-fixed-clock}`, the value `a-fixed-clock` is used +as the factory for `:my.app/clock`: a factory with no dependencies that +builds to itself. + +You reach for the protocol directly only when a key needs resolution +those can't express — most often, to wrap another factory. + +### Building and stopping + +`build` returns the object. Its third argument, `add-stop`, registers a +zero-arg cleanup procedure — call it once per resource you allocate. +Registered cleanups run in LIFO order when the system stops, so a +component is torn down before the dependencies it was built from. See +[Stopping components](/doc/tutorial/c_stopping_components_test.md). + +`add-stop` is what lets factories wrap cleanly. Earlier versions handled +cleanup through `demolish` — a separate `Factory` method DI called with +the built object at stop time. That made wrapping leak. A factory that +wrapped another and allocated a resource of its own had to either return +an object proxying the inner one's teardown, or take over the inner +factory's cleanup itself. Either way it had to know how the inner +factory was torn down. With `add-stop` each factory registers its own +cleanup the moment it allocates — the inner one included. Wrapping no +longer touches teardown: + +```clojure +(reify p/Factory + (dependencies [_] (p/dependencies inner)) + (build [_ deps add-stop] + (let [obj (p/build inner deps add-stop) ; inner registers its own stop + resource (allocate-something obj)] + (add-stop #(release resource)) + (wrap obj resource))) + (description [_] (p/description inner))) +``` + +### The two meanings of nil + +`nil` means *nothing here*, and DI leans on that in one place after +another. The bare registry underneath everything resolves every key to +`nil`. An `:optional` dependency that resolves to `nil` is skipped. A +`:required` one that resolves to `nil` fails with `::missing-dependency`. +The rule is uniform: a `build` that returns `nil` produced nothing. + +That leaves no way to say *the value is `nil`*. Picture a component that +creates a table: + +```clojure +(defn create-users-table + {::di/kind :component} + [{db `db}] + (ddl db "create table users ...") + ;; added during a later refactor + (when remove-legacy? + (ddl db "drop table legacy_users ..."))) +``` + +When `remove-legacy?` is false the `when` returns `nil`, so the +component returns `nil`. Without help the system would fail to start +with `::missing-dependency` — and only in the configuration where the +branch is not taken, so tests pass and production breaks. + +So a `:component` that returns `nil` is stored as the sentinel +`::di/nil`. The key counts as built, satisfies a `:required` dependency, +and dependents receive `::di/nil`: + +```clojure +(with-open [system (di/start `create-users-table)] + @system) ; => :darkleaf.di.core/nil +``` + +The substitution is specific to `:component` factories. Every other +factory returns its value unchanged, so a custom factory that builds +`nil` is read as *nothing*. Reach for `di/opt-ref` or an `:optional` +dependency when *absent* is what you actually mean. + +### Wrapping a factory + +The protocol's shape is what lets one factory wrap another — the +decorator pattern. A wrapper declares the inner factory's dependencies, +adds any of its own, delegates `build` to the inner one, and layers +behaviour around the result. Here is a wrapper that logs +when its key is built. It adds a dependency on a `logger` and otherwise +stays out of the way: + +```clojure +(defn with-logging [inner] + (reify p/Factory + (dependencies [_] + (concat (p/dependencies inner) {`logger :required})) ; inner deps plus a logger — concat, since deps may be a sequence + (build [_ deps add-stop] + (let [logger (deps `logger) + obj (p/build inner deps add-stop)] ; inner builds, registers its own stop + (logger {:built obj}) + obj)) + (description [_] + (p/description inner)))) +``` + +This wrap-and-delegate pattern is what the library is built on. The next +sections show a registry handing out factories, then a middleware +applying such a wrap across the whole registry. + +## The registry + +A registry is a function from a key to a factory: + +```clojure +(fn [key] factory) +``` + +A function with this shape is the most general way to express a lookup, +so nothing else is needed. Even a map lookup is a function from key to +value. + +A registry hands back a factory, not a built object. Nothing is built +when you look a key up. Building happens later, when `di/start` resolves +the root, reads each factory's dependencies, and builds them in order. + +Only one registry is ever written out: a bare one that resolves every +key to `nil`. Every other registry is a closure that a middleware +returns. + +## Middleware + +A middleware takes a registry and returns a new registry. If you have +written Ring middleware, the shape is familiar: a function that wraps a +handler and returns a new one, with a registry in place of the handler. +Naming the two functions makes it clear: + +```clojure +(fn middleware [registry] + (fn new-registry [key] + (registry key))) +``` + +That body, `(registry key)`, is a plain passthrough: the new registry +resolves every key exactly as the old one did. Every middleware starts +there. It becomes useful by returning a different factory for some keys, +or by wrapping the factory the inner registry returns. + +`with-clock` does the first — it returns a fresh factory for `::clock` +and passes the rest through: + +```clojure +(defn with-clock [] + (fn middleware [registry] + (fn new-registry [key] + (if (= key ::clock) + (reify p/Factory + (dependencies [_] nil) + (build [_ _ _] (Clock/systemUTC)) + (description [_] {})) + (registry key))))) +``` + +`with-logging` above does the second — it wraps the factory the inner +registry returns. This shape repeats across `core`: `di/update-key`, +`di/log`, `di/add-side-dependency` and the rest each look up a factory, +override one method, and delegate the others. + +The built-in middlewares wrap the bare registry — one resolves a symbol +to its var, another resolves a string to an environment variable. That +stack is the default registry, no different from the middlewares you +write. `di/start` folds your middlewares over it, each wrapping the one +before — so the last one you pass is the outermost, and a key reaches it +first. For convenience, `di/start` also accepts a map of overrides, +`nil` for a no-op, or a sequence of these, and normalizes each into the +same registry transformation — see +[The middleware argument](/doc/reference/middleware_argument.md) for the +full list. + +## Inspection: walking the graph without building it + +DI infers the wiring for you. You never write out the dependency graph — +each factory declares its own `dependencies`, and `di/start` works out +what to build and in what order. That is convenient, but implicit: from +the code alone, the shape of the running system is not obvious. + +`di/inspect` makes it explicit, and it builds nothing. You call it like +`di/start` — the same key and middlewares — but it returns the graph +instead of building the system. It is not a separate mechanism: it adds +a middleware of its own that wraps every factory with another whose +`build` records the key, its dependencies, and its description instead +of constructing the object: + +```clojure +[{:key `root :dependencies {`foo :required `bar :optional}} + {:key `foo} + {:key `bar}] +``` + +The graph is walked exactly as `di/start` would walk it, so the report +reflects the system you would actually get — middlewares applied, +overrides in place. The only difference is that `build` describes +instead of constructs: no objects are built, no side effects run. + +Inspection costs nothing extra because `description` is part of the +`Factory` protocol. However deeply a factory is wrapped, it can still +describe itself, so the whole system stays readable after every +middleware is applied. + +## di/start: building and the running system + +`di/start` folds the middlewares over the default registry, then +resolves the root, then its dependencies, then theirs. That is a pull: +a component is built only because something above it asked for it. A +factory in the registry that the root never reaches is never built, and +an `:optional` dependency that resolves to nothing is simply skipped. + +What you get back is the object built for the root, wrapped in a +container. Two interfaces matter most: + +- **`IDeref`** — deref it to get the built object. +- **`AutoCloseable`** — closing it stops the whole system. + +The rest is convenience, so you can reach the object without a deref +first: + +- **`IFn`** — if the root is a function, call the container directly. It + delegates across every arity. +- **`Indexed`** and **`ILookup`** — destructure it as a vector or a map, + matching a vector or map of root keys (see + [Starting many keys](/doc/tutorial/h_starting_many_keys_test.md)). + +Pulling from the root is what makes feature flags cheap. A subsystem +adds itself to the registry through a middleware (see +[Composition with update-key](/doc/tutorial/k_composition_with_update_key_test.md)). +Put that behind a flag, and when the flag is off the subsystem +contributes nothing — nothing references its keys, so they are never +built. One binary ships everywhere, each environment building only the +features it turns on. See +[Why DI?](/doc/why_di.md#feature-flags-come-almost-for-free) for the +payoff in full. + + +## The whole model + +Step back, and the model is small. A **factory** makes one object and +declares its dependencies. A **registry** is a function from a key to a +factory. A **middleware** is a function from a registry to a registry. +`di/start` folds the middlewares over the default registry, pulls the +root, and hands back the running system. + +The engine at the centre is deliberately small. Almost every feature is +implemented as middleware layered on top of it — symbol resolution, +environment variables, overrides, logging, inspection, memoization, and +the rest. + +Factories and registry middleware have been enough for the whole +library since the first release. Only the `Factory` protocol was refined over time to improve +composability. The model is stable now, and +there are no plans to change it. diff --git a/doc/example.md b/doc/example.md index 5ff6f61d..c4657a26 100644 --- a/doc/example.md +++ b/doc/example.md @@ -34,11 +34,12 @@ covered in the tutorial: - **`di/update-key`** — extends the shared `reitit/route-data` from `example.core` so each subsystem can append its own routes without the routing component knowing about them. -- **`di/add-side-dependency`** — pulls in `flyway/migrate` so - migrations run before the server starts, even though nothing - references the migrator directly. +- **`di/prepend-side-dependency`** — pulls in `flyway/migrate` so + migrations run before the rest of the system, even though + nothing references the migrator directly — see + [Side dependencies](/doc/how_to/side_dependencies_test.md). - **Registry composition** — `base-registry` returns a vector of maps - and middlewares; `dev-registry` layers dev-only env values on top. + and middlewares. `dev-registry` layers dev-only env values on top. ## Running it @@ -53,4 +54,4 @@ user=> (stop) Redefine `example.core/root-handler` at the REPL and re-evaluate — the running system picks up the new implementation without a restart, as -described in the [Intro](/doc/tutorial/a_intro_test.md) chapter. +described in the [Interactive development](/doc/tutorial/d_interactive_development_test.md) chapter. diff --git a/doc/how_to/env_docs.md b/doc/how_to/env_docs.md new file mode 100644 index 00000000..ab2b7d1e --- /dev/null +++ b/doc/how_to/env_docs.md @@ -0,0 +1,103 @@ +# Documenting environment variables + +The environment variables a system reads are its configuration +contract. Whoever deploys it needs that list: every variable that +exists, what each one means, whether it is required, what it +defaults to. Kept by hand, the list drifts — a new component adds +a variable and the wiki forgets it. Generate it from the code +instead. + +Two things have to live in the code for that: a description for +each variable, and a way to read the whole set back out. + +## Where the description goes + +A variable is a key — a string like `"DATABASE_URL"`, or, once +parsed, a keyword like `:env.long/PORT` +([Environment variables](/doc/tutorial/g_environment_variables_test.md)). +The description belongs with it, but neither a string nor a +keyword can carry Clojure metadata. A symbol can — and the +binding, the left-hand side of the deps-map entry, is a symbol. +So the description goes on the binding, under a metadata key of +your own choosing: + +```clojure +(defn db + {::di/kind :component} + [{^{:app.env/doc "Postgres connection string"} url "DATABASE_URL"}] + ...) +``` + +One consequence follows from this. The description sits on the +*use site*, not on the variable. The same variable read by +several components has several bindings, so it can carry several +descriptions — the generator collects them all rather than +assuming there is one. + +## Reading it back + +`di/inspect` takes the same arguments as `di/start` but builds +nothing; it returns the graph the runtime would walk +([Inspect](/doc/reference/inspect_test.md)). A component that +comes from a var carries that var under `::di/variable` — env +keys, refs and plain values do not — so keeping on `::di/variable` +hands you exactly the var-backed components the system reaches: + +```clojure +(->> (di/inspect :app.system/root) + (keep #(-> % :description ::di/variable))) +;; => (#'app.db/db #'app.mailer/mailer ...) +``` + +The descriptions are not in that output; they live in the +metadata of the bindings, inside each var's source. So `inspect` +tells you *which* vars, and the vars themselves tell you *what* +they read. `defn` records its argument lists under `:arglists`, +and a component's deps map is the first argument of an arglist: + +```clojure +(-> #'db meta :arglists) +;; => ([{url "DATABASE_URL"}]) +``` + +You do not parse that map by hand. `clojure.core/destructure` — +what `let` expands its own bindings with — flattens it into +binding/expression pairs with metadata intact: + +```clojure +(partition 2 (destructure '[{^{:app.env/doc "HTTP port"} port :env.long/PORT + url "DATABASE_URL" + :or {port 8080}} + env])) +;; (... map plumbing, dropped ...) +;; (port (clojure.core/get env :env.long/PORT 8080)) +;; (url (clojure.core/get env "DATABASE_URL")) +``` + +Each variable is one pair, and the pair holds every field: + +- the **binding** still carries your `:app.env/doc` — the + description; +- `get`'s second argument is the **variable** — a string is the + name as-is, a parsed keyword names both the variable (`PORT`) + and its type (`:env.long`); +- `get`'s third argument is the `:or` **default**; whether it is + there is the difference between optional and required. + +Pairs whose expression is not a `get` — the map binding itself, +other components — are not variables; drop them. + +## The result + +Collected across the vars, this gives you a piece of data per +variable: its name, type, whether it is required, its default, +and the set of descriptions written for it. Turning that into a +document is the last step, and it is yours to shape — a markdown +table beside the deployment guide, a section of `--help`, a config +template; strict about missing descriptions or lenient; checked in +a test or not. + +The recipe stops here because the point is upstream: `di/inspect` +opens the whole graph to your own configuration tooling — see +[Inspect](/doc/reference/inspect_test.md) for the rest of what it +exposes. diff --git a/doc/how_to/graceful_shutdown.md b/doc/how_to/graceful_shutdown.md new file mode 100644 index 00000000..01e99ab9 --- /dev/null +++ b/doc/how_to/graceful_shutdown.md @@ -0,0 +1,63 @@ +# Graceful shutdown + +In production, the process must stop cleanly. An orchestrator — +systemd, Kubernetes, Docker — stops a service by sending the +`SIGTERM` signal, and the app gets a short period to release its +resources: stop accepting requests, finish the requests it has +already accepted, close the connection pool. + +`di/stop` performs this kind of stop. It stops components in +the reverse order of the build order: the server was built after +the connection pool it depends on, so the server is stopped +first — the system stops accepting requests before the pool those +requests use is closed. + +`di/stop` has to be called when the process receives the signal. +Different teams solve this in different ways; both of the +following approaches are used in production by projects built on +DI. + +In both examples, the shutdown code is registered only after a +successful `di/start`, so nothing is stopped twice. A start +failure needs no extra code: if `di/start` throws, it has already +stopped the components it managed to build (see +[Handling start failures](/doc/tutorial/l_handling_start_failures_test.md)), +and the JVM exits with a stack trace and a non-zero code. + +## A JVM shutdown hook + +The JVM runs *shutdown hooks* on +`SIGTERM`, on `Ctrl-C` (`SIGINT`), and on a normal `System/exit`: + +```clojure +(ns app.main + (:gen-class) + (:require + [app.system :as system] + [darkleaf.di.core :as di])) + +(defn -main [& _args] + (let [root (di/start ::system/root (system/production-registry))] + (.. Runtime + getRuntime + (addShutdownHook (Thread. #(di/stop root)))))) +``` + +## A signal handling library + +[spootnik/signal](https://github.com/pyr/signal) installs a +handler for a named signal — here, for `SIGTERM`: + +```clojure +(ns app.main + (:gen-class) + (:require + [app.system :as system] + [darkleaf.di.core :as di] + [signal.handler :refer [with-handler]])) + +(defn -main [& _args] + (let [root (di/start ::system/root (system/production-registry))] + (with-handler :term + (di/stop root)))) +``` diff --git a/doc/how_to/instrumenting_services.md b/doc/how_to/instrumenting_services.md new file mode 100644 index 00000000..56fe2e2f --- /dev/null +++ b/doc/how_to/instrumenting_services.md @@ -0,0 +1,159 @@ +# Instrumenting services + +Say you want something to happen around every service call: an +APM span, a timing metric, an error report. The wrapper is the +same for every service. This page uses a NewRelic trace as the +example, but nothing below is specific to NewRelic, or even to +tracing. + +Wrapping each service by hand spreads one concern over the whole +codebase, and the set of wrapped services drifts: services come +and go, and a new one reaches production with no instrumentation. +But every service is built in one place — the registry. +Instrument there, and no service can be missed. + +## Services, not components + +A component builds into a finished object; a service builds into +a function that is called at runtime +([Your first system](/doc/tutorial/a_your_first_system_test.md)). +A trace wraps a *call*, so the targets are services. And at build +time they are easy to tell apart: every factory describes itself, +and `(-> factory p/description ::di/kind)` answers `:service` or +`:component`. + +## Why a new middleware + +`di/update-key` decorates one named key. A registry map overrides +the keys it lists. Both need the list of services written by hand +— the same drifting list we are trying to avoid. What we want is +a decision made per key, for every key the system resolves: that +is the most general form of a middleware, a function `registry -> +key -> Factory` +([The middleware argument](/doc/reference/middleware_argument.md)). + +## The middleware + +```clojure +(ns app.instrument + (:require + [darkleaf.di.core :as di] + [darkleaf.di.protocols :as p])) + +(defn instrument-service + "f is (fn [service key] wrapped-service)" + [f] + (fn [registry] + (fn [key] + (let [factory (registry key)] + (if (= :service (-> factory p/description ::di/kind)) + (reify p/Factory + (dependencies [_] + (p/dependencies factory)) + (build [_ deps add-stop] + (-> (p/build factory deps add-stop) + (f key))) + (description [_] + (p/description factory))) + factory))))) +``` + +For every key it asks the registry beneath for the factory and +checks its kind. Anything but a service passes through untouched. +A service gets a new factory in front of it, transparent in +everything except the built value: + +- `dependencies` delegates unchanged — changing them would break + the wiring; +- `description` delegates unchanged — `di/inspect`, `di/log` and + other middlewares read it; +- `build` builds the real service, then hands it to `f` along + with its key. + +The wrapping happens inside `build` and not sooner, because the +service function does not exist until its dependencies are +resolved. And `f` receives the key because instrumentation needs +a name — the key *is* the name. + +## The removed `di/instrument` + +DI once had this idea in its general form: `di/instrument`, a +middleware that decorated every object the system builds. It was +removed ([Changelog](/CHANGELOG.md)): a wrapper that applies to +everything must avoid instrumenting its own dependencies, must +respect stop procedures, and must make sense for objects it knows +nothing about — and no implementation handled all of that well. +Services are the special case where these problems disappear: a +service is a plain function with no stop procedure, and the +wrapper is passed as a plain value, not as a key to resolve. So +this recipe works where the general one did not. + +## Applying it + +`wrap-trace` here is the NewRelic wrapper; its definition is long +and mechanical, so it is moved to the end of the page: + +```clojure +(declare wrap-trace) + +(di/start :app.system/root + (instrument-service + (fn [service key] + (wrap-trace service (str key))))) +``` + +Every service now reports a span named after its key, and a +service added later is covered automatically. To replace NewRelic +with OpenTelemetry, a timer, or a logger, change `f` — the +middleware stays the same. + +## The wrapper + +NewRelic's agent traces JVM methods annotated with `@Trace`. A +Clojure function cannot carry a JVM annotation, but a `reify` +method can — metadata on it compiles into a real annotation. So +the wrapper reifies `IFn`, annotates each method, and delegates. +Note the full method set: besides the twenty-one `invoke` arities +and `applyTo`, `IFn` extends `Callable` and `Runnable`, so `call` +and `run` must be wrapped too — an executor calls the service +through them, bypassing every `invoke`: + +```clojure +(ns app.newrelic + (:import + (com.newrelic.api.agent NewRelic Trace) + (clojure.lang Fn IFn))) + +(defn- set-trace-name [name] + (.. NewRelic getAgent getTransaction getTracedMethod + (setMetricName (into-array String ["Instrument" name])))) + +(defn wrap-trace [^IFn service name] + (reify + Fn + IFn + (^{Trace true} call [_] + (set-trace-name name) + (.call service)) + (^{Trace true} run [_] + (set-trace-name name) + (.run service)) + (^{Trace true} invoke [_] + (set-trace-name name) + (.invoke service)) + (^{Trace true} invoke [_ a1] + (set-trace-name name) + (.invoke service a1)) + (^{Trace true} invoke [_ a1 a2] + (set-trace-name name) + (.invoke service a1 a2)) + ;; ...and so on for the arities your services use, + ;; up to the full twenty, plus: + (^{Trace true} applyTo [_ args] + (set-trace-name name) + (.applyTo service args)))) +``` + +This complexity comes from NewRelic, not from DI. An +OpenTelemetry span or a plain timer does not need annotations, +and its `wrap-trace` is an ordinary higher-order function. diff --git a/doc/images/visualizing.svg b/doc/images/visualizing.svg new file mode 100644 index 00000000..36921144 --- /dev/null +++ b/doc/images/visualizing.svg @@ -0,0 +1,48 @@ + + + + + + + + + +darkleaf.di.how-to.visualizing-test/b + +darkleaf.di.how-to.visualizing-test/b + + + +darkleaf.di.how-to.visualizing-test/a + +darkleaf.di.how-to.visualizing-test/a + + + +darkleaf.di.how-to.visualizing-test/b->darkleaf.di.how-to.visualizing-test/a + + + + + +darkleaf.di.how-to.visualizing-test/c + +darkleaf.di.how-to.visualizing-test/c + + + +darkleaf.di.how-to.visualizing-test/c->darkleaf.di.how-to.visualizing-test/b + + + + + +darkleaf.di.how-to.visualizing-test/c->darkleaf.di.how-to.visualizing-test/a + + + + + diff --git a/doc/integrant.md b/doc/integrant.md index 20970f02..d95cbaea 100644 --- a/doc/integrant.md +++ b/doc/integrant.md @@ -25,7 +25,7 @@ handles dependencies, REPL workflow, composition, and failure. ## Assumptions - All snippets share the namespace shown above. -- Library calls (Jetty, Reitit) are stubbed; the [example app](/doc/example.md) has runnable wiring. +- Library calls (Jetty, Reitit) are stubbed. The [example app](/doc/example.md) has runnable wiring. ## Code Examples @@ -196,7 +196,7 @@ If you do want the topology as data, `di/inspect` takes the same arguments as `di/start` and returns the resolved graph as a vector of `{:key … :dependencies … :description …}` maps without running anything. Feed it to a graph visualiser, diff between deployments, or -assert a structure in tests — see the [Inspect tutorial](/doc/tutorial/x_inspect_test.md). +assert a structure in tests — see the [Inspect reference](/doc/reference/inspect_test.md). ## Real Applications @@ -296,7 +296,7 @@ disappears from the registry: ``` The registry function is the single place where deployment decisions -live. Turning a flag off means deleting one line; the subsystem's own +live. Turning a flag off means deleting one line. The subsystem's own namespace stays untouched. Tests pass whatever flags map they want — no env-var mocking, no global state. diff --git a/doc/reference/middleware_argument.md b/doc/reference/middleware_argument.md new file mode 100644 index 00000000..ab4ed8ee --- /dev/null +++ b/doc/reference/middleware_argument.md @@ -0,0 +1,149 @@ +# The middleware argument + +`di/start`, `di/inspect` and `di/->memoize` all take a variadic +`middlewares` argument. A middleware wraps a registry to produce a +new one — see +[Design](/doc/design.md#middleware) for the +concept. This page describes the values the argument accepts: a +function, a map, a sequence, `nil`, or a +`java.util.function.Function`. + +## Function + +A function `registry -> key -> Factory`. The most general, and the +one the others reduce to. The inner function is the new registry, +so it either delegates to the one beneath it or answers with a +factory of its own: + +```clojure +;; pass every key through to the registry beneath +(fn middleware [registry] + (fn new-registry [key] + (registry key))) + +;; or answer with a factory of your own +(fn middleware [registry] + (fn new-registry [key] + (reify p/Factory + ...))) +``` + +Every built-in (`di/update-key`, `di/env-parsing`, `di/log`, +`di/ns-publics`, …) is a function of this shape. + +## Map + +A map of `{key factory}`. It overrides the listed keys and +delegates the rest to the registry beneath it: + +```clojure +(di/start `root {`db test-db ; a plain object, built as-is + ::clock (di/ref `fixed) ; a built-in Factory + `cache (reify p/Factory ; a hand-written Factory + ...) + "LOG_LEVEL" "debug"}) ; a plain value too +``` + +A value is used as the factory directly. A plain object counts as +a factory that builds to itself, so you can drop in a stub or a +literal without wrapping it; a value like `(di/ref ...)` or a +`reify p/Factory` is a factory in its own right. This is the most +common way to override components in tests and configuration. + +## Sequence + +A vector or seq of the other values, applied left to right and +flattened into the chain. It pairs naturally with `nil`: build the +sequence from `when` expressions, and the ones whose `when` is false +become `nil`, which is a no-op. + +```clojure +(defn dev-middlewares [{:keys [stub-db? verbose?]}] + [(when stub-db? {`db test-db}) + (when verbose? (di/log :after-build! report))]) + +(di/start `root (dev-middlewares flags) {::override :x}) +``` + +A sequence may contain any of these values, including other +sequences. + +## nil + +A no-op. Handy for a middleware that is only sometimes present, +without an `if` around the whole call: + +```clojure +(di/start `root (when dev? dev-middlewares)) +``` + +## java.util.function.Function + +A `java.util.function.Function` from registry to registry — the +same mapping as the function above, called through `.apply`. + +This exists for a stateful middleware that has to be more than +a function. `di/->memoize` returns a value that is both a `Function` +(so it works as a middleware) and `AutoCloseable` (so `di/stop` can +release everything it cached). A plain Clojure `fn` can't carry a +second interface like that, so the registry accepts a `Function` +object as an alternative. + +## Order + +Two directions are in play, and they are opposite. + +The values you pass are *applied* left to right: the leftmost wraps +the default registry, the next wraps that, and so on, so the +rightmost ends up outermost. A *lookup* then runs through that stack +from the outside in, so the rightmost middleware sees each key +first. When two of them answer the same key, the rightmost therefore +takes effect: + +```clojure +(di/start `root {`x :a} {`x :b}) ; `x resolves to :b +``` + +Keep in mind what a middleware actually does. A middleware only +chooses, per key, whether to answer or to delegate to the registry +beneath it. + +`di/->memoize` is constrained the other way: it must be the +*first* (leftmost) middleware. It does not wrap the registry it is +handed. It carries its own, built from the middleware passed to +`->memoize`, and rejects anything applied before it with +`::wrong-memoized-registry-position`. So anything that would sit +beneath `mem` has to be passed into it instead, and overrides go +after it, on the outside: + +```clojure +(def mem (di/->memoize base-middlewares)) + +;; wrong — a middleware before mem is rejected +(di/start `root {::extra :x} mem) ; throws + +;; right — overrides go after mem, on the outside +(di/start `root mem {::override :x}) ; ok +``` + +## Why a `cond`, not a protocol + +`di/start` dispatches over these values with a `cond` over standard +predicates, not a protocol. A protocol does not fit. You would have +to extend it onto maps, sequences and functions — abstract +groupings, not single types. A sequence alone covers lists, +vectors, lazy seqs, cons cells, and more. And the cases need an +explicit precedence that type-based dispatch does not give: a map +is also a collection, so it has to be recognised as a map of +overrides before the general sequence case. A `cond` over `map?`, +`sequential?`, `fn?` and `instance? Function` states both the +grouping and the order in one place. + +Because a function is already accepted, it doubles as the +extension point: to plug in a type of your own, convert it yourself +and pass the result, which is then an ordinary function or +`Function`. + +```clojure +(di/start `root (your-thing->middleware x)) +``` diff --git a/doc/why_di.md b/doc/why_di.md new file mode 100644 index 00000000..c64d64a0 --- /dev/null +++ b/doc/why_di.md @@ -0,0 +1,149 @@ +# Why DI? + +A typical Clojure project has a few stateful objects — a database +pool, an HTTP server, maybe a cache or a worker queue — surrounded +by many stateless functions. DI starts from the idea that all of +them are first-class components: the stateful objects and the +stateless functions alike. Declaring "this function needs the +datasource" should cost no more than writing a `defn`. + +Everything else follows from that. + +## Dependencies live with the function that uses them + +Each function lists its dependencies in its own argument list: + +```clojure +(defn show-user [{ds ::db/datasource} req] + (jdbc/get-by-id ds (-> req :path-params :id))) +``` + +There is no central wiring file. DI inspects the keys you +destructure and resolves them during start. + +## Lazy initialization + +DI builds only what your root needs, directly or through other +dependencies. A component that nothing uses is never started. + +## Configuration is just components + +Environment variables are the default — a string key in your deps +resolves to an env var. This is plain +[12-factor](https://12factor.net/config) style: a flat namespace of +names and string values, no nested config shape to design or +maintain. If you prefer a config file, read EDN into a map and pass +that map to DI when you start the system. Validation is just a +component that checks its own dependencies — bad configuration +is caught at start, not in production. The recipe: +[Startup checks](/doc/how_to/startup_checks_test.md). + +For typed values, qualified keys like `:env.long/PORT` or +`:env.json/SETTINGS` parse the env var on the way in. The keyword +namespaces (`:env.long`, `:env.json`, anything you like) are yours +to define. + +## Subsystems compose without referencing each other + +A subsystem owns its handlers and adds them to a shared registry. +The namespace that owns the registry does not know about the +subsystem. Adding a subsystem means adding a file — no edits to +existing ones. + +Concretely, the users subsystem ships a `registry` function that +hooks itself onto the central routes: + +```clojure +;; app/users.clj +(defn registry [-feature-flags] + [(di/update-key `app.web/routes conj (di/ref `users-routes))]) +``` + +The main system composes registries from every subsystem. `app.web` +never imports `app.users`. A third subsystem is a third file with +its own `registry`. The pattern is explained in +[Composition with `update-key`](/doc/tutorial/k_composition_with_update_key_test.md). + +## Feature flags come almost for free + +Combine lazy initialization with subsystems that own their wiring, +and feature flags fall out naturally. Flip a flag, and the +subsystem contributes nothing to the registry — the components +behind it stop being built. One binary ships to many environments, +each with a different set of features active. The recipe: +[Feature flags](/doc/how_to/feature_flags_test.md). + +## Wiring inside data + +When you describe something in data — reitit routes, scheduler +tables, connection-pool configs — you can put `(di/ref ...)` right +inside the data: + +```clojure +(def route-data + (di/template + [["/users/:id" {:get {:handler (di/ref `show-user)}}]])) +``` + +`di/template` walks the structure on start and replaces each ref +with the built component. + +## Cross-cutting changes don't touch the original code + +Wrap a function with metrics, schema validation, or any decorator +— without editing the function itself. The wrapping lives in the +registry. See +[`di/update-key`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#update-key). + +## Two kinds of binding + +A stateful component arrives in the function as the built value +itself — no atom to deref, no runtime lookup. To swap one, you +change the registry and restart. + +A stateless service is bound as `(partial #'the-var)`. There is one +level of var indirection, by design: it is exactly what makes live +REPL redefinition work (next section). The component/service +distinction is defined in +[Your first system](/doc/tutorial/a_your_first_system_test.md). + +## The system is a value + +`di/start` returns a system root, not a global singleton. Deref it +to get the built component. You can hold a reference to it, start a +second system alongside, pass it around, and stop it independently. +No global registry to reset, no namespace to reload. + + +## Live redefinition works + +Redefine a service with `defn`, and the running system uses the +new version immediately. No restart, no lost state. But the +service keeps receiving the dependencies that were resolved at +start — to add a new dependency, restart the system. The +workflow: +[Interactive development](/doc/tutorial/d_interactive_development_test.md). + +## Tests share a cached system + +Each test starts its own system and stops it normally. Components +built in one test are reused by the next — DI caches them across +tests via +[`di/->memoize`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#->memoize). +The whole suite runs as fast as a single system start. Teardown +happens once, at the end. The recipe: +[Reusing components between tests](/doc/how_to/reusing_components_between_tests_test.md). + +## Partial start failures are contained + +If start fails halfway through, DI stops the components it already +started before propagating the error. If those stops also fail, +their exceptions are captured alongside the original — nothing is +lost. + +--- + +The [tutorial](/doc/tutorial/a_your_first_system_test.md) walks through each of +these, one chapter at a time. For the model behind the library, +read [Design](/doc/design.md); if you are choosing between DI and +Integrant, see [Integrant vs DI](/doc/integrant.md). diff --git a/example/src/example/system.clj b/example/src/example/system.clj index 8604820a..0dab5293 100644 --- a/example/src/example/system.clj +++ b/example/src/example/system.clj @@ -13,7 +13,7 @@ ::hikari/options (di/template {:adapter "h2" :url (di/ref "H2_URL")})} (di/update-key `reitit/route-data conj (di/ref `core/route-data)) - (di/add-side-dependency `flyway/migrate) + (di/prepend-side-dependency `flyway/migrate) (di/env-parsing :env.long parse-long) #_(if some-feature-flag [(di/update-key `reitit/route-data conj ...)])]) diff --git a/script/tutorial-to-md.sh b/script/tutorial-to-md.sh index 6326f687..91cdfd3d 100755 --- a/script/tutorial-to-md.sh +++ b/script/tutorial-to-md.sh @@ -1,36 +1,40 @@ #!/usr/bin/env bash set -euo pipefail -src=test/darkleaf/di/tutorial -dst=doc/tutorial - -mkdir -p "$dst" - -for f in "$src"/*.clj; do - name=$(basename "$f" .clj) - awk ' - /^;;/ { - if (in_code) { - print "```"; in_code = 0 - if (blanks == 0) blanks = 1 - } - for (i = 0; i < blanks; i++) print "" - blanks = 0 - sub(/^;; ?/, ""); print; emitted = 1; next - } - /^[[:space:]]*$/ { blanks++; next } - { - if (!in_code) { - if (emitted && blanks == 0) blanks = 1 - for (i = 0; i < blanks; i++) print "" - blanks = 0 - print "```clojure"; in_code = 1 - } else { +convert () { + local src=$1 dst=$2 + mkdir -p "$dst" + for f in "$src"/*.clj; do + [ -e "$f" ] || continue + name=$(basename "$f" .clj) + awk ' + /^;;/ { + if (in_code) { + print "```"; in_code = 0 + if (blanks == 0) blanks = 1 + } for (i = 0; i < blanks; i++) print "" blanks = 0 + sub(/^;; ?/, ""); print; emitted = 1; next + } + /^[[:space:]]*$/ { blanks++; next } + { + if (!in_code) { + if (emitted && blanks == 0) blanks = 1 + for (i = 0; i < blanks; i++) print "" + blanks = 0 + print "```clojure"; in_code = 1 + } else { + for (i = 0; i < blanks; i++) print "" + blanks = 0 + } + print; emitted = 1 } - print; emitted = 1 - } - END { if (in_code) print "```" } - ' "$f" > "$dst/${name}.md" -done + END { if (in_code) print "```" } + ' "$f" > "$dst/${name}.md" + done +} + +convert test/darkleaf/di/tutorial doc/tutorial +convert test/darkleaf/di/how_to doc/how_to +convert test/darkleaf/di/reference doc/reference diff --git a/src/darkleaf/di/core.clj b/src/darkleaf/di/core.clj index 70ac94d5..d8a45a31 100644 --- a/src/darkleaf/di/core.clj +++ b/src/darkleaf/di/core.clj @@ -39,7 +39,7 @@ `:required` wins over `:optional`. A reducing function (0/1/2-arity) suitable for `reduce` and `transduce`. - Use when implementing `p/dependencies` for a custom `p/Factory`." + Use it when implementing `p/dependencies` for a custom `p/Factory`." ([] {}) ([a] a) ([a b] @@ -250,8 +250,9 @@ #{key}]) factory (reify p/Factory (dependencies [_] - (concat (p/dependencies factory) - {::side-dependency :required})) + (concat {::prepended-side-dependency :required} + (p/dependencies factory) + {::added-side-dependency :required})) (build [_ deps add-stop] (p/build factory deps add-stop)) (description [_] @@ -267,11 +268,12 @@ (defn- with-internals [registry] (fn [key] (case key - ::side-dependency (reify p/Factory - (dependencies [_]) - (build [_ _ _] '_) - (description [_] - {::implementation-detail true})) + (::prepended-side-dependency + ::added-side-dependency) (reify p/Factory + (dependencies [_]) + (build [_ _ _] '_) + (description [_] + {::implementation-detail true})) (registry key)))) (def ^:private initial-registry @@ -386,9 +388,8 @@ `some-key replacement \"LOG_LEVEL\" \"info\"} [dev-middlewares test-middlewares] - (if dev-routes? - (di/update-key `route-data conj `dev-route-data) - (di/instrument `log)) + (when dev-routes? + (di/update-key `route-data conj (di/ref `dev-route-data)))) ``` Returns a container containing the started root of the system. @@ -410,7 +411,10 @@ (start* ::implicit-root [middlewares (implicit-root key)])) (defn stop - "Stops the root of a system" + "Stops the root of a system. + + If several components throw during stop, the first exception is + rethrown and the rest are attached to it as suppressed exceptions." [^AutoCloseable root] (cond (nil? root) nil @@ -478,9 +482,14 @@ (defn derive "Applies `f` to an object built from `key`. + Returns a factory. + + `key` is depended on as `:optional`: if it is not defined, + `f` receives `nil`, so make `f` nil-safe. + `args` are extra arguments passed to `f` after the object. ```clojure - (def port (-> (di/derive \"PORT\" (fnil parse-long \"8080\")))) + (def port (di/derive \"PORT\" (fnil parse-long \"8080\"))) ``` See `ref`, `template`." @@ -608,36 +617,66 @@ factory (registry key)))))) +(defn- side-dependency [side-key dep-key] + (fn [registry] + (fn [key] + (let [factory (registry key)] + (condp = key + side-key (reify p/Factory + (dependencies [_] + ;; This is an incorrect implementation that does not preserve order. + ;; (assoc (p/dependencies factory) + ;; dep-key :required) + (concat (p/dependencies factory) + {dep-key :required})) + (build [_ deps add-stop] + (p/build factory deps add-stop)) + (description [_] + (p/description factory))) + dep-key (update-description factory assoc ::side-dependency true) + factory))))) + (defn add-side-dependency "A registry middleware for adding side dependencies. - Use it for migrations or other side effects. + Use it for setup steps and other side effects. + + A side dependency is built after the root and its dependencies. + Several side dependencies are built in the order they were added. + See `prepend-side-dependency` for side dependencies that are + built before the root. + + ```clojure + (defn warmup + {::di/kind :component} + [{cache `cache}] + (fill-cache cache)) + + (di/start ::root (di/add-side-dependency `warmup)) + ```" + [dep-key] + (side-dependency ::added-side-dependency dep-key)) + +(defn prepend-side-dependency + "A registry middleware for adding side dependencies + that are built before the root and its dependencies. + Use it for migrations and other setup steps that must finish + before the rest of the system starts. + Several prepended side dependencies are built in the order + they were added. All of them are built before the root. + See `add-side-dependency`. ```clojure - (defn flyway [{url \"DATABASE_URL\"}] + (defn flyway + {::di/kind :component} + [{url \"DATABASE_URL\"}] (.. (Flyway/configure) ...)) - (di/start ::root (di/add-side-dependency `flyway)) + (di/start ::root (di/prepend-side-dependency `flyway)) ```" [dep-key] - (fn [registry] - (fn [key] - (let [factory (registry key)] - (condp = key - ::side-dependency (reify p/Factory - (dependencies [_] - ;; This is an incorrect implementation that does not preserve order. - ;; (assoc (p/dependencies factory) - ;; dep-key :required) - (concat (p/dependencies factory) - {dep-key :required})) - (build [_ deps add-stop] - (p/build factory deps add-stop)) - (description [_] - (p/description factory))) - dep-key (update-description factory assoc ::side-dependency true) - factory))))) + (side-dependency ::prepended-side-dependency dep-key)) (defn- arglists [variable] @@ -778,6 +817,9 @@ and its value will be a number. `cmap` is a map of prefixes and parsers. + The underlying env dependency is optional: if the variable is not set, + the value is `nil` and the parser is not called. + ```clojure (defn root [{port :env.long/PORT}] ...) @@ -830,7 +872,10 @@ (defn ns-publics "A registry middleware that interprets a whole namespace as a component. - A component will be a map of var names to corresponding components. + The built component is a map of simple keywords to built objects: + each public var name becomes a keyword, e.g. the var `handler` + becomes the key `:handler`. Unbound vars and vars holding `nil` + are skipped. The key of a component is a keyword with the namespace `:ns-publics` and a name containing the name of a target ns. @@ -838,7 +883,7 @@ This enables access to all public components, which is useful for testing. - See the test `darkleaf.di.tutorial.x-ns-publics-test`. + See the test `darkleaf.di.how-to.ns-publics-test`. ```clojure (di/start :ns-publics/io.github.my.ns (di/ns-publics)) @@ -870,7 +915,7 @@ (registry key))))) (defmacro with-open - "A `c/with-open` variant that supports destructuring in bindings. + "A `clojure.core/with-open` variant that supports destructuring in bindings. `bindings` => `[name init ...]` Evaluates `body` in a try expression with names bound to the values @@ -893,10 +938,13 @@ (defn log "A logging middleware. - Calls `:after-build!` and `:after-demolish!` during `di/start`. - Must be the last one in the middleware chain. - Both callbacks are expected to accept - the following arg `{:keys [key object]}`." + Calls `:after-build!` when an object is built during `di/start`, + and `:after-demolish!` when it is stopped. + Both callbacks receive a map `{:keys [key object]}`. + + `di/log` records only what the middlewares before it define: + a key resolved by a middleware added after it is not logged. + Put it last to log the whole system." [& {:keys [after-build! after-demolish!] #_#_:as opts :or {after-build! (fn no-op [_]) @@ -943,17 +991,40 @@ deps)))))) (defn inspect - "Collects and returns a vector of keys along with their dependencies. - Useful for inspecting enabled components and services. - Evaluates all registries with middlewares applied. + "Walks the registry like `start`, but builds nothing. - Expects the same arguments as `start` and returns a vector of keys with dependencies e.g.: + Expects the same arguments as `start` — a `key` and registry + middlewares — and returns the dependency graph as a vector of maps, + one per factory the walk visits. The graph is walked exactly as + `start` would walk it — middlewares applied, overrides in place — so + the report matches the system you would get. + + Each map holds up to three entries: + + - `:key` — the key naming the factory. + - `:dependencies` — a map of each dependency key to `:required` or + `:optional`. Absent when the factory depends on nothing. + - `:description` — a diagnostic map from the factory's own + `description` method. See [[darkleaf.di.protocols/Factory]]. + + Two markers come from the walk itself. `::di/root true` marks each + key you asked for. A key that no registry resolves is reported as + `{::di/kind :undefined}`. ```clojure - [{:key `root :dependencies {`foo :required `bar :optional}} - {:key `foo} - {:key `bar}] - ```" + (di/inspect `root) + ;; => [{:key `root + ;; :dependencies {`foo :required, `bar :optional} + ;; :description {::di/kind :service, ::di/root true}} + ;; {:key `foo :description {::di/kind :trivial, :object 42}} + ;; {:key `bar :description {::di/kind :undefined}}] + ``` + + Pass a vector or a map as the first argument to inspect many roots + at once. + + Reach for it to verify how middlewares reshape a system, debug a + wiring mismatch, or feed a dependency-graph visualizer." [key & middlewares] (with-open [components (start* ::implicit-root [middlewares diff --git a/src/darkleaf/di/destructuring_map.clj b/src/darkleaf/di/destructuring_map.clj index 3e188e79..ba5c013f 100644 --- a/src/darkleaf/di/destructuring_map.clj +++ b/src/darkleaf/di/destructuring_map.clj @@ -75,7 +75,7 @@ {key tag}))) (defn dependencies - "Parses destructuring map into a map of dependency key and its type" + "Parses a destructuring map into a map from dependency key to dependency type." [m] (let [defaults (:or m) m (dissoc m :or :as)] diff --git a/test/darkleaf/di/add_side_dependency_test.clj b/test/darkleaf/di/add_side_dependency_test.clj index 856fa57d..0b91f002 100644 --- a/test/darkleaf/di/add_side_dependency_test.clj +++ b/test/darkleaf/di/add_side_dependency_test.clj @@ -118,6 +118,19 @@ @log)) (t/is (= :ok @root))))) + +(t/deftest prepend-side-dependency-test + (let [log (atom []) + after-build! (fn [{:keys [key]}] + (swap! log conj key))] + (with-open [root (di/start ::root + {::root :ok} + (di/prepend-side-dependency `d1) + (di/prepend-side-dependency `d2) + (di/log :after-build! after-build!))] + (t/is (= [`d1 `d2 ::root] @log)) + (t/is (= :ok @root))))) + (t/deftest bug-with-update-key (let [info (di/inspect ::root {::root 42 diff --git a/test/darkleaf/di/how_to/feature_flags_test.clj b/test/darkleaf/di/how_to/feature_flags_test.clj new file mode 100644 index 00000000..54869990 --- /dev/null +++ b/test/darkleaf/di/how_to/feature_flags_test.clj @@ -0,0 +1,253 @@ +;; # Feature flags + +(ns darkleaf.di.how-to.feature-flags-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; Parts of an application can be optional: a feature sold in a +;; paid plan, an integration that only some deployments configure. +;; A feature flag decides whether such a part runs. + +;; A flag is an `if`. The usual place for that `if` is the running +;; code: read the flag, branch. This recipe moves it to build time. +;; A registry function reads the flag once, while the system is +;; assembled, and decides what to build. The components it wires +;; carry no flag check. A disabled feature is not built at all. + +;; Two techniques do this. A feature others depend on is +;; substituted with a null object. A feature that only adds +;; components is dropped by a conditional registry. The last +;; section shows where the flags themselves come from — a +;; bootstrap start. + +;; ## The example system + +;; The example is a small web application with two flagged +;; features: a shop and a geoip lookup. The shop contributes a +;; route and a handler. The handler shows the shipping city, so it +;; calls the geoip lookup. The lookup resolves an IP address to a +;; city. The real implementation opens a database — a file the +;; operator downloads and points the application at. When geoip is +;; off, the application must not demand any of that. The full plan +;; enables both features. The lite plan runs without them. + +;; Everything sits in this one test namespace to stay runnable. In +;; a real project each part is its own namespace: the web adapter +;; owns the route table, each subsystem owns its components and +;; its registry function, and the system namespace assembles them. +;; The sections below follow that split. + +;; The route table starts empty. Subsystems extend it: + +(def routes []) + +;; Flags are a plain map: namespaced keywords, boolean values. +;; Nothing di-specific yet. + +(def full-flags + #:app.features{:shop-enabled true + :geoip-enabled true}) + +(def lite-flags + #:app.features{:shop-enabled false + :geoip-enabled false}) + +;; ## The geoip subsystem + +;; Each subsystem defines a `registry` function. It takes the +;; flags and decides what to contribute. + +;; `geoip` has consumers — the shop is one. Dropping the component +;; would break them, so the registry substitutes it instead. The +;; lookup is a protocol. The real implementation and a null object +;; both honor it: + +(defprotocol GeoIP + (lookup-city [this ip])) + +(defn geoip-impl + {::di/kind :component} + [{path "GEOIP_DB_PATH"}] + ;; the real implementation opens the database at `path` + (reify GeoIP + (lookup-city [_ ip] + "Berlin"))) + +(defn null-geoip + {::di/kind :component} + [] + (reify GeoIP + (lookup-city [_ _ip] + "unknown"))) + +;; The registry binds the `geoip` key explicitly in both states. +;; Consumers depend on `geoip` and never check the flag. The `if` +;; fails safe: if the flag is renamed or misspelled, reading it +;; yields `nil`, and the key goes to the null object. A broken +;; flag turns the feature off, not on: + +(defn geoip-registry [{:app.features/keys [geoip-enabled]}] + {`geoip (di/ref (if geoip-enabled + `geoip-impl + `null-geoip))}) + +;; ## The shop subsystem + +;; The shop is the other case: nothing depends on it. A disabled +;; shop can simply be absent. + +;; The handler needs the city, so it depends on `geoip`. It +;; carries no flag check: + +(defn shop-handler [{geoip `geoip} request] + {:status 200 + :body (str "shipping to " (lookup-city geoip (:remote-addr request)))}) + +;; The route pairs a path with the handler, so the contribution is +;; a template +;; ([Wiring inside data](/doc/tutorial/i_wiring_inside_data_test.md)) — +;; `di/update-key` accepts factories as arguments. The registry +;; wraps the entry in `when`. A disabled entry is `nil`, and `nil` +;; is a valid middleware — a no-op +;; ([The middleware argument](/doc/reference/middleware_argument.md)). +;; With this shape one vector holds any number of conditional +;; entries ([Tips](/doc/how_to/tips_test.md)): + +(defn shop-registry [{:app.features/keys [shop-enabled]}] + [(when shop-enabled + (di/update-key `routes conj + (di/template ["/shop" (di/ref `shop-handler)])))]) + +;; ## The application registry + +;; The top-level registry function passes the flags to every +;; subsystem: + +(defn app-registry [flags] + [#_flags + (geoip-registry flags) + (shop-registry flags)]) + +;; The commented `flags` line is an option. A map is a registry of +;; constants, so adding the map itself to the chain turns every +;; flag into a component. Do that when the running system needs +;; the values: to introspect them, or to report enabled features +;; to a client. But do not branch on a flag at run time — that +;; puts the `if` back into the running code. + +;; ## Testing a flagged system + +;; Run the test suite against the full configuration — every +;; feature on — and share that system across the tests with +;; `di/->memoize` +;; ([Reusing components between tests](/doc/how_to/reusing_components_between_tests_test.md)). + +;; The memoized registry is created from one flag configuration and +;; serves only that shape of the system. Tests for the other shape +;; do not need a second cache, because they do not need a whole +;; system. Any component can be a root. The null object starts +;; alone: + +(t/deftest null-geoip-test + (with-open [geoip (di/start `null-geoip)] + (t/is (= "unknown" (lookup-city @geoip "1.2.3.4"))))) + +;; The lite plan needs a different kind of test. A license is a +;; promise about composition: the deployment must not open features +;; the customer did not buy. To be sure of that, there is no need +;; to run the application. It is enough to look at what the system +;; is made of and compare it with a whitelist. The full plan has +;; no whitelist to violate — it enables everything — and the suite +;; already runs against it. + +;; `di/inspect` +;; ([Inspect](/doc/reference/inspect_test.md)) takes the same +;; arguments as `di/start` and reports the graph the runtime would +;; build, without building anything. The example system is a +;; handful of keys, so its whitelist is the literal key set: + +(defn- plan-keys [flags] + (into #{} + (map :key) + (di/inspect `routes (app-registry flags)))) + +(t/deftest lite-plan-test + ;; no shop, no geoip, no GEOIP_DB_PATH — a lite deployment does + ;; not have to provide the database or the variable at all + (t/is (= #{`routes} + (plan-keys lite-flags)))) + +;; A real system has too many keys to list, and the list would +;; break on every new component. Aggregate the report instead. +;; Constants supplied by the registries report themselves as +;; `:trivial` — drop them. Map every qualified key — symbol or +;; keyword, `qualified-ident?` covers both — to its namespace, and +;; keep string keys as they are. The result is one set of +;; namespaces and environment variables. A new component in an +;; existing subsystem does not change it. It changes when the +;; plan's composition changes — a new subsystem, a new variable — +;; and that is what the whitelist must catch: + +;; ```clojure +;; (into #{} +;; (comp +;; (remove #(= :trivial (-> % :description ::di/kind))) +;; (map (fn [{:keys [key]}] +;; (if (qualified-ident? key) +;; (namespace key) +;; key)))) +;; (di/inspect `routes (app-registry flags))) +;; ``` + +;; The variables in that set are the configuration contract of the +;; deployment: a deployment that misses one fails at `di/start`, +;; before any traffic. So the test also guards backward +;; compatibility — a new required variable is a breaking change +;; for every existing deployment, and it must be a deliberate +;; decision, not a side effect of wiring. List the test file in +;; CODEOWNERS: adding a variable then requires an explicit review. + +;; ## Flag sources + +;; In the tests above the flags were literals. In production they +;; are computed: from environment variables, from a license key +;; that encodes the customer's plan. The natural first attempt is +;; to make `flags` a component of the system. It does not work. +;; The registries need the flags before `di/start` — the flags +;; decide what the registries are. + +;; The way out is a bootstrap start. `flags` is a component — of a +;; separate, tiny system that starts first. Its built value is a +;; plain map: pass it to the registry functions of the real +;; system. + +;; The flag variables go through `di/env-parsing` +;; ([Environment variables](/doc/tutorial/g_environment_variables_test.md)). +;; The bootstrap is a separate system, but it can reuse some +;; middlewares of the real one — here the configured +;; `env-parsing` — so variables follow one parsing convention +;; everywhere. A missing +;; variable defaults to off — the same fail-safe rule as the geoip +;; binding: + +(def env-parsing-registry + (di/env-parsing :env.bool parse-boolean)) + +(defn flags + {::di/kind :component} + [{shop-enabled :env.bool/SHOP_ENABLED + geoip-enabled :env.bool/GEOIP_ENABLED + :or {shop-enabled false + geoip-enabled false}}] + #:app.features{:shop-enabled shop-enabled + :geoip-enabled geoip-enabled}) + +(t/deftest bootstrap-test + (with-open [flags (di/start `flags + env-parsing-registry + {"SHOP_ENABLED" "true"}) + routes (di/start `routes (app-registry @flags))] + ;; GEOIP_ENABLED is not set, so the flag defaulted to off + (t/is (= false (:app.features/geoip-enabled @flags))) + (t/is (= ["/shop"] (mapv first @routes))))) diff --git a/test/darkleaf/di/tutorial/x_log_test.clj b/test/darkleaf/di/how_to/log_test.clj similarity index 51% rename from test/darkleaf/di/tutorial/x_log_test.clj rename to test/darkleaf/di/how_to/log_test.clj index 6348c9e4..a0a82cae 100644 --- a/test/darkleaf/di/tutorial/x_log_test.clj +++ b/test/darkleaf/di/how_to/log_test.clj @@ -1,28 +1,29 @@ -;; # Log +;; # Logging system lifecycle -(ns darkleaf.di.tutorial.x-log-test +(ns darkleaf.di.how-to.log-test (:require [clojure.test :as t] [darkleaf.di.core :as di])) -;; `di/log` is a middleware that fires a callback every time a -;; factory is built and every time it is demolished. Each callback -;; receives `{:keys [key object]}` — the key and the built value (or -;; the value about to be demolished). +;; `di/log` fires a callback every time a factory is built and +;; every time it is stopped — `:after-build!` and +;; `:after-demolish!`. Each callback receives +;; `{:keys [key object]}` — the key and the built value (or the +;; value about to be stopped). ;; Reach for it to instrument the system at runtime — time each -;; build or demolish step, or stream lifecycle events into your -;; logging system. To analyze build and teardown order without +;; build or stop step, or stream lifecycle events into your +;; logging system. To analyze build and stop order without ;; actually running the system, use -;; [`di/inspect`](/doc/tutorial/x_inspect_test.md) instead. +;; [`di/inspect`](/doc/reference/inspect_test.md) instead. -;; `di/log` must be the last middleware in the chain — it wraps every -;; factory the registry exposes, and anything appended after it ends -;; up between `log` and the original factory. +;; Put `di/log` last when you call `di/start`. `log` reports +;; every factory before it in the argument list. Anything after +;; `log` is not reported. ;; The components below form a chain `c → b → a`. Builds run in -;; dependency order; demolitions run in reverse — last built, first -;; demolished. Note also how the printed forms differ: a component +;; dependency order. Stops run in reverse — last built, first +;; stopped. The printed forms also differ: a component ;; shows its built value, a service shows the var it points to. (defn a @@ -38,6 +39,10 @@ [{b `b}] :c) +;; The callbacks log via `pr-str` because service `b` builds to +;; a partial fn — not `=`-comparable to a literal, but it has a +;; custom print method that yields a stable string. + (t/deftest log-test (let [logs (atom []) after-build! (fn [{:keys [key object]}] @@ -50,10 +55,10 @@ (di/stop root) (t/is (= [[:built `a ":a"] [:built `b - "#darkleaf.di.core/service #'darkleaf.di.tutorial.x-log-test/b"] + "#darkleaf.di.core/service #'darkleaf.di.how-to.log-test/b"] [:built `c ":c"] [:demolished `c ":c"] [:demolished `b - "#darkleaf.di.core/service #'darkleaf.di.tutorial.x-log-test/b"] + "#darkleaf.di.core/service #'darkleaf.di.how-to.log-test/b"] [:demolished `a ":a"]] @logs)))) diff --git a/test/darkleaf/di/tutorial/y_multi_arity_service_test.clj b/test/darkleaf/di/how_to/multi_arity_service_test.clj similarity index 51% rename from test/darkleaf/di/tutorial/y_multi_arity_service_test.clj rename to test/darkleaf/di/how_to/multi_arity_service_test.clj index df817580..a390e54b 100644 --- a/test/darkleaf/di/tutorial/y_multi_arity_service_test.clj +++ b/test/darkleaf/di/how_to/multi_arity_service_test.clj @@ -1,11 +1,15 @@ -;; # Multi arity service +;; # Multi-arity services -(ns darkleaf.di.tutorial.y-multi-arity-service-test +(ns darkleaf.di.how-to.multi-arity-service-test (:require [clojure.test :as t] [darkleaf.di.core :as di])) -;; DI collects dependencies from all arities and only then resolves dependencies. +;; Clojure services often have multiple arities — one with defaults +;; that calls into a richer one. DI handles this by collecting +;; dependencies from every arity, then resolving them once before +;; the service is bound. Each arity receives the same fully-resolved +;; dependency map. (defn multi-arity-service ([{a `a, :as deps}] @@ -15,9 +19,12 @@ ([deps arg1 arg2] [::result deps arg1 arg2])) +;; The 1-arg arity declares `a`; the 2-arg arity declares `b`. DI +;; reads both and resolves both — when you call `(s)`, you still +;; get a map containing `a` and `b`. + (t/deftest multi-arity-service-test (with-open [s (di/start `multi-arity-service {`a :a, `b :b})] - ;; each arity gets all the dependencies (t/is (= [::result {`a :a, `b :b} :a1 :a2] (s))) (t/is (= [::result {`a :a, `b :b} :arg1 :a2] (s :arg1))) (t/is (= [::result {`a :a, `b :b} :arg1 :arg2] (s :arg1 :arg2))))) diff --git a/test/darkleaf/di/how_to/multimethods_test.clj b/test/darkleaf/di/how_to/multimethods_test.clj new file mode 100644 index 00000000..175b04fc --- /dev/null +++ b/test/darkleaf/di/how_to/multimethods_test.clj @@ -0,0 +1,49 @@ +;; # Multimethods + +(ns darkleaf.di.how-to.multimethods-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; A `defmulti` can be a service, but DI cannot read its argument +;; list the way it reads a `defn`. Declare the dependencies in +;; metadata under `::di/deps`. + +(defmulti service + {::di/deps [::x]} + (fn [-deps kind] kind)) + +(defmethod service :default [{x ::x} kind] + [kind x]) + +(t/deftest required-dep-test + (with-open [root (di/start `service {::x :value})] + (t/is (= [:kind :value] (root :kind))))) + +;; ## Optional dependencies + +;; `::di/deps` only declares required dependencies. To make a +;; dependency optional, route it through +;; [`di/derive`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#derive). +;; A factory made by `di/derive` depends on its source key as +;; *optional*: DI applies the function to the built value, or to +;; `nil` when the key is not defined. Here `wrap-default` turns +;; that `nil` into a fallback value: + +(defn- wrap-default [x default] + (if (some? x) x default)) + +(def dep (di/derive ::optional wrap-default :default-value)) + +;; The multimethod still requires `::x`, so the registry binds +;; `::x` to `dep` with `di/ref`: the required dependency is +;; satisfied by the derived value, and the optional part lives +;; inside `dep`. See also +;; [Transforming values](/doc/tutorial/j_transforming_values_test.md). + +(t/deftest optional-dep-test + (with-open [root (di/start `service {::x (di/ref `dep), ::optional :value})] + (t/is (= [:kind :value] (root :kind)))) + + (with-open [root (di/start `service {::x (di/ref `dep)})] + (t/is (= [:kind :default-value] (root :kind))))) diff --git a/test/darkleaf/di/tutorial/z_multi_system_test.clj b/test/darkleaf/di/how_to/multiple_systems_test.clj similarity index 57% rename from test/darkleaf/di/tutorial/z_multi_system_test.clj rename to test/darkleaf/di/how_to/multiple_systems_test.clj index 6f367821..3ecb1cb2 100644 --- a/test/darkleaf/di/tutorial/z_multi_system_test.clj +++ b/test/darkleaf/di/how_to/multiple_systems_test.clj @@ -1,14 +1,16 @@ -;; # Multi system -(ns darkleaf.di.tutorial.z-multi-system-test +;; # Multiple systems + +(ns darkleaf.di.how-to.multiple-systems-test (:require [clojure.test :as t] [darkleaf.di.core :as di])) -;; In some cases, you may need multiple systems and to share a subsystem between them. -;; In that case, just pass the subsystem in the registry. - -;; To get a value of the subsystem, you should `deref` it as you would for a regular system root. -;; Also you should manually stop systems in reverse order. +;; Sometimes you need several running systems that share a +;; subsystem — for example a few independent web apps backed by +;; the same database pool. Start the shared subsystem first, +;; deref it for its built value, and pass that value through the +;; registry of each downstream system. Stop them in reverse +;; order when you are done. (defn shared {::di/kind :component} @@ -29,4 +31,5 @@ ::name :b})] (t/is (= :a (first @a))) (t/is (= :b (first @b))) + ;; both servers see the same shared instance (t/is (identical? (second @a) (second @b))))) diff --git a/test/darkleaf/di/how_to/ns_publics_test.clj b/test/darkleaf/di/how_to/ns_publics_test.clj new file mode 100644 index 00000000..5a32d66b --- /dev/null +++ b/test/darkleaf/di/how_to/ns_publics_test.clj @@ -0,0 +1,48 @@ +;; # All public vars as a component + +(ns darkleaf.di.how-to.ns-publics-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; `di/ns-publics` treats every public var of a namespace as a +;; component or service and bundles them under +;; `:ns-publics/`. Starting that key gives you a map of +;; var name → built object — handy when you want a group of +;; related components and services without listing each one +;; explicitly. + +;; Vars holding `nil` or unbound vars are skipped. + +(def nil-component nil) ; excluded + +(def unbound-component) ; excluded + +(defn component + {::di/kind :component} + [] + :component) + +(defn service [{component `component} arg] + [component arg]) + +(t/deftest ok-test + (with-open [system (di/start :ns-publics/darkleaf.di.how-to.ns-publics-test + (di/ns-publics))] + (t/is (map? @system)) + (t/is (= #{:component :service :ok-test} + (set (keys @system)))) + (t/is (= :component (:component system))) + (t/is (= [:component :my-arg] ((:service system) :my-arg))))) + +;; ## In practice + +;; The feature originated in test infrastructure: a single global +;; test system was kept running, with adapter namespaces (a +;; database client, etc.) registered as roots via `ns-publics` so +;; any test could reach into them directly. `di/->memoize` later +;; covered the same ground with less ceremony — each test starts +;; just the keys it touches against a shared cache. `ns-publics` +;; still works as documented, but `->memoize` is the preferred +;; way to do that now. See +;; [Reusing components between tests](/doc/how_to/reusing_components_between_tests_test.md). diff --git a/test/darkleaf/di/tutorial/z_two_databases_test.clj b/test/darkleaf/di/how_to/one_factory_many_keys_test.clj similarity index 66% rename from test/darkleaf/di/tutorial/z_two_databases_test.clj rename to test/darkleaf/di/how_to/one_factory_many_keys_test.clj index 69a20971..d62b8518 100644 --- a/test/darkleaf/di/tutorial/z_two_databases_test.clj +++ b/test/darkleaf/di/how_to/one_factory_many_keys_test.clj @@ -1,14 +1,22 @@ -;; # Two databases +;; # One factory, many keys -(ns darkleaf.di.tutorial.z-two-databases-test +(ns darkleaf.di.how-to.one-factory-many-keys-test (:require [clojure.string :as str] [clojure.test :as t] [darkleaf.di.core :as di] [darkleaf.di.protocols :as p])) -;; In DI, each key corresponds to one object. So if you want to use two databases -;; you have to define two keys. +;; Each key names one component. When several components share the same +;; build logic and differ only by their parameters, write the factory once +;; as a function that returns a +;; [`Factory`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.protocols#Factory), +;; then bind its result to a separate key for each component. + +;; The example connects to two databases. `db-factory` takes a database +;; name and returns a factory whose dependencies are the environment +;; variables for that database. `db-a` and `db-b` are two keys backed by +;; the same factory. (defn db-factory [db-name] (let [db-name (-> db-name name str/upper-case) diff --git a/test/darkleaf/di/how_to/reusing_components_between_tests_test.clj b/test/darkleaf/di/how_to/reusing_components_between_tests_test.clj new file mode 100644 index 00000000..2d29b904 --- /dev/null +++ b/test/darkleaf/di/how_to/reusing_components_between_tests_test.clj @@ -0,0 +1,242 @@ +;; # Reusing components between tests + +(ns darkleaf.di.how-to.reusing-components-between-tests-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di] + [darkleaf.di.utils :refer [catch-some]])) + +;; An integration test starts a system, exercises it, and stops it. +;; Each `di/start` builds every component the test needs. When some +;; of them are expensive — a database connection, an HTTP server, a +;; parser with a heavy dictionary — every test pays that cost again. + +;; [`di/->memoize`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#->memoize) +;; returns a stateful middleware +;; ([The middleware argument](/doc/reference/middleware_argument.md)) +;; that caches built components across `di/start` calls. Each test +;; starts only the keys it needs. The first test builds the +;; expensive components, the rest take them from the cache. + +;; This recipe models a small web stack. The handler depends on a +;; database connection and a parser. The `Object.` instances stand +;; in for real connection state — the tests compare them by +;; identity to tell a cached component from a rebuilt one. + +(defn db + {::di/kind :component} + [{url "DB_URL"}] + {:url url, :conn (Object.)}) + +(defn parser + {::di/kind :component} + [] + (Object.)) + +(defn handler + {::di/kind :component} + [{db `db, parser `parser}] + {:db db, :parser parser}) + +;; Two tests start the handler against the same memoized registry. +;; Here they are folded into one `deftest` so the recipe stays +;; self-contained. + +(t/deftest reuse-test + (with-open [mem (di/->memoize {"DB_URL" "jdbc:test"}) + first (di/start `handler mem) + second (di/start `handler mem)] + ;; the second start took every component from the cache + (t/is (identical? @first @second)))) + +;; ## Per-test overrides + +;; The memoized registry must be the first middleware in +;; `di/start`. Per-test overrides go after it: + +(t/deftest mem-goes-first-test + (with-open [mem (di/->memoize)] + (let [ex (catch-some (di/start `handler {"DB_URL" "jdbc:test"} mem))] + (t/is (= ::di/wrong-memoized-registry-position + (-> ex ex-data :type)))))) + +;; An override rebuilds only the components that depend on it. The +;; rest still comes from the cache. And overrides are compared by +;; value: pass an equal override again and you get the cached +;; components back. + +(t/deftest override-test + (with-open [mem (di/->memoize {"DB_URL" "jdbc:test"}) + first (di/start `handler mem) + second (di/start `handler mem {"DB_URL" "jdbc:other"}) + third (di/start `handler mem {"DB_URL" "jdbc:other"})] + ;; the new url produced a new db... + (t/is (not (identical? (:db @first) (:db @second)))) + ;; ...while the parser does not depend on it and was reused + (t/is (identical? (:parser @first) (:parser @second))) + ;; an equal override hits the cache + (t/is (identical? @second @third)))) + +;; ## Who stops what + +;; The memoized registry owns everything it builds. Stopping a +;; test's system does not stop cached components. They live until +;; you stop the memoized registry itself with `di/stop`. + +;; To observe the lifecycle, this test folds `di/log` +;; ([Logging system lifecycle](/doc/how_to/log_test.md)) into the +;; cache — `di/->memoize` takes the same middleware values +;; `di/start` does. + +(t/deftest ownership-test + (let [log (atom []) + logging (di/log :after-build! #(swap! log conj [:built (:key %)]) + :after-demolish! #(swap! log conj [:stopped (:key %)])) + mem (di/->memoize {"DB_URL" "jdbc:test"} logging)] + (-> (di/start `db mem) + (di/stop)) + ;; the test's system was stopped, but the cached db was not + (t/is (= [[:built "DB_URL"] + [:built `db]] + @log)) + (swap! log empty) + + (di/stop mem) + ;; stopping mem released everything it cached, in reverse order + (t/is (= [[:stopped `db] + [:stopped "DB_URL"]] + @log)))) + +;; A stub passed through an override deserves attention. Ownership +;; follows from who answers the key. A value in the override map is +;; answered by the map itself, outside the cache. But `di/ref` only +;; redirects the key to another one, and the referenced key is +;; answered by mem. Such a stub is cached and owned by mem like any +;; other component: tests that override `db` the same way share one +;; stub instance, and its stop hook runs at `(di/stop mem)`, not +;; when the test's system stops. + +(defn stub-db + {::di/kind :component + ::di/stop (fn [db] (swap! (:log db) conj :stub-db-stopped))} + [{log ::log}] + {:log log, :conn (Object.)}) + +(t/deftest stub-ownership-test + (let [log (atom []) + mem (di/->memoize {::log log})] + (-> (di/start `handler mem {`db (di/ref `stub-db)}) + (di/stop)) + ;; the test's system stopped, but the stub's stop hook did not run + (t/is (= [] @log)) + + (di/stop mem) + ;; mem answered the `stub-db key, so mem owns the stub + (t/is (= [:stub-db-stopped] @log)))) + +;; Keeping `with-open` around each test's system is still a good +;; habit: a test may build something outside the cache, and +;; stopping a fully cached system is cheap. + +;; ## Editing a component between runs + +;; The cache watches the vars it built components from. Redefine a +;; component — reload its namespace, re-evaluate its `defn` — and +;; the next start rebuilds it and everything that depends on it. +;; The rest of the cache stays. This is what makes the REPL loop +;; fast: edit a component, rerun its test, and only the affected +;; subgraph is rebuilt. + +(t/deftest redefinition-test + (with-open [mem (di/->memoize)] + (defn router + {::di/kind :component} + [] + :v1) + (with-open [system (di/start `router mem)] + (t/is (= :v1 @system))) + + ;; edit and re-evaluate the component + (defn router + {::di/kind :component} + [] + :v2) + ;; the next start picks it up, no restart of mem needed + (with-open [system (di/start `router mem)] + (t/is (= :v2 @system))))) + +;; ## One cache for the whole suite + +;; The tests above create a fresh cache each to stay independent. +;; In a project, one memoized registry serves the whole suite, +;; held in a var. Fold everything the tests share into it: the +;; application registries, stubs for the clock and randomness, +;; test credentials. Per-test variation stays outside, as +;; overrides on `di/start`. + +;; The system namespace owns the cache alongside the application +;; registry. For a CI run, the test-runner entry point wraps the +;; real runner: the cache is created before the first test and +;; released after the last one, even when the run throws. For the +;; REPL there is a pair of start and stop functions that bind the +;; same var. + +;; ```clojure +;; (ns app.system +;; (:require +;; [clojure.edn :as edn] +;; [darkleaf.di.core :as di])) +;; +;; ;; the application registry, shared with prod and dev +;; (defn registry [] +;; [...]) +;; +;; (defn test-registries [] +;; [(registry) +;; {:app/clock (di/ref `fixed-clock)} +;; (-> "env_test.edn" slurp edn/read-string)]) +;; +;; (declare mem) +;; +;; ;; the entry point for `clojure -X:test` +;; (defn wrap-test-runner [{:keys [runner] :as args}] +;; (let [runner (requiring-resolve runner)] +;; (with-open [m (di/->memoize (test-registries))] +;; (def mem m) +;; (runner args)))) +;; +;; ;; the REPL counterpart +;; (defn start-test [] (def mem (di/->memoize (test-registries)))) +;; (defn stop-test [] (di/stop mem)) +;; ``` + +;; The `:test` alias in `deps.edn` points at the wrapper and +;; passes the real runner as an argument: + +;; ```clojure +;; :test {:extra-paths ["test"] +;; :extra-deps {io.github.cognitect-labs/test-runner +;; {:git/tag "v0.5.1" :git/sha "dfb30dd"}} +;; :exec-fn app.system/wrap-test-runner +;; :exec-args {:runner cognitect.test-runner.api/test}} +;; ``` + +;; Tests reach the cache through the var: + +;; ```clojure +;; (ns app.login-test +;; (:require +;; [app.system :refer [mem]] +;; ...)) +;; +;; (t/deftest login-test +;; (with-open [system (di/start `app.web/handler mem +;; {"SESSION_TTL" "60"})] +;; ...)) +;; ``` + +;; The same registry then serves interactive work: start a +;; subsystem, call it by hand, stop it — the expensive components +;; remain running for the next start. The +;; [`di/->memoize`](https://cljdoc.org/d/org.clojars.darkleaf/di/CURRENT/api/darkleaf.di.core#->memoize) +;; docstring shows the matching workflow for a dev system. diff --git a/test/darkleaf/di/how_to/side_dependencies_test.clj b/test/darkleaf/di/how_to/side_dependencies_test.clj new file mode 100644 index 00000000..b546ccc2 --- /dev/null +++ b/test/darkleaf/di/how_to/side_dependencies_test.clj @@ -0,0 +1,67 @@ +;; # Side dependencies + +(ns darkleaf.di.how-to.side-dependencies-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; Some components must run at system start, but nothing else +;; references them. Two middlewares pull such a component into +;; the system without forcing the root to declare it: +;; `di/prepend-side-dependency` builds it before the rest of the +;; system, and `di/add-side-dependency` builds it after. + +;; Migrations go in front — they must finish before the +;; components that use the database start: + +(defn migrations + {::di/kind :component} + [{*migrated? ::*migrated?}] + (reset! *migrated? true)) + +(defn root + {::di/kind :component} + [] + 'root) + +(t/deftest prepend-side-dependency-test + (let [*migrated? (atom false)] + (with-open [root (di/start `root + (di/prepend-side-dependency `migrations) + {::*migrated? *migrated?})] + (t/is @*migrated?) + (t/is (= 'root @root))))) + +;; A cache warmup or a setup step that uses components built by +;; the rest of the system goes after — `di/add-side-dependency`. + +;; ## Build order + +;; DI builds prepended side dependencies first, then the root +;; with its dependencies, and then added side dependencies. +;; Inside each group, side dependencies are built in the order +;; they were added. The system stops in the reverse order. + +;; When the order is a relation between two components, no +;; middleware is needed: declare the task as a dependency of the +;; component that needs it. + +;; ## Composition + +;; Each subsystem declares its setup inside its own registry, so +;; the top-level start never mentions it: + +;; ```clojure +;; ;; users subsystem +;; (defn registry [_] +;; [(di/prepend-side-dependency `migrations)]) +;; +;; ;; main system composes subsystems +;; (di/start `app +;; (users/registry flags) +;; (orders/registry flags)) +;; ``` + +;; See +;; [Composition with `update-key`](/doc/tutorial/k_composition_with_update_key_test.md) +;; for the broader pattern. diff --git a/test/darkleaf/di/how_to/startup_checks_test.clj b/test/darkleaf/di/how_to/startup_checks_test.clj new file mode 100644 index 00000000..3a9a8a3d --- /dev/null +++ b/test/darkleaf/di/how_to/startup_checks_test.clj @@ -0,0 +1,151 @@ +;; # Startup checks + +(ns darkleaf.di.how-to.startup-checks-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di] + [darkleaf.di.utils :refer [catch-some]])) + +;; A deployment can be broken in a way that no component notices. +;; A new version of the application stopped using Redis, but the +;; deployment still runs the server and sets its connection +;; variable: nothing fails, and the mistake silently persists. +;; Or the database server is too old for the code: the system +;; starts fine, and the failure waits for the first query that +;; needs a missing feature — at run time, far from its cause. + +;; A startup check turns such a mistake into an error at system +;; build time: `di/start` throws, the deploy fails, and the log +;; names the fix. +;; DI already fails this way on what it can see — a missing +;; dependency stops the start. A check extends that to what only +;; your code can see. + +;; There are two techniques. A freestanding check — nothing in the +;; system references it — becomes a component of its own, pulled +;; in with `di/add-side-dependency`. A check on an existing +;; component attaches to it with `di/update-key`. + +;; ## A freestanding check + +;; In version 1 the application cached in Redis. In version 2 the +;; cache moved in-process, and the code no longer connects to +;; Redis. But a deployment is more than the application. The +;; operator still runs a Redis server and still sets `REDIS_URL`. +;; An unused variable is not an error, so the upgrade succeeds. +;; `app` stands in for the whole application — in a real project +;; a tree of many components stands behind this stub: + +(defn app + {::di/kind :component} + [] + :app) + +;; A string key is an environment variable +;; ([Environment variables](/doc/tutorial/g_environment_variables_test.md)); +;; in tests a map registry stands in for the real environment. + +;; The check is a component whose whole job is to throw when the +;; mistake is present. It declares the forbidden variable as an +;; optional dependency. The `:or` matters — a check must not +;; require what it forbids. The message is written for the +;; operator reading the log of a failed deploy: it says what to +;; shut down and what to remove. The return value never matters: + +(defn redis-removed + {::di/kind :component} + [{url "REDIS_URL" + :or {url nil}}] + (when (some? url) + (throw (ex-info "Redis is no longer used. Stop the server and remove REDIS_URL" + {:REDIS_URL url})))) + +;; Nothing references the check, so `di/add-side-dependency` pulls +;; it into the system +;; ([Side dependencies](/doc/how_to/side_dependencies_test.md)). +;; When the check fires, the components built before it are +;; stopped before the error propagates +;; ([Handling start failures](/doc/tutorial/l_handling_start_failures_test.md)): + +(t/deftest removed-redis-test + (let [ex (catch-some (di/start `app + (di/add-side-dependency `redis-removed) + {"REDIS_URL" "redis://cache.internal"}))] + ;; `di/start` wraps the failure, the check's exception is the cause + (t/is (= "Redis is no longer used. Stop the server and remove REDIS_URL" + (-> ex ex-cause ex-message))))) + +;; A clean deployment — no `REDIS_URL` — passes the check and +;; never notices it. + +;; The same shape guards other mistakes. A renamed variable that +;; deployments still set under the old name. A known misspelling +;; of a flag variable that would silently leave the feature off. + +;; ## A check on a built component + +;; The second technique inspects a component that already +;; exists. The classic case is a version requirement: the code +;; relies on features of the database server, so a server that is +;; too old must fail the start, not the first query that hits the +;; missing feature. + +;; `db` stands in for a connection component. A real one opens a +;; connection. The stub carries only what the check reads: + +(defn db + {::di/kind :component} + [] + {:version 14}) + +;; The check is a plain function. It receives the built object, +;; throws or does not, and returns the object untouched: + +(defn check-db-version [db] + ;; a real check queries the server + (let [version (:version db)] + (when (< version 15) + (throw (ex-info "Update the database server to version 15 or higher" + {:version version})))) + db) + +;; `di/update-key` attaches the check to the component +;; ([Composition with update-key](/doc/tutorial/k_composition_with_update_key_test.md)). +;; The stub is too old, so the start fails: + +(t/deftest version-too-old-test + (let [ex (catch-some (di/start `db (di/update-key `db check-db-version)))] + (t/is (= "Update the database server to version 15 or higher" + (-> ex ex-cause ex-message))))) + +;; ## Why not a component that depends on `db`? + +;; The version check could be written like the first one — a +;; micro-component that depends on `db`, added as a side +;; dependency. The difference is what it forces to exist. A side +;; dependency is always built, and it demands its dependencies, so +;; that check would force `db` into every system — including one +;; where a feature flag leaves the database out +;; ([Feature flags](/doc/how_to/feature_flags_test.md)). +;; `di/update-key` wraps the component's own factory. The check +;; runs exactly when `db` is built and never forces it to be. + +;; ## The checks registry + +;; Checks accumulate over the life of a project: a removed +;; dependency in this release, a version requirement in the next. +;; Collect them in one namespace with a registry function, and add +;; it to the chain like any other subsystem: + +;; ```clojure +;; (ns app.checks ...) +;; +;; (defn registry [] +;; [(di/add-side-dependency `redis-removed) +;; (di/update-key `app.db/db check-db-version)]) +;; +;; ;; main system +;; (di/start `app +;; (web/registry) +;; (checks/registry)) +;; ``` diff --git a/test/darkleaf/di/how_to/tips_test.clj b/test/darkleaf/di/how_to/tips_test.clj new file mode 100644 index 00000000..af776d5e --- /dev/null +++ b/test/darkleaf/di/how_to/tips_test.clj @@ -0,0 +1,127 @@ +;; # Tips + +;; A collection of small DI tricks and lesser-known features. + +(ns darkleaf.di.how-to.tips-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; ## `::di/stop` implies `:component` + +;; You don't need to attach `{::di/kind :component}` if the +;; function already has `::di/stop` metadata. DI treats any +;; function with a stop hook as a component — there is no other +;; reasonable interpretation, since services don't have a built +;; value to stop. + +(defn resource + {::di/stop #(reset! % :stopped)} + [] + (atom :running)) + +(t/deftest stop-implies-component-test + (let [root (di/start `resource) + a @root] + (t/is (= :running @a)) + (di/stop root) + (t/is (= :stopped @a)))) + +;; ## Pass a vector instead of `apply` + +;; When your registries come from a helper that returns a +;; collection, you might reach for `(apply di/start ...)`. You do +;; not need to. `di/start` treats a seqable value as a single +;; argument, so pass the collection directly. + +(t/deftest grouped-registry-test + ;; registries from a helper — instead of: + ;; (apply di/start ::root registries) + ;; pass them as one vector: + (with-open [r (di/start ::root [{::root :first} + {::root :replacement}])] + (t/is (= :replacement @r)))) + +;; ## Conditional registry entries + +;; A registry function can wrap each contribution in `when` and +;; return the vector unconditionally. A disabled entry becomes +;; `nil`, and `nil` is a valid middleware — a no-op +;; ([The middleware argument](/doc/reference/middleware_argument.md)). +;; So one vector holds any number of conditional entries. The +;; [Feature flags](/doc/how_to/feature_flags_test.md) recipe uses +;; this shape for subsystem registries. + +(defn features-registry [{:keys [alpha-enabled beta-enabled]}] + [(when alpha-enabled {::alpha :on}) + (when beta-enabled {::beta :on}) + {::gamma :on}]) + +(t/deftest conditional-entries-test + (let [registry (features-registry {:alpha-enabled true + :beta-enabled false})] + (with-open [root (di/start ::alpha registry)] + (t/is (= :on @root))))) + +;; ## A key does not need a `require` + +;; A dependency is a key — a symbol, a keyword, a string. A key +;; is data: the reader reads it without loading any code. So a +;; component can depend on a var from a namespace that its own +;; namespace does not require: + +;; ```clojure +;; (ns app.handlers ; app.db is not required +;; (:require +;; [darkleaf.di.core :as-alias di])) + +;; (defn get-user +;; {::di/kind :component} +;; [{db 'app.db/db}] +;; ...) +;; ``` + +;; DI loads `app.db` by itself: at start, a symbol key is +;; resolved with `requiring-resolve`. Most of the time you take +;; a single key from a namespace, and a `require` for one key is +;; not worth the ceremony — just write the key in full. + +;; There is also a structural gain: namespaces stop depending on +;; each other at load time. Reloading gets lighter, and two +;; namespaces whose components use each other do not form a +;; require cycle. + +;; ## An EDN file instead of dotenv + +;; Development and tests need a dozen environment variables: +;; ports, database URLs, tokens. The usual answer is dotenv +;; tooling — a `.env` file plus a library or a shell hook that +;; loads it. With DI you don't need any of that: put the +;; variables in an EDN file and read it into the registry. + +;; ```clojure +;; ;; env_dev.edn +;; {"PORT" "8081" +;; "DATABASE_URL" "jdbc:postgresql://localhost/app" +;; ;; comments work, and #_ turns an entry off: +;; #_#_"BASIC_AUTH" "admin"} +;; ``` + +;; ```clojure +;; (di/start ::root +;; [(base-registry) +;; (-> "env_dev.edn" slurp edn/read-string)]) +;; ``` + +;; This works because an environment variable is just a string +;; key, and a map is a middleware that overrides the keys it +;; lists — components can't tell the difference. The last +;; registry element is the outermost, so the file wins over both +;; the registries before it and the real environment; a key that +;; is not in the file falls through to `System/getenv` as usual +;; ([The middleware argument](/doc/reference/middleware_argument.md)). + +;; Note that the values are strings, exactly what `System/getenv` +;; would return. The file is not a second configuration system — +;; it is another source for the same contract, so `di/env-parsing` +;; and `:or` defaults keep working unchanged. diff --git a/test/darkleaf/di/how_to/visualizing_test.clj b/test/darkleaf/di/how_to/visualizing_test.clj new file mode 100644 index 00000000..7b25884d --- /dev/null +++ b/test/darkleaf/di/how_to/visualizing_test.clj @@ -0,0 +1,99 @@ +;; # Visualizing your system + +(ns darkleaf.di.how-to.visualizing-test + (:require + [clojure.string :as str] + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; `di/inspect` returns the dependency graph as plain Clojure +;; data: a vector of maps, one per key, each carrying a `:key` +;; and its `:dependencies`. Plain data is easy to turn into a +;; picture. +;; This recipe turns that data into the +;; [DOT language](https://graphviz.org/doc/info/lang.html) and +;; renders it with [Graphviz](https://graphviz.org/). + +;; `di` ships no drawing code — the function below lives in your +;; project, so you can shape the output however you like. + +;; A small system to draw: `a` depends on `b` and `c`, and `b` +;; depends on `c`. + +(defn c + {::di/kind :component} + [] + :c) + +(defn b [{c `c}] + [:b c]) + +(defn a [{b `b, c `c}] + [:a b c]) + +;; The `graphviz` function takes the output of `di/inspect` and +;; returns the graph as text in the DOT language. For every map +;; it prints one edge from its `:dependencies` to its `:key`. A +;; map with no `:dependencies` prints an empty set on the left, +;; which Graphviz reads as a standalone box. + +(defn- graphviz-id [x] + (str "\"" x "\"")) + +(defn graphviz [components] + (with-out-str + (println "digraph {") + (println "rankdir=LR;") + (println "node [shape=box color=gray style=rounded];") + (println "edge [color=gray arrowhead=empty];") + (doseq [{:keys [key dependencies]} components] + (print "{") + (doseq [token (interpose "," (map graphviz-id (keys dependencies)))] + (print token)) + (println "}" "->" (graphviz-id key))) + (println "}"))) + +;; For the system above it produces: + +(def result + (-> "digraph { +rankdir=LR; +node [shape=box color=gray style=rounded]; +edge [color=gray arrowhead=empty]; +{'darkleaf.di.how-to.visualizing-test/b','darkleaf.di.how-to.visualizing-test/c'} -> 'darkleaf.di.how-to.visualizing-test/a' +{'darkleaf.di.how-to.visualizing-test/c'} -> 'darkleaf.di.how-to.visualizing-test/b' +{} -> 'darkleaf.di.how-to.visualizing-test/c' +} +" + (str/replace "'" "\""))) + +(t/deftest graphviz-test + ;; Each component becomes one edge: its dependencies point at + ;; it. `c` has no dependencies, so its left-hand set is empty. + (t/is (= result (graphviz (di/inspect `a))))) + +;; ## Rendering + +;; `graphviz` returns DOT text. Save it from a REPL and render it +;; with `dot`, the Graphviz engine for directed graphs: + +;; ```clojure +;; (spit "system.dot" (graphviz (di/inspect `root))) +;; ``` + +;; ``` +;; dot -Tsvg system.dot -o system.svg +;; ``` + +;; ![The rendered dependency graph](/doc/images/visualizing.svg) + +;; The nodes are the fully qualified keys of your system, so the +;; picture matches the names you wrote. + +;; This graph uses only `:key` and `:dependencies`. Each map from +;; `di/inspect` carries more — a `:description` with the key's +;; kind, the var behind it, and markers the walk adds. Reach for +;; those fields to style the picture: color by kind, highlight the +;; roots you asked for, or group keys by namespace with `subgraph`. +;; The [Inspect](/doc/reference/inspect_test.md) reference lists +;; every field. diff --git a/test/darkleaf/di/tutorial/x_inspect_test.clj b/test/darkleaf/di/reference/inspect_test.clj similarity index 89% rename from test/darkleaf/di/tutorial/x_inspect_test.clj rename to test/darkleaf/di/reference/inspect_test.clj index 611778e8..69c84f73 100644 --- a/test/darkleaf/di/tutorial/x_inspect_test.clj +++ b/test/darkleaf/di/reference/inspect_test.clj @@ -1,11 +1,11 @@ ;; # Inspect -(ns darkleaf.di.tutorial.x-inspect-test +(ns darkleaf.di.reference.inspect-test (:require [clojure.test :as t] [darkleaf.di.core :as di] [darkleaf.di.protocols :as p] - [darkleaf.di.tutorial.x-ns-publics-test :as x-ns-publics-test])) + [darkleaf.di.how-to.ns-publics-test :as x-ns-publics-test])) ;; `di/inspect` takes the same arguments as `di/start` but builds ;; nothing. It walks the registry and returns a vector describing @@ -263,40 +263,48 @@ (di/update-key `a str (di/ref `b)) (di/update-key `a identity))))) -;; `di/add-side-dependency` marks the pulled-in keys with -;; `::di/side-dependency true`. +;; `di/add-side-dependency` and `di/prepend-side-dependency` mark +;; the pulled-in keys with `::di/side-dependency true`. The +;; entries follow the build order: prepended keys come before the +;; root, added keys after it. -(t/deftest add-side-dependency-test - (t/is (= [{:key `a +(t/deftest side-dependency-test + (t/is (= [{:key `prepended-dep + :description {::di/kind :trivial + :object :side-dep + ::di/side-dependency true}} + {:key `a :description {::di/kind :trivial :object :obj ::di/root true}} - {:key `side-dep-1 + {:key `added-dep-1 :description {::di/kind :trivial :object :side-dep ::di/side-dependency true}} - {:key `side-dep-2 + {:key `added-dep-2 :description {::di/kind :trivial :object :side-dep ::di/side-dependency true}}] (di/inspect `a - {`a :obj - `side-dep-1 :side-dep - `side-dep-2 :side-dep} - (di/add-side-dependency `side-dep-1) - (di/add-side-dependency `side-dep-2))))) + {`a :obj + `prepended-dep :side-dep + `added-dep-1 :side-dep + `added-dep-2 :side-dep} + (di/prepend-side-dependency `prepended-dep) + (di/add-side-dependency `added-dep-1) + (di/add-side-dependency `added-dep-2))))) ;; `di/ns-publics` and `di/env-parsing` show up as standalone ;; `:middleware` factories standing in front of the keys they expose. (t/deftest ns-publics-test - (t/is (= [{:key :ns-publics/darkleaf.di.tutorial.x-ns-publics-test + (t/is (= [{:key :ns-publics/darkleaf.di.how-to.ns-publics-test :dependencies {`x-ns-publics-test/service :required `x-ns-publics-test/component :required `x-ns-publics-test/ok-test :required} :description {::di/kind :middleware :middleware ::di/ns-publics - :ns 'darkleaf.di.tutorial.x-ns-publics-test + :ns 'darkleaf.di.how-to.ns-publics-test ::di/root true}} {:key `x-ns-publics-test/service :dependencies {`x-ns-publics-test/component :required} @@ -309,7 +317,7 @@ :description {::di/kind :trivial :object x-ns-publics-test/ok-test ::di/variable #'x-ns-publics-test/ok-test}}] - (di/inspect :ns-publics/darkleaf.di.tutorial.x-ns-publics-test + (di/inspect :ns-publics/darkleaf.di.how-to.ns-publics-test (di/ns-publics))))) @@ -328,6 +336,9 @@ {"PORT" "8080"})))) ;; `di/log` adds `::di/log` to every factory it wraps. +;; The `#_#_:opts nil` form below is commented-out code: `:opts` +;; is not part of the description today, but a future version may +;; report the logger options under that key. (t/deftest log-test (t/is (= [{:key `foo @@ -340,17 +351,6 @@ {`foo :obj} (di/log))))) - -(def variable-factory-regression - (reify p/Factory - (dependencies [_]) - (build [_ _ _] :ok) - (description [_]))) - -(t/deftest variable-factory-regression-test - (t/is (= :ok - @(di/start `variable-factory-regression)))) - ;; ## Multiple roots ;; Pass a vector or a map as the first argument to inspect several diff --git a/test/darkleaf/di/tutorial/a_intro_test.clj b/test/darkleaf/di/tutorial/a_intro_test.clj deleted file mode 100644 index 18539c55..00000000 --- a/test/darkleaf/di/tutorial/a_intro_test.clj +++ /dev/null @@ -1,117 +0,0 @@ -;; # Intro - -(ns darkleaf.di.tutorial.a-intro-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di]) - (:import - (java.time Instant))) - -;; Let's start. -;; In this chapter I'll show you how to deal with components. - -;; ## Trivial system - -;; The following test describes the most trivial system that -;; contains the most trivial component. - -(def a ::a) - -;; `root` is a system root. -;; To get root's value deref it. -;; To stop a system use `di/stop`. - -(t/deftest a-test - (let [root (di/start `a)] - (t/is (= ::a @root)) - (di/stop root))) - -;; ## AutoCloseable - -;; A root implements `AutoCloseable` -;; so in tests we should use `with-open` macro -;; for properly stopping. - -(t/deftest a'-test - (with-open [root (di/start `a)] - (t/is (= ::a @root)))) - -;; ## Component - -;; A component definition is a function of 0 or 1 arity -;; with `{::di/kind :component}` meta. - -(defn b - {::di/kind :component} - [] - (Instant/now)) - -(t/deftest b-test - (with-open [root (di/start `b)] - (t/is (inst? @root)))) - -;; ## Dependencies - -;; To define a component that depends on other components, -;; define a function of one argument. -;; DI will parse associative destructuring to get dependencies of the component. -;; We'll consider component dependencies in the next chapter. -;; But now we will use placeholder. - -(defn c - {::di/kind :component} - [-deps] - ::c) - -(t/deftest c-test - (with-open [root (di/start `c)] - (t/is (= ::c @root)))) - -;; ## Services - -;; A service is a function with or without dependencies. - -(defn d [] - ::d) - -;; `root` is a wrapper, and it implements `clojure.lang.IFn`, just like `clojure.lang.Var`. -;; So you can just call `root`. - -(t/deftest d-test - (with-open [root (di/start `d)] - (t/is (= ::d (@root) (root))))) - -(defn d* [-deps] - ::d) - -(t/deftest d*-test - (with-open [root (di/start `d*)] - (t/is (= ::d (@root) (root))))) - -(defn e [-deps arg] - [::e arg]) - -(t/deftest e-test - (with-open [root (di/start `e)] - (t/is (= [::e 42] (root 42))))) - -;; ## Interactive Development - -;; You don't need to restart the whole system if you redefine a service. -;; Just redefine a Var. -;; It's very helpful for interactive development. - -;; It does not work if you change definition of dependencies, -;; so in this case you have to restart the system. - -;; The new implementation of a service will receive the same dependencies. -;; To check that, I have to look a little ahead and define component with a dependency. -;; As I said we consider deps in the next chapter. - -(t/deftest f-test - (defn f [{x ::x} arg] - [::f x arg]) - (with-open [root (di/start `f {::x :x})] - (defn f [deps arg] - [::new-f (deps ::x) arg]) - (t/is (= [::new-f :x 42] (root 42))))) diff --git a/test/darkleaf/di/tutorial/a_your_first_system_test.clj b/test/darkleaf/di/tutorial/a_your_first_system_test.clj new file mode 100644 index 00000000..d8609ec4 --- /dev/null +++ b/test/darkleaf/di/tutorial/a_your_first_system_test.clj @@ -0,0 +1,110 @@ +;; # Your first system + +(ns darkleaf.di.tutorial.a-your-first-system-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di]) + (:import + (java.time Instant))) + +;; By the end of this chapter you can start a small system, stop +;; it, and see the difference between a component and a service. +;; The rest of the tutorial builds on these terms. + + +;; ## The smallest system + +;; A system is one or more components connected by their +;; dependencies. The smallest one has a single component — a +;; trivial value stored in a var. + +(def a ::a) + +;; `di/start` builds the system. It takes a key, looks it up, and +;; returns the system root. Deref the root with `@` to get the +;; built value. `di/stop` shuts the system down. + +(t/deftest a-test + (let [root (di/start `a)] + (t/is (= ::a @root)) + (di/stop root))) + +;; ## Stopping safely + +;; The root implements `AutoCloseable`, so in tests use `with-open` +;; instead of calling `di/stop` by hand. If a test fails midway, +;; the system is still stopped. + +(t/deftest a'-test + (with-open [root (di/start `a)] + (t/is (= ::a @root)))) + +;; ## Components + +;; A component is a function of zero or one argument with +;; `{::di/kind :component}` metadata. DI calls it once during start +;; and uses the returned value. + +(defn b + {::di/kind :component} + [] + (Instant/now)) + +(t/deftest b-test + (with-open [root (di/start `b)] + (t/is (inst? @root)))) + +;; The argument, when present, carries the component's +;; dependencies. DI reads the destructuring map to figure out what +;; to inject. Declaring real dependencies is the next chapter. For +;; now we just use a placeholder name. + +(defn c + {::di/kind :component} + [-deps] + ::c) + +(t/deftest c-test + (with-open [root (di/start `c)] + (t/is (= ::c @root)))) + +;; ## Services + +;; A service is a plain `defn` — no metadata. DI does not call it +;; during start. The function itself is the component. + +(defn d [] + ::d) + +;; The root implements `clojure.lang.IFn`, so you can call it +;; directly — `(root)` invokes the underlying function, just like +;; you would invoke a var. + +(t/deftest d-test + (with-open [root (di/start `d)] + (t/is (= ::d (@root) (root))))) + +;; A service can also take dependencies. As with a component, the +;; first argument is the dependency map (placeholder for now). + +(defn d* [-deps] + ::d) + +(t/deftest d*-test + (with-open [root (di/start `d*)] + (t/is (= ::d (@root) (root))))) + +;; Arguments after the dependency map are the service's own +;; arguments, supplied by the caller. + +(defn e [-deps arg] + [::e arg]) + +(t/deftest e-test + (with-open [root (di/start `e)] + (t/is (= [::e 42] (root 42))))) + +;; That's the vocabulary: system, root, component, and service. +;; The next chapter wires components together through real +;; dependencies. + diff --git a/test/darkleaf/di/tutorial/b_dependencies_test.clj b/test/darkleaf/di/tutorial/b_dependencies_test.clj index 63edd573..ed980ff5 100644 --- a/test/darkleaf/di/tutorial/b_dependencies_test.clj +++ b/test/darkleaf/di/tutorial/b_dependencies_test.clj @@ -3,22 +3,25 @@ (ns darkleaf.di.tutorial.b-dependencies-test (:require [clojure.test :as t] - [darkleaf.di.core :as di]) - (:import - (clojure.lang ExceptionInfo))) + [darkleaf.di.core :as di] + [darkleaf.di.utils :as u])) -;; DI uses associative destructuring syntax to define dependencies of a component. -;; https://clojure.org/guides/destructuring#_associative_destructuring +;; The previous chapter built a single component on its own. Real +;; systems are graphs: components depend on other components. This +;; chapter shows how a component declares its dependencies and how +;; DI resolves them at start. -;; There is a mapping between keys and components. -;; A key can be symbol, keyword, or string. -;; In this chapter we'll use only symbols. +;; ## Declaring dependencies -;; If we use symbols DI will try to resolve a component's var. +;; DI uses Clojure's +;; [associative destructuring](https://clojure.org/guides/destructuring#_associative_destructuring) +;; to read a component's dependencies. Keys can be symbols, +;; keywords, or strings — this chapter uses only symbols, which DI +;; resolves to vars by name. -;; In the following example the `root` component depends on -;; the `a` and `b` and the `b` is optional. -;; You also can get all component dependencies by the `deps` binding. +;; `root` below depends on `a` and `b`. `b` is optional via `:or` +;; with `::default` as fallback. The full dependency map is also +;; available through `:as deps`. (defn root {::di/kind :component} @@ -34,17 +37,34 @@ (with-open [root (di/start `root)] (t/is (= [:root ::a ::default {`a ::a}] @root)))) -;; `di/start` can accept additional arguments. -;; In the following example the argument is a map registry. -;; I use it to define local keys. -;; In general they are middlewares but I'll describe it later. +;; Two equivalent forms in the destructuring map: ``{a `a}`` binds +;; the value of key `` `a `` to the local `a`. `::syms [b]` is a +;; shorthand for several symbols at once. Use whichever reads +;; better. + +;; ## Substituting a dependency + +;; `di/start` accepts a second argument — a map that supplies or +;; overrides values by key: (t/deftest root-with-extra-deps-test (with-open [root (di/start `root {`b ::b})] (t/is (= [:root ::a ::b {`a ::a `b ::b}] @root)))) -;; Dependencies are required by default. -;; There is no definition of `a'` so DI will throw an exception. +;; This map is called a *registry*. Use it to override what DI +;; would otherwise resolve from a var — a fake datasource in +;; tests, a different implementation in dev, and so on. +;; Registries are covered in detail in +;; [Registries](/doc/tutorial/e_registries_test.md). + +;; ## Required by default + +;; Dependencies are required unless `:or` declares a default. A +;; missing required dependency makes `di/start` throw. The +;; exception carries enough information to locate the missing +;; dependency: the failure `:type` and a `:stack` of keys DI was +;; resolving — from the missing key (head) up through its parents +;; to the root. (defn root' {::di/kind :component} @@ -52,6 +72,12 @@ [::root a]) (t/deftest root'-test - (t/is (thrown-with-msg? ExceptionInfo - #"Missing dependency darkleaf.di.tutorial.b-dependencies-test/a'" - (di/start `root')))) + (let [ex (u/catch-some (di/start `root'))] + (t/is (= "Missing dependency darkleaf.di.tutorial.b-dependencies-test/a'" + (ex-message ex))) + (t/is (= {:type ::di/missing-dependency + :stack [`a' `root']} + (ex-data ex))))) + +;; The next chapter covers how a component cleans up when the +;; system stops. diff --git a/test/darkleaf/di/tutorial/c_stop_test.clj b/test/darkleaf/di/tutorial/c_stop_test.clj deleted file mode 100644 index 1c812ed6..00000000 --- a/test/darkleaf/di/tutorial/c_stop_test.clj +++ /dev/null @@ -1,30 +0,0 @@ -;; # Stop - -(ns darkleaf.di.tutorial.c-stop-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; To stop a component, you should teach DI how to do it. -;; Use `::di/stop` to define a stop function. - -(defn root - {::di/stop #(reset! % true)} - [{::keys [*stopped?]}] - *stopped?) - -(t/deftest stop-test - (let [*stopped? (atom false)] - (with-open [root (di/start `root {::*stopped? *stopped?})] - (t/is (= false @@root))) - (t/is @*stopped?))) - -;; In most cases, a component will be a Java class. -;; To prevent reflection calls use `memfn` -;; ```clojure -;; (defn- connection-manager -;; {::di/stop (memfn ^AutoCloseable close)} -;; [{max-conn :env.long/CONNECTION_MANAGER_MAX_CONN -;; :or {max-conn 50}}] -;; (ConnectionManager. max-conn)) -;; ``` diff --git a/test/darkleaf/di/tutorial/c_stopping_components_test.clj b/test/darkleaf/di/tutorial/c_stopping_components_test.clj new file mode 100644 index 00000000..baf22dad --- /dev/null +++ b/test/darkleaf/di/tutorial/c_stopping_components_test.clj @@ -0,0 +1,57 @@ +;; # Stopping components + +(ns darkleaf.di.tutorial.c-stopping-components-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; By default, a component is built once at start and discarded on +;; stop — DI does nothing else with it. When a component owns a +;; resource (a connection, a thread pool, a file handle), attach +;; a stop function via metadata. DI calls it with the built value +;; when the system shuts down. + +;; ## Declaring a stop function + +;; `::di/stop` is just another function attached as metadata. It +;; receives the built value and its return value is ignored. Below, +;; the component returns an atom, and the stop function flips it to +;; `true` — the test asserts the flip happened. + +;; Note that `root` below has no `{::di/kind :component}` metadata. +;; A function with `::di/stop` metadata is a component by +;; convention — only components have a lifecycle, so there is no +;; need to state the kind explicitly. + +(defn root + {::di/stop #(reset! % true)} + [{::keys [*stopped?]}] + *stopped?) + +(t/deftest stop-test + (let [*stopped? (atom false)] + (with-open [root (di/start `root {::*stopped? *stopped?})] + (t/is (= false @@root))) + (t/is @*stopped?))) + +;; ## Stopping Java objects + +;; In real systems, a stateful component is often a Java object — +;; a connection pool, a server, a queue — with a `close` or +;; `shutdown` method. Use a qualified method value (Clojure 1.12+) +;; or `memfn` to call it without reflection: + +;; ```clojure +;; (defn connection-pool +;; {::di/stop ConnectionPool/.close} +;; [{max-conn :env.long/MAX_CONN}] +;; (ConnectionPool. max-conn)) +;; +;; (defn connection-pool +;; {::di/stop (memfn ^ConnectionPool close)} +;; [{max-conn :env.long/MAX_CONN}] +;; (ConnectionPool. max-conn)) +;; ``` + +;; The next chapter covers the REPL workflow — redefining functions +;; on a running system without restarting. diff --git a/test/darkleaf/di/tutorial/d_interactive_development_test.clj b/test/darkleaf/di/tutorial/d_interactive_development_test.clj new file mode 100644 index 00000000..469fd762 --- /dev/null +++ b/test/darkleaf/di/tutorial/d_interactive_development_test.clj @@ -0,0 +1,50 @@ +;; # Interactive development + +(ns darkleaf.di.tutorial.d-interactive-development-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; In Clojure you grow a program by evaluating new code into a +;; running process. State — db connections, server threads, caches +;; — survives the eval, but the functions that capture that state +;; can grow stale. Stuart Sierra's *Reloaded Workflow* (2013) answers +;; this by stopping the system, reloading namespaces, and starting +;; a new one. DI offers a smaller-scope answer for the common case: +;; you don't have to restart anything to update a service. + +;; ## Redefining a service + +;; A service is bound as `(partial #'the-var deps)`. There is one level +;; of var indirection between the running system and the function +;; body. When you redefine the var with `defn`, the next call goes +;; through the new function immediately — no restart needed. + +(t/deftest redefine-service-test + (defn f [{x ::x} arg] + [::f x arg]) + (with-open [root (di/start `f {::x :x})] + (t/is (= [::f :x 42] (root 42))) + ;; redefine f while the system is running + (defn f [deps arg] + [::new-f (deps ::x) arg]) + ;; the running root picks up the new implementation + (t/is (= [::new-f :x 42] (root 42))))) + +;; Notice the new implementation received the same dependency map +;; (`{::x :x}`) the system was started with. DI does not recompute +;; the wiring on redef. + +;; ## When a restart is needed + +;; The var indirection only covers the function body. The +;; dependency graph itself was decided at start. If you change +;; the declaration of the first argument — the dependency map — +;; the running system keeps the old wiring. Stop and start again +;; to apply the change. + +;; The same applies to components — their built value is what the +;; system holds. Redefining the var does not rebuild the component. + +;; The next chapter zooms in on the registry: how to override +;; dependencies, stack overrides, and what last-wins means. diff --git a/test/darkleaf/di/tutorial/e_registries_test.clj b/test/darkleaf/di/tutorial/e_registries_test.clj new file mode 100644 index 00000000..3c0a7737 --- /dev/null +++ b/test/darkleaf/di/tutorial/e_registries_test.clj @@ -0,0 +1,75 @@ +;; # Registries + +(ns darkleaf.di.tutorial.e-registries-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; You already saw the registry — the map passed to `di/start`. +;; This chapter covers it in full. A registry tells DI what to +;; use for a given key — overriding what DI would otherwise +;; resolve from a var, or supplying a value for a key that has +;; no var at all. + +;; The component below declares two dependencies that DI cannot +;; resolve on its own — `dep-a` and `dep-b` have no vars. The +;; registry fills them in. + +(defn value + {::di/kind :component} + [{dep-a `dep-a + dep-b `dep-b}] + [:value dep-a dep-b]) + +;; ## A map of values by key + +;; The simplest registry is a map. Each entry maps a key to the +;; value DI should use. Any key can be overridden — including the +;; root key itself. + +(t/deftest map-registry-test + ;; supply two undefined deps + (with-open [root (di/start `value {`dep-a :a `dep-b :b})] + (t/is (= [:value :a :b] @root))) + ;; replace the root with a literal value + (with-open [root (di/start `value {`value :replacement})] + (t/is (= :replacement @root)))) + +;; ## Stacking registries — last wins + +;; `di/start` takes any number of registries after the key. They +;; stack: a key is resolved in the right-most registry that +;; defines it. + +(t/deftest stacked-registries-test + ;; two registries together + (with-open [root (di/start `value {`dep-a :a} {`dep-b :b})] + (t/is (= [:value :a :b] @root))) + ;; later wins + (with-open [root (di/start `value + {`dep-a :a `dep-b :b} + {`dep-a :a' `dep-b :b'})] + (t/is (= [:value :a' :b'] @root)))) + +;; ## Grouping registries with a sequence + +;; To avoid splicing with `apply`, a sequential collection (see +;; `clojure.core/sequential?`) — a vector or a list — counts as a +;; single registry. DI walks the sequence as if you had passed +;; each entry separately. + +(t/deftest sequential-registry-test + (with-open [root (di/start `value [{`dep-a :a} + [{`dep-b :b}]])] + (t/is (= [:value :a :b] @root)))) + +;; A map is the simplest form of this argument. The API calls the +;; whole variadic argument `middlewares`, and it accepts more than +;; maps — most importantly a function that wraps the registry, which +;; is how built-ins like `di/env-parsing` and `di/update-key` work. +;; Later chapters call these arguments middleware. See +;; [The middleware argument](/doc/reference/middleware_argument.md) +;; for the full list. + +;; The next chapter introduces keyword keys — a way to decouple +;; a component from any specific var. diff --git a/test/darkleaf/di/tutorial/f_abstractions_test.clj b/test/darkleaf/di/tutorial/f_abstractions_test.clj new file mode 100644 index 00000000..ec91e79c --- /dev/null +++ b/test/darkleaf/di/tutorial/f_abstractions_test.clj @@ -0,0 +1,103 @@ +;; # Abstractions + +(ns darkleaf.di.tutorial.f-abstractions-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di] + [darkleaf.di.utils :as u])) + +;; Symbol keys resolve to vars — when DI sees `` `foo `` in a +;; destructuring map, it looks up the var `foo` in the current +;; namespace. That's the default and it covers most code: you are +;; wiring functions together by their names. + +;; Sometimes the dependency does not belong to any one namespace — +;; a database connection, a session source, a config map. There +;; is no var to point at. The value comes from outside the code. +;; For these, use a keyword key. DI does not try to resolve it. +;; The registry must supply it. + +;; ## A worked example + +;; In the example below, `::datasource` and `::session` are +;; abstractions — they have no vars. The `ring-handler` does not +;; care where they come from. At start, the registry binds them. + +(defn get-user [{ds ::datasource} id] + (ds id)) + +(defn get-current-user [{session ::session + get-user `get-user}] + (-> session :user-id get-user)) + +(defn ring-handler [{get-current-user `get-current-user} -req] + {:status 200 :body (str "Hi, " (get-current-user) "!")}) + +(t/deftest handler-test + (with-open [root (di/start `ring-handler + {::datasource {1 "John"} + ::session {:user-id 1}})] + (t/is (= {:status 200 :body "Hi, John!"} + (root {}))))) + +;; Notice the mix: `get-user` and `get-current-user` are symbol +;; deps (they have real vars). `::datasource` and `::session` are +;; keyword deps (no vars, supplied at start). Both kinds sit side +;; by side in the same destructuring map. + +;; ## When to pick which + +;; **Symbol** points at a specific var. You are naming the +;; implementation directly. This is the default for code you +;; write — no extra wiring needed. DI resolves the var. + +;; **Keyword** does not point at anything. It names an abstract +;; role. The registry decides which implementation fills it. Use +;; a keyword when you have explicitly decided to abstract a +;; dependency — most commonly in a reusable library or an +;; internal module that declares what it needs without naming +;; the implementation, or when the role itself has no canonical +;; default that should live in code. + +;; Both kinds can be overridden by a registry — swap-ability is +;; not the deciding factor. The choice is whether the dependency +;; has a default implementation tied to a var, or is abstract by +;; design. + +;; ## Validating the abstraction + +;; Because a keyword has no var, the registry can supply anything +;; for it. If you want a contract check — must be callable, must +;; satisfy a protocol, must match a spec — declare a same-named +;; component that validates the registry value and returns it. +;; Downstream code depends on the wrapper symbol, not the raw +;; keyword. + +(defn user-repo + {::di/kind :component} + [{repo ::user-repo}] + (when-not (ifn? repo) + (throw (ex-info "::user-repo must be callable" {:value repo}))) + repo) + +;; Good value goes through: + +(t/deftest checked-user-repo-test + (with-open [root (di/start `user-repo + {::user-repo #(str "user-" %)})] + (t/is (= "user-7" (@root 7))))) + +;; A bad value fails the system at start, before any code touches +;; it. The original error is wrapped as `::di/build-failure`; the +;; original is available via `ex-cause`. + +(t/deftest checked-user-repo-failure-test + (let [ex (u/catch-some (di/start `user-repo {::user-repo 42}))] + (t/is (= ::di/build-failure (-> ex ex-data :type))) + (t/is (= "::user-repo must be callable" + (-> ex ex-cause ex-message))) + (t/is (= {:value 42} + (-> ex ex-cause ex-data))))) + +;; The next chapter introduces the third kind of key: strings, +;; for pulling in environment variables. diff --git a/test/darkleaf/di/tutorial/g_environment_variables_test.clj b/test/darkleaf/di/tutorial/g_environment_variables_test.clj new file mode 100644 index 00000000..564efefe --- /dev/null +++ b/test/darkleaf/di/tutorial/g_environment_variables_test.clj @@ -0,0 +1,107 @@ +;; # Environment variables + +(ns darkleaf.di.tutorial.g-environment-variables-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; Symbols resolve to vars, keywords to abstractions in the +;; registry. The third kind of key — a string — resolves to an +;; environment variable. This is how you plug a system into the +;; environment it runs in: container env, .env file, shell, CI +;; secrets. + +;; ## String keys → env vars + +;; Declare a string in the destructuring map. DI looks the value +;; up from `System/getenv`. + +(defn root + {::di/kind :component} + [{path "PATH"}] + [:root path]) + +(def PATH (System/getenv "PATH")) + +(t/deftest root-test + (with-open [root (di/start `root)] + (t/is (= [:root PATH] @root)))) + +;; The value is always a string — the way the OS sees it. To get +;; numbers, booleans, JSON, edn, parse them on the way in. + +;; ## Typed values via `di/env-parsing` + +;; Pass `(di/env-parsing ...)` to `di/start` and you can depend +;; on a qualified keyword like `:env.long/PORT`. DI looks up the +;; env var by name (`PORT`) and passes its string through the +;; parser registered for the keyword namespace (`:env.long` → +;; `parse-long`). + +(defn jetty + {::di/kind :component} + [{port :env.long/PORT + :or {port 8080}}] + [:jetty port]) + +(t/deftest jetty-test + ;; PORT is not set — :or default kicks in + (with-open [jetty (di/start `jetty + (di/env-parsing {:env.long parse-long}))] + (t/is (= [:jetty 8080] @jetty))) + ;; PORT supplied as a string in a registry — parsed to a number + (with-open [jetty (di/start `jetty + (di/env-parsing :env.long parse-long) + {"PORT" "8081"})] + (t/is (= [:jetty 8081] @jetty)))) + +;; Both the kwargs form (`:env.long parse-long`) and the map form +;; (`{:env.long parse-long}`) work. Register as many keyword +;; namespaces as you need: `:env.long`, `:env.bool`, `:env.json`, +;; anything. + +;; (`di/env-parsing` is a middleware — see +;; [The middleware argument](/doc/reference/middleware_argument.md) +;; for the wider picture.) + +;; ## Required vs optional + +;; An env-typed dependency without `:or` is required. If the +;; underlying env var is missing, `di/start` throws. + +(defn required-env + {::di/kind :component} + [{enabled :env.bool/ENABLED}] + [:enabled enabled]) + +(defn optional-env + {::di/kind :component} + [{enabled :env.bool/ENABLED + :or {enabled true}}] + [:enabled enabled]) + +(t/deftest env-test + ;; required, ENABLED is not set → fails at start + (t/is (thrown? clojure.lang.ExceptionInfo + (di/start `required-env + (di/env-parsing {:env.bool #(= "true" %)})))) + + ;; required, ENABLED supplied via registry + (with-open [sys (di/start `required-env + (di/env-parsing {:env.bool #(= "true" %)}) + {"ENABLED" "false"})] + (t/is (= [:enabled false] @sys))) + + ;; optional, ENABLED not set → :or default + (with-open [sys (di/start `optional-env + (di/env-parsing {:env.bool #(= "true" %)}))] + (t/is (= [:enabled true] @sys))) + + ;; optional, ENABLED supplied → overrides the default + (with-open [sys (di/start `optional-env + (di/env-parsing {:env.bool #(= "true" %)}) + {"ENABLED" "false"})] + (t/is (= [:enabled false] @sys)))) + +;; The next chapter shows how one `di/start` call can start +;; several independent keys at once. diff --git a/test/darkleaf/di/tutorial/h_starting_many_keys_test.clj b/test/darkleaf/di/tutorial/h_starting_many_keys_test.clj new file mode 100644 index 00000000..36d1de1d --- /dev/null +++ b/test/darkleaf/di/tutorial/h_starting_many_keys_test.clj @@ -0,0 +1,43 @@ +;; # Starting many keys + +(ns darkleaf.di.tutorial.h-starting-many-keys-test + (:require + [darkleaf.di.core :as di] + [clojure.test :as t])) + +;; A single `di/start` can start several components at once — +;; a webserver and a worker queue and a scheduler in production, +;; or a few independent components a test wants to poke at. +;; Rather than writing an explicit root component that pulls all +;; of them in, hand `di/start` a vector or a map of keys +;; directly. The returned root supports the matching kind of +;; destructuring. + +(def a :a) +(def b :b) + +;; ## Vector — Indexed root + +;; A vector of keys produces a root that implements +;; `clojure.lang.Indexed`. Sequence destructuring works directly +;; — use `di/with-open` (a drop-in replacement for +;; `clojure.core/with-open` that supports destructuring): + +(t/deftest indexed-test + (di/with-open [[a b] (di/start [`a `b])] + (t/is (= :a a)) + (t/is (= :b b)))) + +;; ## Map — ILookup root + +;; A map of label → key produces a root that implements +;; `clojure.lang.ILookup`. Associative destructuring works: + +(t/deftest lookup-test + (di/with-open [{:keys [a b]} (di/start {:a `a :b `b})] + (t/is (= :a a)) + (t/is (= :b b)))) + +;; The next chapter shows how to wire components into plain data +;; — reitit routes, scheduler tables — with `di/template` and +;; `di/ref`. diff --git a/test/darkleaf/di/tutorial/i_wiring_inside_data_test.clj b/test/darkleaf/di/tutorial/i_wiring_inside_data_test.clj new file mode 100644 index 00000000..be0a7c81 --- /dev/null +++ b/test/darkleaf/di/tutorial/i_wiring_inside_data_test.clj @@ -0,0 +1,65 @@ +;; # Wiring inside data + +(ns darkleaf.di.tutorial.i-wiring-inside-data-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; Many Clojure libraries are configured via data — reitit routes, +;; scheduler tables, connection-pool maps. DI lets you embed +;; `(di/ref ...)` directly inside that data. `di/template` walks +;; the structure on start and replaces each ref with the value it +;; points at. + +;; ## Embedding refs in data + +;; Below, `routes` is reitit-style data with handler references at +;; each leaf. The handlers are real services with their own +;; dependencies. At start, DI resolves every `di/ref` — the +;; handlers in the produced data already have `::datasource` +;; baked in. + +(defn list-users [{ds ::datasource} -req] + (->> ds vals sort vec)) + +(defn show-user [{ds ::datasource} req] + (ds (-> req :path-params :id))) + +(defn create-user [{ds ::datasource} -req] + {:status :created, :existing-count (count ds)}) + +(def routes + (di/template + [["/users" {:get (di/ref `list-users) + :post (di/ref `create-user)}] + ["/users/:id" {:get (di/ref `show-user)}]])) + +(t/deftest routes-test + (di/with-open [[routes list-users create-user show-user] + (di/start [`routes `list-users `create-user `show-user] + {::datasource {"1" "Alice", "2" "Bob"}})] + (t/is (= [["/users" {:get list-users + :post create-user}] + ["/users/:id" {:get show-user}]] + routes)))) + +;; `di/template` walks any nested structure — vectors, maps, sets +;; — and resolves every `di/ref` it finds. Everything else passes +;; through unchanged. + +;; ## Optional refs + +;; `di/opt-ref` resolves to `nil` when the key has no value. +;; Useful when a data DSL has slots that may or may not be filled. + +(def maybe-handler + (di/template + {:get (di/opt-ref `nonexistent-handler)})) + +(t/deftest opt-ref-test + (with-open [root (di/start `maybe-handler)] + (t/is (= {:get nil} @root)))) + +;; The next chapter covers `di/derive` — transforming a built +;; value with a function, useful for parsing, normalizing, or +;; wrapping the result of another dependency. diff --git a/test/darkleaf/di/tutorial/j_transforming_values_test.clj b/test/darkleaf/di/tutorial/j_transforming_values_test.clj new file mode 100644 index 00000000..6756f4ba --- /dev/null +++ b/test/darkleaf/di/tutorial/j_transforming_values_test.clj @@ -0,0 +1,68 @@ +;; # Transforming values + +(ns darkleaf.di.tutorial.j-transforming-values-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; Sometimes the value DI builds for a key is not quite what +;; downstream code expects: an env var arrives as a string but +;; you want a number, or a templated list contains nils that you +;; want to filter out. `di/derive` builds a value from another key and +;; runs a function over the result. Shape: + +;; ```clojure +;; (di/derive source-key f arg1 arg2 ...) +;; ;; ≡ (f source-value arg1 arg2 ...) +;; ``` + +;; ## A typed env var + +;; The simplest case: parse an env var. + +(def port (di/derive "PORT" parse-long)) + +(t/deftest port-test + (with-open [root (di/start `port {"PORT" "8080"})] + (t/is (= 8080 @root)))) + +;; The source key of `di/derive` is optional: if `"PORT"` were not +;; defined, the function would receive `nil`. When the source may +;; be missing, make the function nil-safe — for example +;; `(fnil parse-long "8080")` to fall back to a default. + +;; Same effect as defining a one-line component: + +(defn port' + {::di/kind :component} + [{port "PORT"}] + (parse-long port)) + +(t/deftest port'-test + (with-open [root (di/start `port' {"PORT" "8080"})] + (t/is (= 8080 @root)))) + +;; Use whichever reads better. For env vars specifically, +;; [`di/env-parsing`](/doc/tutorial/g_environment_variables_test.md) is usually +;; nicer — it registers a parser once for a whole keyword +;; namespace (`:env.long`, `:env.json`). `di/derive` is the +;; general tool, useful when the transformation does not fit the +;; env-parsing pattern. + +;; ## Post-processing a template + +;; `di/derive` shines on top of a `di/template`. Here we build a +;; list of optional refs and filter out the nils: + +(def -box (di/template [(di/opt-ref ::a) + (di/opt-ref ::b) + (di/opt-ref ::c)])) +(def box (di/derive `-box (partial filter some?))) + +(t/deftest box-test + (with-open [root (di/start `box {::b :b})] + (t/is (= [:b] @root)))) + +;; The next chapter is what the whole tutorial has been pointing +;; at: composition through `di/update-key`. With it, modules +;; assemble into a system without naming each other. diff --git a/test/darkleaf/di/tutorial/k_composition_with_update_key_test.clj b/test/darkleaf/di/tutorial/k_composition_with_update_key_test.clj new file mode 100644 index 00000000..8a44d85e --- /dev/null +++ b/test/darkleaf/di/tutorial/k_composition_with_update_key_test.clj @@ -0,0 +1,93 @@ +;; # Composition with `update-key` + +(ns darkleaf.di.tutorial.k-composition-with-update-key-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +;; `di/update-key` rewires an existing key: the original value is +;; built first, your function transforms it, and the result is +;; what every dependent sees. Multiple `update-key` calls on the +;; same target apply in registration order — each transforms the +;; result of the previous. + +;; This is the main tool for cross-namespace composition: the +;; namespace that owns a key does not need to know about the +;; modules that decorate or extend it. Two patterns below cover +;; most uses — wrapping a value with extra behaviour, and +;; extending a shared collection. + +;; ## Decorate the built value + +;; `(di/update-key target f & args)` applies `f` to the built +;; value of `target`, threading it as the first argument. The +;; classic case is the decorator pattern: wrap the original in +;; something with the same shape that delegates to it, adding +;; behaviour. In Clojure this is usually a higher-order `wrap-X` +;; — takes the thing, returns a wrapped thing of the same kind. + +(defn handler [-deps req] + {:status 200 :body (:uri req)}) + +(defn wrap-log [handler *log] + (fn [req] + (swap! *log conj (:uri req)) + (handler req))) + +(t/deftest decorate-test + (let [*log (atom [])] + (with-open [root (di/start `handler + (di/update-key `handler wrap-log *log))] + (t/is (= {:status 200 :body "/a"} (root {:uri "/a"}))) + (t/is (= {:status 200 :body "/b"} (root {:uri "/b"}))) + (t/is (= ["/a" "/b"] @*log))))) + +;; ## Extend a collection + +;; Any argument after `f` is itself a factory and gets built. This +;; lets each module attach itself to a shared registry: it owns +;; its handler and the route entry that wires the handler in, +;; then hooks the entry onto `routes` with `di/ref`. The namespace +;; that defines `routes` never references any of the modules. + +(defn user-handler [-deps -req] + :user) + +(def user-route (di/template ["/users" (di/ref `user-handler)])) + + +(defn order-handler [-deps -req] + :order) + +(def order-route (di/template ["/orders" (di/ref `order-handler)])) + + +(def routes []) + +(t/deftest extend-test + (with-open [root (di/start `routes + (di/update-key `routes conj (di/ref `user-route)) + (di/update-key `routes conj (di/ref `order-route)))] + (t/is (= [["/users" :user] + ["/orders" :order]] + (for [[path handler] @root] + [path (handler :req)]))))) + +;; Everything `update-key` takes is a factory — the function +;; included. Plain values like `wrap-log`, `conj`, or `*log` above +;; are constants and pass through as is, but any position accepts +;; any factory — `di/ref` or `di/template`, for example. With +;; ``(di/update-key `handler (di/ref `wrap-metrics))`` the +;; decorator itself is built by the system and receives +;; dependencies of its own — say, a stateful metrics store. +;; And an argument can be assembled too: pass plain `wrap-cache` +;; with ``(di/template {:store (di/ref `redis)})``, and the +;; wrapper receives an options map with the started store inside. + +;; (Under the hood `di/update-key` is a middleware — see +;; [The middleware argument](/doc/reference/middleware_argument.md) +;; for what that means. For everyday use you just need to know +;; what update-key does.) + +;; The next chapter shows what DI does when a component throws +;; halfway through start. diff --git a/test/darkleaf/di/tutorial/l_handling_start_failures_test.clj b/test/darkleaf/di/tutorial/l_handling_start_failures_test.clj new file mode 100644 index 00000000..1aff1d21 --- /dev/null +++ b/test/darkleaf/di/tutorial/l_handling_start_failures_test.clj @@ -0,0 +1,80 @@ +;; # Handling start failures + +(ns darkleaf.di.tutorial.l-handling-start-failures-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di] + [darkleaf.di.utils :refer [catch-some]])) + +;; A real system can fail halfway through start: the database +;; came up, the queue worker came up, then the third component +;; threw. If nothing stops what was already built, you leak +;; resources — and some are non-shareable. A Jetty server holds +;; a port. If you cannot stop it, you cannot start another one +;; on the same port. The reference to the root is usually lost too, +;; so the only escape is to restart the REPL. + +;; DI handles this for you. When a build fails, it stops what +;; was already built, and only then propagates the error. + +;; ## Built components are stopped before the error escapes + +;; `dep` builds successfully and returns the `stopped` atom. Its +;; `::di/stop` flips the atom to `true`. `root` depends on `dep` +;; and then throws. + +(defn dep + {::di/stop (fn [stopped] (reset! stopped true))} + [{stopped ::stopped}] + stopped) + +(defn root + {::di/kind :component} + [{_ `dep}] + (throw (ex-info "build failed" {}))) + +;; `catch-some` captures whatever `di/start` throws. The original +;; failure is wrapped as `::di/build-failure`. `ex-cause` gives +;; you the original. `:stack` in `ex-data` shows the chain of +;; keys DI was building when the failure happened. + +(t/deftest built-deps-are-stopped-test + (let [*stopped (atom false) + ex (catch-some (di/start `root {::stopped *stopped}))] + (t/is (= ::di/build-failure (-> ex ex-data :type))) + (t/is (= [`root] (-> ex ex-data :stack))) + (t/is (= "build failed" (-> ex ex-cause ex-message))) + ;; dep was stopped before the error propagated + (t/is @*stopped))) + +;; ## When a stop itself fails + +;; When a build fails, DI stops the components it already built. +;; These stop functions can also throw. So there can be several +;; errors at once: the original one and the stop errors. DI packs +;; them all into the one exception it throws: +;; +;; - the thrown exception is the build failure; +;; - its cause is the original error, see `ex-cause`; +;; - the stop errors are attached to it as *suppressed* +;; exceptions, see `Throwable/.getSuppressed`. + +(defn dep-stop-throws + {::di/stop (fn [_] (throw (ex-info "stop failed" {})))} + [] + :built) + +(defn root-after-bad-stop + {::di/kind :component} + [{_ `dep-stop-throws}] + (throw (ex-info "build failed" {}))) + +(t/deftest stop-errors-are-suppressed-test + (let [ex (catch-some (di/start `root-after-bad-stop))] + (t/is (= ::di/build-failure (-> ex ex-data :type))) + (t/is (= "build failed" (-> ex ex-cause ex-message))) + (t/is (= ["stop failed"] (->> ex .getSuppressed (map ex-message)))))) + +;; That is the end of the tutorial. The +;; [Example app](/doc/example.md) shows the pieces working +;; together in a real project. diff --git a/test/darkleaf/di/tutorial/l_registries_test.clj b/test/darkleaf/di/tutorial/l_registries_test.clj deleted file mode 100644 index 680e548b..00000000 --- a/test/darkleaf/di/tutorial/l_registries_test.clj +++ /dev/null @@ -1,41 +0,0 @@ -;; # Registries - -(ns darkleaf.di.tutorial.l-registries-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; Here we use undefined dependencies. - -(defn value - {::di/kind :component} - [{dep-a `dep-a - dep-b `dep-b}] - [:value dep-a dep-b]) - -;; To locally define or redefine a dependency we should use registries. - -(t/deftest map-registry - (with-open [root (di/start `value {`dep-a :a `dep-b :b})] - (t/is (= [:value :a :b] @root))) - - (with-open [root (di/start `value {`value :replacement})] - (t/is (= :replacement @root))) - - (with-open [root (di/start `value {`dep-a :a} {`dep-b :b})] - (t/is (= [:value :a :b] @root))) - - ;; last wins - (with-open [root (di/start `value - {`dep-a :a `dep-b :b} - {`dep-a :a' `dep-b :b'})] - (t/is (= [:value :a' :b'] @root)))) - - -;; To avoid using `(apply di/start ...)`, -;; we can use a seqable value as a single registry. -;; See `clojure.core/seqable?`. -(t/deftest seqable-registry - (with-open [root (di/start `value [{`dep-a :a} - [{`dep-b :b}]])] - (t/is (= [:value :a :b] @root)))) diff --git a/test/darkleaf/di/tutorial/m_abstractions_test.clj b/test/darkleaf/di/tutorial/m_abstractions_test.clj deleted file mode 100644 index 3350871d..00000000 --- a/test/darkleaf/di/tutorial/m_abstractions_test.clj +++ /dev/null @@ -1,27 +0,0 @@ -;; # Abstractions - -(ns darkleaf.di.tutorial.m-abstractions-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; For some reasons, we may want to not depend on specific vars. -;; In this case, use keywords instead of symbols to define dependencies. -;; Later in the main function you will be able to bind all parts of your application. - -(defn get-user [{ds ::datasource} id] - (ds id)) - -(defn get-current-user [{session ::session - get-user `get-user}] - (-> session :user-id get-user)) - -(defn ring-handler [{get-current-user `get-current-user} -req] - {:status 200 :body (str "Hi, " (get-current-user) "!")}) - -(t/deftest handler-test - (with-open [root (di/start `ring-handler - {::datasource {1 "John"} - ::session {:user-id 1}})] - (t/is (= {:status 200 :body "Hi, John!"} - (root {}))))) diff --git a/test/darkleaf/di/tutorial/n_env_test.clj b/test/darkleaf/di/tutorial/n_env_test.clj deleted file mode 100644 index ad08b2af..00000000 --- a/test/darkleaf/di/tutorial/n_env_test.clj +++ /dev/null @@ -1,72 +0,0 @@ -;; # Env - -(ns darkleaf.di.tutorial.n-env-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; Like symbols and keywords, you can also use strings for keys. -;; String keys are resolved into values of environment variables. - -(defn root - {::di/kind :component} - [{path "PATH"}] - [:root path]) - -(def PATH (System/getenv "PATH")) - -(t/deftest root-test - (with-open [root (di/start `root)] - (t/is (= [:root PATH] @root)))) - -;; As of 2.3.0, there is `di/env-parsing` registry middleware -;; to parse values of environment variables. -;; You can define a dependency of env as a string key like \"PORT\", -;; and its value will be a string. -;; With this middleware, you can define it as a qualified keyword like :env.long/PORT, -;; and its value will be a number. - -(defn jetty - {::di/kind :component} - [{port :env.long/PORT - :or {port 8080}}] - [:jetty port]) - -(t/deftest jetty-test - (with-open [jetty (di/start `jetty - (di/env-parsing {:env.long parse-long}))] - (t/is (= [:jetty 8080] @jetty))) - (with-open [jetty (di/start `jetty - (di/env-parsing :env.long parse-long) - {"PORT" "8081"})] - (t/is (= [:jetty 8081] @jetty)))) - -(defn required-env - {::di/kind :component} - [{enabled :env.bool/ENABLED}] - [:enabled enabled]) - -(defn optional-env - {::di/kind :component} - [{enabled :env.bool/ENABLED - :or {enabled true}}] - [:enabled enabled]) - -(t/deftest env-test - (t/is (thrown? clojure.lang.ExceptionInfo - (di/start `required-env - (di/env-parsing {:env.bool #(= "true" %)})))) - - (with-open [sys (di/start `required-env - (di/env-parsing {:env.bool #(= "true" %)}) - {"ENABLED" "false"})] - (t/is (= [:enabled false] @sys))) - - (with-open [sys (di/start `optional-env - (di/env-parsing {:env.bool #(= "true" %)}))] - (t/is (= [:enabled true] @sys))) - - (with-open [sys (di/start `optional-env - (di/env-parsing {:env.bool #(= "true" %)}) - {"ENABLED" "false"})] - (t/is (= [:enabled false] @sys)))) diff --git a/test/darkleaf/di/tutorial/o_data_dsl_test.clj b/test/darkleaf/di/tutorial/o_data_dsl_test.clj deleted file mode 100644 index 750b2178..00000000 --- a/test/darkleaf/di/tutorial/o_data_dsl_test.clj +++ /dev/null @@ -1,27 +0,0 @@ -;; # Data DSL - -(ns darkleaf.di.tutorial.o-data-dsl-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; It is common to use data-DSLs in Clojure, such as reitit routing, -;; and DI offers tools to handle them easily. -;; Here they are: `di/template` and `di/ref`. - -;; You can also use `di/opt-ref` for optional dependencies. -;; If there is no defined key opt-ref resolves to nil. - -(def route-data - (di/template - [["/" {:get {:handler (di/ref `root-handler)}}] - ["/news" {:get {:handler (di/ref `news-handler)}}]])) - -(t/deftest template-test - (letfn [(root-handler [req]) - (news-handler [req])] - (with-open [root (di/start `route-data {`root-handler root-handler - `news-handler news-handler})] - (t/is (= [["/" {:get {:handler root-handler}}] - ["/news" {:get {:handler news-handler}}]] - @root))))) diff --git a/test/darkleaf/di/tutorial/p_derive_test.clj b/test/darkleaf/di/tutorial/p_derive_test.clj deleted file mode 100644 index 37bd7574..00000000 --- a/test/darkleaf/di/tutorial/p_derive_test.clj +++ /dev/null @@ -1,39 +0,0 @@ -;; # Derive - -(ns darkleaf.di.tutorial.p-derive-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; In some cases, your components may have a complex structure or require transformation. -;; You can use `di/derive` to transform a component. - -;; The first way - -(defn port - {::di/kind :component} - [{port "PORT"}] - (parse-long port)) - -(t/deftest port-test - (with-open [root (di/start `port {"PORT" "8080"})] - (t/is (= 8080 @root)))) - - -;; The second way - -(def port' (di/derive "PORT" parse-long)) - -(t/deftest port'-test - (with-open [root (di/start `port' {"PORT" "8080"})] - (t/is (= 8080 @root)))) - - -(def -box (di/template [(di/opt-ref ::a) - (di/opt-ref ::b) - (di/opt-ref ::c)])) -(def box (di/derive `-box (partial filter some?))) - -(t/deftest box-test - (with-open [root (di/start `box {::b :b})] - (t/is (= [:b] @root)))) diff --git a/test/darkleaf/di/tutorial/q_starting_many_keys_test.clj b/test/darkleaf/di/tutorial/q_starting_many_keys_test.clj deleted file mode 100644 index 3fd1b6b9..00000000 --- a/test/darkleaf/di/tutorial/q_starting_many_keys_test.clj +++ /dev/null @@ -1,33 +0,0 @@ -;; # Starting many keys - -(ns darkleaf.di.tutorial.q-starting-many-keys-test - (:require - [darkleaf.di.core :as di] - [clojure.test :as t])) - -;; The standard `with-open` does not support destructuring in bindings. -;; Use `di/with-open` to handle resources with destructuring support. - -(def a :a) -(def b :b) - -(t/deftest verbose-test - (di/with-open [[a b] (di/start ::root {::root (di/template [(di/ref `a) (di/ref `b)])})] - (t/is (= :a a)) - (t/is (= :b b)))) - -;; The root container implements `clojure.lang.Indexed` -;; so you can use destructuring without derefing the root. - -(t/deftest indexed-test - (di/with-open [[a b] (di/start [`a `b])] - (t/is (= :a a)) - (t/is (= :b b)))) - -;; The root container implements `clojure.lang.ILookup` -;; so you can use destructuring without derefing the root. - -(t/deftest lookup-test - (di/with-open [{:keys [a b]} (di/start {:a `a :b `b})] - (t/is (= :a a)) - (t/is (= :b b)))) diff --git a/test/darkleaf/di/tutorial/r_multimethods_test.clj b/test/darkleaf/di/tutorial/r_multimethods_test.clj deleted file mode 100644 index 2b09db0a..00000000 --- a/test/darkleaf/di/tutorial/r_multimethods_test.clj +++ /dev/null @@ -1,37 +0,0 @@ -;; # Multimethods - -(ns darkleaf.di.tutorial.r-multimethods-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; You can use `defmulti` like `defn` to define a service. -;; Unlike `defn`, there is no way to get a definition of dependencies -;; and we have to define them as `::di/deps` on metadata. - -(defmulti service - {::di/deps [::x]} - (fn [-deps kind] kind)) - -(defmethod service :default [{x ::x} kind] - [kind x]) - -(t/deftest required-dep-test - (with-open [root (di/start `service {::x :value})] - (t/is (= [:kind :value] (root :kind))))) - -;; `::di/deps` defines only required dependencies, mostly for simplicity. -;; If you need to use an optional dependency, -;; simply convert it to a required dependency by adding a default value. - -(defn- wrap-default [x default] - (if (some? x) x default)) - -(def dep (di/derive ::optional wrap-default :default-value)) - -(t/deftest optional-dep-test - (with-open [root (di/start `service {::x (di/ref `dep), ::optional :value})] - (t/is (= [:kind :value] (root :kind)))) - - (with-open [root (di/start `service {::x (di/ref `dep)})] - (t/is (= [:kind :default-value] (root :kind))))) diff --git a/test/darkleaf/di/tutorial/x_add_side_dependency_test.clj b/test/darkleaf/di/tutorial/x_add_side_dependency_test.clj deleted file mode 100644 index cf943ef1..00000000 --- a/test/darkleaf/di/tutorial/x_add_side_dependency_test.clj +++ /dev/null @@ -1,30 +0,0 @@ -;; # Add side dependency - -(ns darkleaf.di.tutorial.x-add-side-dependency-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; Actually, the rest of the `di/start` arguments are middlewares. -;; Maps and collections are special cases of ones. -;; Middlewares allow us to implement extra features. - -;; In this test I'll show you how to perform a side effect like a database migration. - -(defn root - {::di/kind :component} - [] - 'root) - -(defn migrations - {::di/kind :component} - [{::keys [*migrated?]}] - (reset! *migrated? true)) - -(t/deftest add-side-dependency-test - (let [*migrated? (atom false)] - (with-open [root (di/start `root - (di/add-side-dependency `migrations) - {::*migrated? *migrated?})] - (t/is @*migrated?) - (t/is (= 'root @root))))) diff --git a/test/darkleaf/di/tutorial/x_ns_publics_test.clj b/test/darkleaf/di/tutorial/x_ns_publics_test.clj deleted file mode 100644 index 9ba03f48..00000000 --- a/test/darkleaf/di/tutorial/x_ns_publics_test.clj +++ /dev/null @@ -1,46 +0,0 @@ -;; # Ns publics - -(ns darkleaf.di.tutorial.x-ns-publics-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; `di/ns-publics` is a middleware that treats every public var of a -;; namespace as a component or service to be built, bundling them -;; under `:ns-publics/`. Starting that key gives you a map -;; of var name → built object — handy when you want a group of -;; related components and services without listing each one -;; explicitly. - -;; Vars holding `nil` or unbound vars are skipped. - -(def nil-component nil) ; excluded - -(def unbound-component) ; excluded - -(defn component - {::di/kind :component} - [] - :component) - -(defn service [{component `component} arg] - [component arg]) - -(t/deftest ok-test - (with-open [system (di/start :ns-publics/darkleaf.di.tutorial.x-ns-publics-test - (di/ns-publics))] - (t/is (map? @system)) - (t/is (= #{:component :service :ok-test} - (set (keys @system)))) - (t/is (= :component (:component system))) - (t/is (= [:component :my-arg] ((:service system) :my-arg))))) - -;; ## In practice - -;; This middleware originated in test infrastructure: a single global -;; test system was kept running, with adapter namespaces (a database -;; client, etc.) registered as roots via `ns-publics` so any test -;; could reach into them directly. `di/->memoize` later covered the -;; same ground with less ceremony — each test starts just the keys -;; it touches against a shared cache. `ns-publics` still works as -;; documented, but `->memoize` is the preferred way to do that now. diff --git a/test/darkleaf/di/tutorial/x_override_deps_test.clj b/test/darkleaf/di/tutorial/x_override_deps_test.clj deleted file mode 100644 index e6664cbb..00000000 --- a/test/darkleaf/di/tutorial/x_override_deps_test.clj +++ /dev/null @@ -1,23 +0,0 @@ -(ns darkleaf.di.tutorial.x-override-deps-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; (defn a [{dep ::dep -;; common ::common}] -;; [:a dep common]) - -;; (defn b [{dep ::dep -;; common ::common}] -;; [:b dep common]) - -;; (t/deftest ok -;; (with-open [root (di/start [`a `b] -;; {::dep-a :dep-a -;; ::dep-b :dep-b -;; ::common :c} -;; (di/rename-deps `a {::dep ::dep-a}) -;; (di/rename-deps `b {::dep ::dep-b}))] -;; (let [[a b] root] -;; (t/is (= [:a :dep-a :c] a)) -;; (t/is (= [:b :dep-b :c] b))))) diff --git a/test/darkleaf/di/tutorial/x_override_deps_test.clj.disabled b/test/darkleaf/di/tutorial/x_override_deps_test.clj.disabled new file mode 100644 index 00000000..f7824ee2 --- /dev/null +++ b/test/darkleaf/di/tutorial/x_override_deps_test.clj.disabled @@ -0,0 +1,23 @@ +(ns darkleaf.di.tutorial.x-override-deps-test + (:require + [clojure.test :as t] + [darkleaf.di.core :as di])) + +(defn a [{dep ::dep + common ::common}] + [:a dep common]) + +(defn b [{dep ::dep + common ::common}] + [:b dep common]) + +(t/deftest ok + (with-open [root (di/start [`a `b] + {::dep-a :dep-a + ::dep-b :dep-b + ::common :c} + (di/rename-deps `a {::dep ::dep-a}) + (di/rename-deps `b {::dep ::dep-b}))] + (let [[a b] root] + (t/is (= [:a :dep-a :c] a)) + (t/is (= [:b :dep-b :c] b))))) diff --git a/test/darkleaf/di/tutorial/x_update_key_test.clj b/test/darkleaf/di/tutorial/x_update_key_test.clj deleted file mode 100644 index 8a6addb2..00000000 --- a/test/darkleaf/di/tutorial/x_update_key_test.clj +++ /dev/null @@ -1,75 +0,0 @@ -;; # Update key - -(ns darkleaf.di.tutorial.x-update-key-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di])) - -;; `di/update-key` rewires an existing key: the original value is -;; built first, your function transforms it, and the result is what -;; every dependent sees. Multiple `update-key` calls on the same -;; target apply in registration order — each transforms the result of -;; the previous. - -;; This is the main tool for composing across namespaces — the -;; namespace that owns a key doesn't need to know about the modules -;; that decorate or extend it. It covers the two cases Integrant has -;; no clean answer for: AOP-style wrappers around components and -;; shared registries assembled from independent modules (see -;; [Integrant vs DI](/doc/integrant.md)). - -;; ## Decorate the built value - -;; `(di/update-key target f & args)` applies `f` to the built value -;; of `target`, threading it as the first argument. The classic case -;; is the decorator pattern: wrap the original in something with the -;; same shape that delegates to it, adding behaviour. In Clojure this -;; is usually a higher-order `wrap-X` — takes the thing, returns a -;; wrapped thing of the same kind. - -(defn handler [-deps req] - {:status 200 :body (:uri req)}) - -(defn wrap-log [handler *log] - (fn [req] - (swap! *log conj (:uri req)) - (handler req))) - -(t/deftest decorate-test - (let [*log (atom [])] - (with-open [root (di/start `handler - (di/update-key `handler wrap-log *log))] - (t/is (= {:status 200 :body "/a"} (root {:uri "/a"}))) - (t/is (= {:status 200 :body "/b"} (root {:uri "/b"}))) - (t/is (= ["/a" "/b"] @*log))))) - -;; ## Extend a collection - -;; Any argument after `f` is itself a factory and gets built. This -;; lets each module attach itself to a shared registry: it owns its -;; handler and the route entry that wires the handler in, then hooks -;; the entry onto `routes` with `di/ref`. The namespace that defines -;; `routes` never references any of the modules. - -(defn user-handler [-deps -req] - :user) - -(def user-route (di/template ["/users" (di/ref `user-handler)])) - - -(defn order-handler [-deps -req] - :order) - -(def order-route (di/template ["/orders" (di/ref `order-handler)])) - - -(def routes []) - -(t/deftest extend-test - (with-open [root (di/start `routes - (di/update-key `routes conj (di/ref `user-route)) - (di/update-key `routes conj (di/ref `order-route)))] - (t/is (= [["/users" :user] - ["/orders" :order]] - (for [[path handler] @root] - [path (handler :req)]))))) diff --git a/test/darkleaf/di/tutorial/y_graceful_stop_test.clj b/test/darkleaf/di/tutorial/y_graceful_stop_test.clj deleted file mode 100644 index b85fd8b2..00000000 --- a/test/darkleaf/di/tutorial/y_graceful_stop_test.clj +++ /dev/null @@ -1,37 +0,0 @@ -;; # Graceful stop - -(ns darkleaf.di.tutorial.y-graceful-stop-test - (:require - [clojure.test :as t] - [darkleaf.di.core :as di] - [darkleaf.di.utils :refer [catch-some]])) - -;; The DI tries to stop components that are already started -;; if another component fails while it is starting. - -;; It throws the original exception. -;; All other possible exceptions are added as suppressed. - -(defn root - {::di/kind :component} - [{dep `dep - on-start-root-ex ::on-start-root-ex}] - (throw on-start-root-ex)) - -(defn dep - {::di/stop (fn [on-stop-dep-ex] (throw on-stop-dep-ex))} - [{on-stop-dep-ex ::on-stop-dep-ex}] - on-stop-dep-ex) - - -(t/deftest graceful-start-test - (let [on-start-root-ex (ex-info "on start root" {}) - on-stop-dep-ex (ex-info "on stop dep" {}) - registry {::on-start-root-ex on-start-root-ex - ::on-stop-dep-ex on-stop-dep-ex} - ex (-> (di/start `root registry) - catch-some)] - (t/is (= ::di/build-failure (-> ex ex-data :type))) - (t/is (= [`root] (-> ex ex-data :stack))) - (t/is (= on-start-root-ex (-> ex ex-cause))) - (t/is (= [on-stop-dep-ex] (-> ex .getSuppressed seq)))))