Dendritic Nix

Dendritic is a pattern for writing nix configurations based on flake-parts's modules option.

We say that Dendritic nix configurations are aspect-oriented, meaning that each nix file provides config-values for the same aspect across different nix configuration classes.

Normally, this is done via flake-parts' flake.modules.<class>.<aspect> options.

Where <class> is a type of configuration, like nixos, darwin, homeManager, nixvim, etc.

And <aspect> is the cross-cutting concern or feature that is being configured across one or more of these classes.

[!NOTE] Dendritic is a configuration pattern - a way-of-doing-. Not a library nor a framework. See No Dependencies.

Example of a dendritic configuration.

As an example of what a dendritic nix config looks like, suppose we want to configure ssh facilities (the ssh aspect) across our NixOS, Nix-darwin hosts and user homes.

# modules/ssh.nix -- like every other file inside modules, this is a flake-parts module.
{ inputs, config, ... }: let
  scpPort = 2277; # let-bindings or custom flake-parts options communicate values across classes
in {
  flake.modules.nixos.ssh = {
    # Linux config: setup OpenSSH server, firewall-ports, etc.
  };

  flake.modules.darwin.ssh = {
    # MacOS config: enable MacOS builtin ssh server, etc.
  };

  flake.modules.homeManager.ssh = {
    # setup ~/.ssh/config, authorized_keys, private keys secrets, etc.
  };

  perSystem = {pkgs, ...}: {
    # custom packages taking advantage of ssh facilities, eg deployment-scripts.
  };
}

That's it. This is what Dendritic is all about. By following this configuration pattern you will notice your code now incorporates the following:

Denritic Advantages

No need to use specialArgs for communicating values.

A common pattern for passing values between different nix configurations types (e.g., between a nixos config and a homeManager one), is to use the specialArgs module argument or home-manager.extraSpecialArgs.

This is considered an anti-pattern in dendritic setups, since there's no need to use specialArgs at all. Because you can always use let bindings (or even define your own options at the flake-parts level) to share values across different configuration classes.

# modules/vic.nix -- a flake-parts module that configures the "vic" user aspect.
let
  userName = "vic"; # a shared value between classes
in
{
  flake.modules.nixos.${userName} = {
     users.users.${userName} = { isNormalUser = true; extraGroups = [ "wheel" ]; };
  };

  flake.modules.darwin.${userName} = {
     system.primaryUser = userName; # note that configuring a user is different on MacOS than on NixOS.
  };

  flake.modules.homeManager.${userName} =
    { pkgs, lib, ... }:
    {
      home.username = lib.mkDefault userName;
      home.homeDirectory = lib.mkDefault (if pkgs.stdenvNoCC.isDarwin then "/Users/${userName}" else "/home/${userName}");
      home.stateVersion = lib.mkDefault "25.05";
    };
}

No file organization restrictions.

The dendritic pattern imposes no restrictions on how you organize or name your nix files.

Unlike other nix-configuration libs/frameworks that mandate a predefined structure. In Dendritic, you are free to move or rename each file as it better suits your mental model.

This is possible because:

All nix files have the same semantic meaning.

In a Dendritic setup, each .nix file has only one interpretation: A flake-parts module.

Unlike other kinds of setup where each nix file can be a nixos configuration, or a home-manager configuration, or a package, or something entirely different. In such setups, loading a file requires you to know what kind of meaning each file has before importing it.

This leads us to having:

No manual file imports.

All files being flake-parts modules, means we have no need for manually importing nix files. They can all be loaded at once into a single import.

The Dendritic community commonly uses vic/import-tree for this. Note: import-tree ignores any file that has an /_ as part of its path.

# flake.nix
{
  inputs = {
    flake-parts.url = "github:hercules-ci/flake-parts";
    import-tree.url = "github:vic/import-tree";
    # all other inputs your flake needs, like nixpkgs.
  };
  outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);
}

This is the only place you will call import-tree and it will load all files under ./modules recursively.

This means we can have:

Minimal and focused flake.nix

Instead of having huge flake.nix files with lots of nix logic inside. It is now possible to move all nix logic into well organized auto-imported flake-parts in ./modules. This way, flake.nix serves more as a manifest of dependencies and flake entrypoint.

Some people go a step further and use vic/flake-file to manage their flake.nix automatically, by letting each flake-parts module also define the flake inputs needed by each module.

Any flake-parts module contributes to flake.nix as needed, either inputs/flake-configuration (by using vic/flake-file) or outputs (modules/packages/checks/osConfigurations/etc by using flake-parts options).

# ./modules/home/vim.nix
{ inputs, ... }:
{
  flake-file.inputs.nixvim.url = "github:nix-community/nixvim";
  flake.modules.homeManager.vim = {
    # use inputs.nixvim
  };
}

Feature Centric instead of Host Centric.

As noted by Pol Dellaiera in Flipping the Configuration Matrix -a very recommended read-:

[In a Dendritic setup] the configuration is now structured around features, not hostnames. It is a shift in the axis of composition, essentially an inversion of configuration control. What may seem like a subtle change at first has profound implications for flexibility, reuse, and maintainability.

You will notice that you start naming your files around the aspects (features) they define instead of where they are specifically applied.

It might be useful to ask yourself how you like to use your Linux Environment, instead of what components constitute the environment or where will the environment finally applied. By doing so, you will start naming your aspects around "usability concerns", eg. macos-like-bindings, scrolling-desktop, tui interfaces, nextgen cli utilities, ai intergation, etc. Instead of naming modules around specific packages or host-names.

In the following example, the scrolling-desktop aspect is included accross different operating systems: On Linux, flake.modules.nixos.scrolling-desktop might enable niri and on MacOS, flake.modules.darwin.scrolling-desktop might enable paneru. Each configuration uses the tools available on the respective platform, but the aspect is the same, and you could use flake-parts level options or let-bindings to configure behaviour on both (e.g, the scrolling animations speed).

# ./modules/hosts.nix
{ inputs, ... }:
{
  flake.nixosConfigurations.my-host = inputs.nixpkgs.lib.nixosSystem {
    system = "aarm64-linux";
    modules = with inputs.self.modules.nixos;  [ ai ssh vpn mac-like-keyboard scrolling-desktop ];
  };
  flake.darwinConfigurations.my-host = inputs.nix-darwin.lib.darwinSystem {
    system = "aarm64-darwin";
    modules = with inputs.self.modules.darwin; [ ai ssh vpn scrolling-desktop ];
  };
}

Feature Closures

By closure, we mean: everything that is needed for a given feature to work is configured closely, in the same unit (file/directory named after the feature).

Because a single feature.nix contributes to different configuration classes, it has all the information on how feature works, instead of having to look at different files for package definitions, nixos or home-manager configurations dispersed over all over the tree.

If you need to look where some feature is defined on a repo you don't know, it will be easier to simply guess by path name. Paths become documentation.

Incremental Features

Since all nix files are loaded automatically. You can increment the capabilities that an existing feature-x/basic.nix provides by just creating another feature-x/advanced.nix. Both of them should contribute to the same aspect: flake.modules.<class>.feature-x, but each file focuses on the different capabilities they provide to the system whole.

This way, you can split feature-x/advanced.nix into more files. And adding or removing files from your modules (or adding an _ for them to be ignored) has no other impact than the overall capabilities provided into your systems.

This is an easy way to disable loading files while on a huge refactor. Or when some hosts or features should be decommissioned immediately/temporarily.

No dependencies

Dendritic is a configuration pattern - a way-of-doing-, not a library nor a framework.

The Dendritic repository has no code at all and any libraries mentioned on this document are mere recommendations and pointers to things other people using the Dendritic pattern has found useful.

You are free and encouraged to explore new ways of doing or wiring Dendritic setups. Be sure to share your insights with the community.

Because all of this, there are many possible implementations of the Dendritic pattern.

Some people like to use inline-style definitions:

{ inputs, ... }:
{
  flake.modules.<class1>.<aspect1> = { ... };
  flake.modules.<class2>.<aspect1> = { ... };
  flake.modules.<class3>.<aspect1> = { ... };
}

Others might prefer nested modules using libs like unify or vic/flake-aspects:

All is good as long as you expose flake.modules.<class>.<aspect> attribute sets.

# using vic/flake-aspects:
{ inputs, ... }:
{
  flake.aspects.<aspect1> = {
    <class1> = { ... };
    <class2> = { ... };
    <class3> = { ... };
  };
}

Dendritic community.

Last but not least. By using the dendritic pattern you open the door to defining or re-using existing generic (user/host independent) configurations from the community.

This is the goal of the Dendrix project: To allow people share dendritic configurations and socially enhance their capabilities.