A Nixpkgs module system usage pattern
Includes an annotated example
I adore this idea by @mightyiam of every file is a flake parts module and I think I will adopt it everywhere.
—Daniel Firth (source)
Massive, very interesting!
—Pol Dellaiera (source)
I’ve adopted your method. Really preferring it.
—gerred (source)
NixOS, home-manager and nix-darwin are popular projects that allow the user to produce derivations that can be customized by evaluating a Nixpkgs module system configuration.
Figuring out a practical and expressive architecture for a codebase that provides configurations had proven to cost many a Nix user numerous rounds of re-architecturing.
Factors contributing to the complexity of the matter:
- Multiple configurations
- Sharing of modules across configurations
- Multiple configuration classes (
nixos,home-manager, etc.) - Configuration nesting such as home-manager within NixOS or within nix-darwin
- Cross-cutting concerns that span multiple configuration classes
- Accessing values such as functions, constants and packages across files
The dendritic pattern reconciles these factors using yet another application of the Nixpkgs module system—a top-level configuration. The top-level configuration facilitates the declaration and evaluation of lower-level configurations, such as NixOS, home-manager and nix-darwin.
Commonly, this top-level configuration is a flake-parts configuration,
but it does not have to be.
Alternatives to flake-parts may exist.
Also, the module system can be used directly via (lib.evalModules).
In the dendritic pattern every Nix file
except for entry points such as default.nix and flake.nix
is a module of the top-level configuration.
In other words, every Nix file that isn't an entry point is
a Nixpkgs module system module that is imported directly into the evaluation of the top-level configuration.
Additionally, every top-level module:
- implements a single feature
- ...across all configurations that that feature applies to
- is at a path that serves to name that feature
Lower-level modules and configurations such as NixOS, home-manager and nix-darwin are stored as option values in the top-level configuration.
Lower-level modules take part in the evaluation of any number of lower-level configurations.
The Nixpkgs module system type deferredModule and similar implementations feature value merging.
This allows multiple lower-level module values to merge under one distinct name.
For example, in a top-level module, a lower-level module can be declared:
Note
In flake-parts, named lower-level modules are often under the flake.modules option.
# modules/audio.nix
flake.modules.nixos.audio = { /* content */ };Or it may be merged with others under a common name:
# modules/audio.nix
flake.modules.nixos.pc = { /* content */ };The common question "what's in that Nix file?" is made irrelevant.
Non-entry point files contain a Nixpkgs module system module
of the same class
as the top-level configuration.
Since all non-entry-point files are top-level modules and their paths convey meaning only to the author, they can all be automatically imported using a trivial expression or a small library.
In some patterns file paths are significant to some particular detail, such as the type of the expression the file contains or what specific configuration it belongs to.
Contrary to those, in this pattern a file path represent a feature. Each file can be freely renamed and moved, and it can be split when it grows too large or too complex.
- Shahar "Dawn" Or (@mightyiam) (adoption commit)
- Victor Borja (@vic) (adoption pull request) (forum answer)
- Pol Dellaiera (adoption pull request) (blog post)
- Horizon Haskell
- Gaétan Lepage (acknowledgment commit)
- bivsk (adoption pull request)
- Michael Belsanti
- Oliver Davies
- vic/dendrix/Dendritic - on the benefits of the pattern
- vic/den - aspect-oriented dendritic framework
- vic/dendritic-unflake - non-flake, non-flake-parts examples
- Doc-Steve/dendritic-design-with-flake-parts - module design guide
In a non-dendritic pattern some Nix files may be modules that are lower-level
(such as NixOS or home-manager).
Often they require access to values that are defined outside of their config evaluation.
Those values are often passed through to such evaluations
via the specialArgs argument of lib.evalModules wrappers like lib.nixosSystem.
For example, scripts/foo.nix defines a script called script-foo
which is then included in environment.systemPackages in nixos/laptop.nix.
script-foo is made available in nixos/laptop.nix by injecting it
(or a superset of it, such as the flake self may be) via specialArgs.
This might occur even once deeper from the NixOS evaluation into a nested home-manager evaluation
(this time via extraSpecialArgs).
In the dendritic pattern
every file is a top-level module and can therefore add values to the top-level config.
In turn, every file can also read from the top-level config.
This makes the sharing of values between files seem trivial in comparison.
One may be tempted to assign each lower-level module to its own unique name.
Such granularity would result in a great number of named modules.
The cost of such a pattern is that imports lists would end up being much longer than necessary.
For example, a flake.modules.nixos.pc module would import with config.flake.modules.nixos; [fonts graphics audio <...and many more>].
Another cost is that when a new named lower-level module is added,
it would have to be added to all of the import lists in which it should be.
That goes for the removal of named lower-level modules, as well.
Consider merging multiple non-distinct lower-level modules under one distinct name.
