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.

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.

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.
{ config, ... }: {
  flake.modules.nixos.ssh = {
    # Linux config: server, firewall-ports, etc.
  };

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

  flake.modules.homeManager.ssh = {
    # setup ~/.ssh/config or keys.
  };

  perSystem = {pkgs, ...}: {
    # expose custom package/checks/devshells by this aspect.
  };
}

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 (eg 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; # 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 entrely 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 _ anywhere 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 the flake itself. It is now possible move to all nix logic into ./modules.

Your flake becomes minimal, focused on defining inputs and possibly cache, experimental-features config.

And any file inside modules can contribute to flake outputs (packages/checks/osConfigurations) as needed.

# modules/flake/formatter.nix
{
  perSystem = {pkgs, ...}: {
    formatter = pkgs.alejandra;
  };
}

Feature Centric instead of Host Centric.

As noted by Pol Dellaiera in Flipping the Configuration Matrix:

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 applied.

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 decomissioned immediatly/temporarily.

No dependencies other than flake-parts

Since the dendritic pattern builds around flake-parts modules, the only dependency is flake-parts. You can load files using import-tree or any other means. You can also use any flake-parts based library to define your configurations, like Unify, as long as it exposes flake.modules.<class>.<aspect> attribute sets.

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.