Evaluating Den - A Dendritic Configuration Framework
| | 15 minutes | 3140 words
I am currently rewriting again my infrastructure repository, now renamed
drupol/infra, and documenting the work on an ongoing draft pull request:
#124.
After my previous rewrite based on a
feature-first mindset, I wanted to push the architectural concept further using
vic/den, a Nix framework built around aspects, context transformations and
dendritic pattern.
The short version: Den is impressive, genuinely powerful, and intellectually satisfying. However, while experimenting with it in a real infrastructure, I ran into an architectural constraint that feels deeper than any specific framework.
This post explores that specific problem: a collision of cardinality, scope, and ownership in declarative configuration.
Why Den Looked Like the Right Next Step
Den models “dendritic” configuration as:
- Aspect-oriented configuration (handling cross-cutting features)
- Context pipeline transformations (e.g., mapping
host->user, with optional reverse relationships) - Per-class outputs (
nixos,homeManager, and others)
This maps beautifully to how I think about modular infrastructure.
If a feature is desktop, I want to define it once, then have it intelligently influence all relevant
configuration domains. At first glance, this is exactly what Den enables.
A Minimal Example
To illustrate the problem, let’s look at a minimal example.
This is not my actual configuration, but a simplified version that captures the essence of the issue.
1{
2 lib,
3 den,
4 ...
5}:
6{
7 # Enable HomeManager for all users by default
8 den.schema.user.classes = lib.mkDefault [ "homeManager" ];
9
10 # Define a host `igloo` with one user `alice`
11 # This implicitly creates the `igloo` and `alice` aspects, which we can then extend
12 den.hosts.x86_64-linux.igloo.users = {
13 alice = { };
14 };
15
16 # Extend the `alice` aspect that includes user definition and primary user role
17 den.aspects.alice = {
18 includes = [
19 # These aspects are built-in, see: https://den.oeiuwq.com/guides/batteries/
20 den.provides.define-user
21 den.provides.primary-user
22 ];
23 };
24
25 # Extend the `igloo` aspect that includes `base` and `desktop`, plus some NixOS options
26 den.aspects.igloo = {
27 includes = with den.aspects; [
28 base
29 desktop
30 ];
31
32 nixos = {
33 boot.loader.grub.enable = false;
34 fileSystems."/".device = "/dev/null";
35 };
36 };
37
38 # Define the `base` aspect that includes both Home Manager and NixOS options
39 den.aspects.base = {
40 homeManager = {
41 home.stateVersion = "25.11";
42 };
43
44 nixos =
45 { pkgs, ... }:
46 {
47 boot.kernelPackages = pkgs.linuxPackages_latest;
48 system.stateVersion = "25.11";
49 };
50 };
51
52 # Define the `desktop` aspect that includes both Home Manager and NixOS options
53 den.aspects.desktop = {
54 homeManager = {
55 programs.firefox.enable = true;
56 };
57
58 nixos = {
59 services.desktopManager.plasma6.enable = true;
60 };
61 };
62}
Conceptually, this delivers exactly what aspect-oriented configuration promises:
- One conceptual feature (e.g.,
base,desktop) - Multiple configuration targets (e.g.,
nixos,homeManager)
In practice, this is where the friction begins.
Propagation Scope
If we evaluate the configuration above, we hit an immediate snag:
1$ nix build .#nixosConfigurations.igloo.config.system.build.toplevel
2
3error: The option `home-manager.users.alice.home.stateVersion' was accessed but
4has no value defined. Try setting the option.
The home.stateVersion defined in the base aspect is not propagating to the user alice.
Fear not ! Den anticipates this and provides a mechanism to establish a mutual provider relationship:
@@ -7,6 +7,11 @@
# Enable HomeManager for all users by default
den.schema.user.classes = lib.mkDefault [ "homeManager" ];
+ # Allows you to define mutual configurations by letting you to define named
+ # aspects under `.provides.` to create explicit relationship between users and
+ # hosts. See https://den.oeiuwq.com/guides/mutual/#denprovidesmutual-provider
+ den.ctx.user.includes = [ den._.mutual-provider ];
+
@@ -24,10 +29,12 @@
# Extend the `igloo` aspect that includes `base` and `desktop`, plus some NixOS options
den.aspects.igloo = {
- includes = with den.aspects; [
- base
- desktop
- ];
+ provides.to-users = {
+ includes = with den.aspects; [
+ base
+ desktop
+ ];
+ };
Basically, this means that the base and desktop aspects included in the igloo host aspect will now be forwarded to
the users of that host, making the homeManager configuration available to alice as expected:
A mixed aspect like base naturally contains both:
- Host-level concerns (
nixos): kernel packages, state versions, GPU drivers,… - User-level concerns (
homeManager): shell aliases, desktop apps, user packages,…
This exposes a classic OS design problem: the separation of Mechanism and Policy.
The framework provides the mechanism to pass configurations between contexts. But it cannot dictate the policy. If
an aspect is attached at the host level (igloo includes base), what exactly should happen for the users ? Do all
users get the homeManager payload ? Only some users ? Only the primary user ? Only users matching a specific role ?
<you-name-it> ?
The framework requires you to encode this intent. But as we scale, encoding this intent creates a deeper structural issue, leading to more complex and less intuitive models.
From 1->1 to 1->N
With a host and 1 user, forwarding host-selected aspects to users is trivial. The evaluation behaves exactly as expected:
1nix-repl> nixosConfigurations.igloo.config.home-manager.users.alice.home.stateVersion
2"25.11"
3
4nix-repl> nixosConfigurations.igloo.config.home-manager.users.alice.programs.firefox.enable
5true
The breaking point appears when we move from a 1->1 topology to a 1->N topology. Let’s add a second user, bob, to
the igloo host:
@@ -12,10 +12,11 @@
# hosts. See https://den.oeiuwq.com/guides/mutual/#denprovidesmutual-provider
den.ctx.user.includes = [ den._.mutual-provider ];
- # Define a host `igloo` with one user `alice`
- # This implicitly creates the `igloo` and `alice` aspects, which we can then extend
+ # Define a host `igloo` with users `alice` and `bob`.
+ # This implicitly creates the `igloo`, `alice` and `bob` aspects, which we can then extend
den.hosts.x86_64-linux.igloo.users = {
alice = { };
+ bob = { };
};
@@ -27,6 +28,13 @@
];
};
+ # Extend the `bob` aspect that includes user definition
+ den.aspects.bob = {
+ includes = [
+ den.provides.define-user
+ ];
+ };
The graph topology now looks like this:
But evaluation now fails with:
1error: The option `boot.kernelPackages' is defined multiple times while it's expected to be unique.
2
3Definition values:
4 - In `/nix/store/...-source/modules/igloo.nix, via option den.aspects.base.nixos'
5 - In `/nix/store/...-source/modules/igloo.nix, via option den.aspects.base.nixos'
6
7Use `lib.mkForce value` or `lib.mkDefault value` to change the priority on any of these definitions.
Not Invented Here
In software engineering, this is analogous to the Diamond Problem, exacerbated here by a cardinality mismatch.
In data modelling, this is analogous to a SQL
join fan-out: a host is unique (cardinality 1), but
after joining through users (cardinality N) it gets duplicated, and when projected back to host scope it collides with
itself.
Because boot.kernelPackages is defined in the base aspect, and both alice and bob receive the base aspect from
the host, they both attempt to feed that host-level nixos configuration back up the evaluation graph.
In this demo, base and desktop are active on both the host path and the forwarded user paths, which is why a unique
host-level option can collide when cardinality moves from 1->1 to 1->N.
Visually, the evaluation graph looks like this:
The host (cardinality of 1) receives the identical configuration twice from the users (cardinality of N), creating a collision.
To fix this, we must make ownership boundaries explicit by splitting the mixed aspect into dedicated host and user variants, as follows:
And the complete corresponding configuration now looks like this:
1{
2 den,
3 lib,
4 ...
5}:
6{
7 den.ctx.user.includes = [ den._.mutual-provider ];
8 den.schema.user.classes = lib.mkDefault [ "homeManager" ];
9
10 den.hosts.x86_64-linux.igloo.users = {
11 alice = { };
12 bob = { };
13 };
14
15 den.aspects.alice = {
16 includes = [
17 den.provides.define-user
18 den.provides.primary-user
19 ];
20 };
21
22 den.aspects.bob = {
23 includes = [
24 den.provides.define-user
25 ];
26 };
27
28 den.aspects.igloo = {
29 includes = with den.aspects; [
30 base-host
31 desktop-host
32 ];
33
34 provides.to-users = {
35 includes = with den.aspects; [
36 base-user
37 desktop-user
38 ];
39 };
40
41 nixos = {
42 boot.loader.grub.enable = false;
43 fileSystems."/".device = "/dev/null";
44 };
45 };
46
47 den.aspects.base-host = {
48 nixos =
49 { pkgs, ... }:
50 {
51 boot.kernelPackages = pkgs.linuxPackages_latest;
52 system.stateVersion = "25.11";
53 };
54 };
55
56 den.aspects.base-user = {
57 homeManager = {
58 home.stateVersion = "25.11";
59 };
60 };
61
62 den.aspects.desktop-host = {
63 nixos = {
64 services.desktopManager.plasma6.enable = true;
65 };
66 };
67
68 den.aspects.desktop-user = {
69 homeManager = {
70 programs.firefox.enable = true;
71 };
72 };
73}
While this definitely solves the evaluation error, it defeats the primary goal of aspect-oriented configuration.
We no longer have a single, cohesive atomic cross-cutting feature. We have returned to rigidly separated data silos, making the model more verbose and arguably less intuitive, potentially making the use of a framework less worthwhile.
The Contextual Meaning of Features
This reveals an underlying truth: “Features” do not exist in a vacuum. In a multi-user environment, base or desktop
are not simple boolean toggles. Their meaning mutates depending on the bounded context they evaluate in.
As soon as a host has more than one user or another similar pattern, “including an aspect/feature” ceases to be a simple import. It becomes a domain-modelling problem.
Accidental vs. Essential Complexity
In his paper (10.1109/MC.1987.1663532) No Silver Bullet, Fred Brooks divides software engineering difficulties into Accidental Complexity (clunky syntax, boilerplate) and Essential Complexity (the inherent difficulty of the problem domain).
Frameworks like Den brilliantly solve accidental complexity. They provide:
- Composition primitives: aspect inclusion, context transformations, and mutual providers
- Context-aware dispatch: allowing you to route configurations based on user roles, host types, or any custom context stage
- Modular organisation and deduplication patterns: defining features once and reusing them across contexts
But no framework can solve the essential complexity of policy:
- Who fundamentally owns a feature ?
- How should a host-level capability cascade to multiple heterogeneous users ?
- Where do exceptions live ?
These are architectural decisions. They depend entirely on your specific infrastructure use case, your team constraints, and your security posture. A framework cannot infer the “right” propagation rule because a universal rule does not exist (yet ?).
A Note About Den
I want to be explicitly clear: this post is not a criticism of Den, nor of Victor Borja, its author.
Victor has been consistently responsive, helpful, and genuinely friendly throughout my experiments. His work on Den is incredibly solid, and I appreciate his dedication enough to sponsor him on GitHub. (He is also the author of
vic/import-tree, which powers all my projects by default).
My goal here is to highlight a fundamental class of modelling constraints that any framework will inevitably encounter
when reconciling host-level (nixos) and user-level (homeManager) boundaries at scale.
Practical Consequences
My previous rewrite taught me to pivot from host-first to feature-first. Den confirms this is the right direction and provides superior building blocks to achieve it, for sure 😎 !
At the same time, it made a deeper truth highly visible:
Aspect-oriented composition improves how we express configuration, but it does not eliminate the need to strictly define ownership boundaries between entities.
The hard part of IaC is no longer “how to write this or that module”. The hard part is deciding which entity has the authority to control activation semantics.
To resolve this in practice, engineers usually adopt one of these strategies:
- Splitting Aspects: Separating mixed features into explicit host and user variants (sacrificing cohesion), defeating the primary goal of aspect-oriented configuration.
- Capability Gating: Adding metadata to users to gate user-level activation (adding overhead).
- Role-Driven Forwarding: Building custom context layers to route configurations based on roles.
- Convention over Configuration: Dictating rules like “Hosts include infrastructure features; users include experience features”
All of these are valid. None of them are free. Each strategy forces you to encode policy.
The Real Evaluation Criterion
I started this migration asking: “Which framework should I use ?” I now realise the better question is: “Which framework makes my policy decisions explicit, easily adaptable, and maintainable in the long run ?”
Den scores exceptionally high here. It gives me enough control to model my intent precisely, right down to custom context stages and dispatch strategies. But it is not a silver bullet (No pun intended !). It does not absolve the engineer of the responsibility to define ownership and map out cardinality boundaries.
Conclusion
This rewrite is still in progress, and I still have open questions before merging PR
#124.
The main takeaway so far is a timeless engineering lesson:
- Frameworks have the potential to reduce accidental complexity.
- Frameworks cannot remove essential complexity.
In configuration management, that essential complexity is almost always found at the boundaries: host vs. user, mechanism vs. policy, and cohesion vs. specificity.
I am still exploring the most elegant shape for these boundaries in my own setup. If you have faced similar trade-offs—whether with Den, flake-parts, or another framework: I would love to hear how you modeled them.