Vertical NixOS module organisation and module dependency

Hello,

This blog post from Gabriella439 describes why vertical module organisation can be nice. It’s originally written for Haskell modules. Despite nix being interpreted, I find it more ergonomic to use vertically organized NixOS modules because the changes are easily grouped.

Currently I keep all my NixOS modules “additive”. Each module is as standalone as possible, and adding it to the imports attribute is all it takes to use it. Otherwise, each module can toggle other modules’ options, and the behaviour is not visible without evaluation, making the module system hard to reason with.

Regarding module dependency:
I have a few modules for network or user configuration that uses agenix. With the vertical organisation in mind, each agenix secret (the encrypted age file) and what uses the decrypted file is declared in each of the self contained module.

For example, in the file system module:

  age.secrets = {
    mybigdisk.file = ./mybigdisk.age;
  };
  environment.etc."crypttab".text = ''
    mybigdisk /dev/disk/by-uuid/blahblahuuid ${config.age.secrets.mybigdisk.path} noauto
  '';

The secret is handled within the same module as cryptab. Importing this module will not lead to having a secret that is defined but not used, or crypttab not finding the secret.

However this module depends on agenix is not fully self-contained. Not importing agenix in this module is a conscious choice, because once it’s imported we can’t remove an import. If there are a lot of self-contained modules like this, agenix will also be imported multiple times. Is there a way to define the dependency of a module for it to work as intended? This way, the module can fail with better error message too. Instead of saying “options.age.secrets” is not defined, it would say "fileSystems.nix" module requires agenix module but this constraint is not satisfied.

Thanks for reading my convoluted question :slight_smile:

1 Like

Maybe you want to try the dendritic pattern.

It might be able to solve your first problem:

However, I do not recommend using any existing dendritic frameworks, such as den. Because I believe these ‘early practices’ did not address your second question:

So far, these frameworks use functions to deduplicate module dependencies, which is not transparent and makes error reporting harder to understand.

However, according to the design concept of the dendritic pattern, it is feasible to use module options in the top-level module to conditionally import modules, thereby controlling module dependencies. I use a similar method in my configuration (pardon the mess).

btw, I don’t want to belittle the efforts of the dendritic pattern developers. They are very friendly and have helped me in many ways. But I feel that these frameworks are a bit too complicated, and I find it hard to understand. ‘early practices’ is my honest opinion about these libraries, with no intent to offend, but I feel that they are not yet ready for use in a production environment.

EDIT: To add, I think the solution proposed in this RFC addresses the issues of module dependencies and interactions. Although this is aimed at NixOS modules, this idea has inspired me, and I believe this approach can be used in more places.

1 Like

You’re completely correct that currently it is not possible to doubly-import modules for this; the module system assumes that each module is only imported once, and is very allergic to conditional additions or removals from the module list.

The module system is simply not designed to support this - you have to live with importing your “dependency” modules at the top-level - for now, until that RFC @FrdrCkII links progresses.


That said:

Am I misreading the post? @leana8959 seems to already have solved that problem using the NixOS module system - which is to be expected, vertical module integration is perfectly doable with just one module system, you don’t need to nest them.

E.g., this works and is fully “vertical” configuration in the style of initiatives like clan or selfhostblocks:

# configuration.nix
{
  imports = [
    # Just to showcase that flakes aren't necessary
    <agenix/modules/age.nix>

    ./filesystem.nix
    ./some-cool-service.nix
  ];

  # Some high-level boilerplate, like `system.stateVersion`
}
# filesystem.nix
{ config, ... }: {
  age.secrets = {
    mybigdisk.file = ./mybigdisk.age;
  };

  environment.etc."crypttab".text = ''
    mybigdisk /dev/disk/by-uuid/blahblahuuid ${config.age.secrets.mybigdisk.path} noauto
  '';
}
# some-cool-service.nix
{ config, ... }: {
  age.secrets = {
    myapisecret.file = ./myapisecret.age;
  };

  systemd.services.some-cool-service = {
    environment.myapisecretfile = config.age.secrets.myapisecret.file;
  };
}

Each of the “services” is self-contained, and can be moved around - even to other projects - perfectly fine, although you cannot duplicate imports. The reason these “dendritic” projects haven’t solved that issue is because NixOS hasn’t, either, they all use the exact same code (from the nixpkgs repo) behind the scenes.

In fact, I believe “vertical” organization like this to be the intent behind the NixOS module system. Just look at how the NixOS modules are organized (though NixOS has another top-level directory to categorize them, and fit things that don’t abstract into “verticals” as cleanly as you would in a traditional software project), it’s exactly like this.


The whole concept to me just reads like different words for fundamental software architecture practices, you might have heard of “lasagna” and “spaghetti” code before - “vertically” integrated code is “lasagna”, y’know, vertical pasta layers, instead of the tangled mess of spaghetti.

Design like this does eventually start running into issues once you have cross-“vertical” dependencies, e.g. if you want to scrape metrics from some-cool-service with prometheus. At that point you start having to weave in hard dependencies between the “vertical” layers. Even if you deploy prometheus to another server, a dependency on a hostname that you need to recover from somewhere remains. Good architecture minimizes such dependencies, but never eliminates them (just like in a good lasagna water from the sauce will cook the pasta, if you want more pasta analogies).

NixOS’ config module arg caters to this problem, but over-use can easily turn your lasagna into spaghetti - e.g. by creating many custom options for abstract, high-level concepts, as many newbies like to do when they learn how to create options. I’m pretty sure there are some still in my personal configurations to this day :smiley:

Lasagna is definitely superior to spaghetti, for the record. This is an immutable fact, the code analogy is unrelated.


To be clear, finding someone talking about dendritic here is exactly my problem with it; it seems to come purely from associating a name with an architectural pattern that doesn't need any of the marketing hype or associated projects. Click for the use cases where associated projects *do* make sense and some elaboration.

The NixOS module system by itself only starts struggling when you start worrying about the “vertical” integration of specifically nix-darwin into a NixOS configuration.

At face value attempting to do so makes zero sense, because NixOS and nix-darwin are fundamentally for different operating systems and hence inherently incompatible, but they may describe similar “services”, so people want to integrate the “verticals” of those services, and they want one configuration to span both their desktop computer running Linux and a Mac.

This is at least a legitimate use case, though the marketing and hype around it is way overblown for this niche (and I still believe you could do better than nesting module systems, but that’s a story for another day).

Even integrating home-manager into NixOS or nix-darwin works fine thanks to sharedModules, though people generally don’t understand the module system well enough to use it properly before they start reading stuff - and that leads them to blog posts that talk with a lot of hype about “dendritic”.

I think this post showcases exactly why I believe “dendritic” is largely people discovering that the NixOS module system has really useful properties through the lens of flake-parts (and projects or blog posts inspired by it), without coming to the complete realization that that’s what they’re using behind the scenes already.

ETA: It’s kinda funny that @FrdrCkII mentions that they think the RFC to help solve multi-imports in NixOS could be used elsewhere; yeah, it will be, all these projects share the same code path. Kinda proves my point, I think, you have to fundamentally misunderstand how the projects came to be or function to think that way :wink:

5 Likes

Indeed the contracts RFC’s goal is not to solve duplicate imports. It is to provide a pattern when one wants to decouple modules and avoid having one module implicitly define options of another one. This decoupling makes only sense when some module does care about having access to a feature and not how it is implemented.

For example, a service can setup some routes on a reverse proxy but it does not necessarily care about which reverse proxy is used. Currently in nixpkgs, modules allowing this implement the equivalent of a big if clause. The RFC propose another pattern where the module declares what it wants and have the user provide the reverse proxy they want.

The user must still wire up the reverse proxy though. Applied to your use case, you still would need at some point to say “I want age to provide this secret”. Taking the example snippets from @TLATER, this wiring would go inside the top-level configuration.nix.

I think what you would want to say is something like “use age to provide all the secrets for all options where I don’t explicitly set it”. So something that would recurse over all submodule options and identify which represent secrets and setup the wiring automatically. Although the RFC does not intend to solve this, it is one step forward to having this possible.

2 Likes

My mistake. When I was trying to build my configuration, I encountered module dependency issues with the dendritic pattern, so I mentioned it. But you’re right, it has nothing to do with the dendritic pattern.

But I think you also agree with this point: nixos does not solve the problem of module dependencies, and this problem exists in any ‘vertical’ configuration.

Let me explain this problem in detail.

So @TLATER quoted @leana8959’s example, trying to illustrate that, in theory, NixOS can organize ‘vertical’ configurations. @TLATER is correct, but I think the problem is not whether it can be done, but how to do it.

Let me show a slightly more complex example:

# adghome.nix
{ ... }:
{
  services = {
    adguardhome = {
      enable = true;
      settings = { };
    };
  };
}
# configuration.nix
{
  imports = [
    ./adguardhome.nix
  ];
}

This is a super simple NixOS module that enables adguardhome.

I just need to simply import it in the configuration, and the configuration is ‘vertical’.

Everything looked fine until I added impermanence to my configuration.

# adghome.nix
{
  services = {
    adguardhome = {
      enable = true;
      settings = { };
    };
  };
}
# impermanence.nix
{
  imports = [
    <impermanence/nixos.nix>
  ];
  environment.persistence."/persistent" = {
    directories = [
      "/var/lib/AdGuardHome"
      "/var/lib/private/AdGuardHome"
    ];
  };
}
# configuration.nix
{
  imports = [
    ./adguardhome.nix
    ./impermanence.nix
  ];
}

So, obviously, I want to persist AdGuard Home’s data. But the problem is, this piece of code is the result of the combined effect of adghome.nix and impermanence.nix.

  environment.persistence."/persistent" = {
    directories = [
      "/var/lib/AdGuardHome"
      "/var/lib/private/AdGuardHome"
    ];
  };

It shouldn’t be in impermanence.nix, because this setting belongs to adguardhome. But it also shouldn’t be in adghome.nix, because adguardhome doesn’t depend on impermanence. For me, these kinds of configurations create a huge maintenance burden. As @TLATER said, it’s messy spaghetti, tightly coupled together, making it difficult to separate cleanly.

So, when I saw the RFC linked earlier, I was very excited. We can simply declare the required paths, and then impermanence can easily consume this metadata, saving the trouble of manual setup. Just like this:

{
  services = {
    adguardhome = {
      enable = true;
      settings = { };
    };
  };
  iNeedDirectories = [
    "/var/lib/AdGuardHome"
    "/var/lib/private/AdGuardHome"
  ];
}

Not only external modules like impermanence, but internal NixOS modules can also benefit from it; for example, the systemd module can consume this metadata and pre-create the needed paths.

That’s exactly what I mean. Maybe my original expression wasn’t clear enough, but I really think it provides a better way for communication between modules.

This indicates one thing that module dependency issues are not that simple:

No. If you want to write some truly useful ‘vertical’ modules, simple imports can’t solve the problem.

Just like @TLATER said, NixOS hasn’t solved this problem at all. Even though NixOS has been organized into ‘vertical’ modules, it hasn’t gained the benefits of ‘vertical’ modules. All modules are still tightly coupled together.

This brings up another problem:

@TLATER talked a lot about code organization, design concepts, and the like. They are correct, but it does not help solve the problems of module dependencies and communication. Of course, I know that nesting a new layer of modules will lead to extra abstraction and overhead, but it also brings the conditional import capability that NixOS modules cannot achieve, and provides excellent error handling and type checking just like NixOS modules.

Firstly, I need to clarify that this RFC addresses the problem of module communication, whereas the top-level module addresses the problem of conditional imports/duplicate imports.

Secondly, NixOS is a large collection of modules, and changing it requires considerable effort from developers. I have seen developers’ efforts in many related RFCs and PRs, but these changes cannot be accomplished overnight. Trying to introduce the pattern of this RFC in personal configurations requires much less work.

Finally, let me summarize what I said:

I am recommending two methods to handle vertical modules:

  1. Use top-level modules with conditional imports to handle module dependencies
  2. Use the pattern from this RFC to handle communication between modules

It’s not silver bullet. This approach has drawbacks. It introduces an additional module system; it has some runtime overhead; but it works immediately, without waiting for upstream support.

@TLATER There has been too much criticism about the dendritic pattern, which really isn’t that related to this post. Of course, it was my mistake. I shouldn’t have brought it up in the first place.

5 Likes

Yeah, which is why I hid most of that in a details section.

My intent was just to limit more conflation between the “dendritic” thing and the broad architectural design pattern we’re talking about here. I see confusion around this a lot from newcomers.

You do a good job explaining why that RFC is cool, though, kudos for that.

2 Likes

What I do in this case, is “conditionally declaring the option” in adguardhome.nix. If options.environment.persistence exists, then add the relevant adg directories to it:

{ options, lib, ... }: {
  environment.${if options ? environment.persistence then "persistence" else null} = {
    "/persistent".directories = [
      ...
    ];
  };
}

Note that mkIf doesn’t work here, since you’d still be setting an option that does not exist. But like this you can import adguardhome.nix regardless of whether impermanence is imported. I don’t like this but it tends to get the job done, for example in my catppuccin home-manager module, where I set the catppuccin wallpaper and colorscheme if plasma-manager is available.

But then again that doesn’t really un-spaghettify it, it only gets rid of errors when a “dependency” isn’t imported.

3 Likes