Skip to content

From Den

Den and yen share the aspect idea. The difference is where structure comes from. Den gives you a schema: den.hosts, users, homes and context pipes. In yen you declare those kinds yourself, in plain Nix, and every rule of how content moves is a sink you can read.

This page moves Den’s default template to yen. The docs evaluate both and check that the host gets the same account, groups, home, packages and state versions.

Denyen
den.hosts.x86_64-linux.igloo.users.tuxinstances: yen.host.igloo, yen.user.tux with hosts = [ … ]
den.aspects.igloo.nixosan aspect with at = yen.host.igloo and a nixos key
den.default.nixosan aspect with at = yen.kinds.host
includes = [ den.batteries.x ]the included aspect’s at names the includer, from any file
provides.to-users.homeManageran aspect at the users within the host
hasAspectyen.lib.holds, and r.graph edges with origin
context pipes, osConfigargs: a kind, a sink or out (the built flake)
den.schema.*yen.kinds.<kind>.fields
before/modules/den.nix
{ inputs, lib, ... }:
{
imports = [ inputs.den.flakeModule ];
den.schema.user.classes = lib.mkDefault [ "homeManager" ];
den.default.nixos.system.stateVersion = "25.05";
den.default.homeManager.home.stateVersion = "25.05";
den.hosts.x86_64-linux.igloo.users.tux = { };
}

Den’s schema is built in. In yen, fleet.nix says the same in one short file: hosts build NixOS, users live under their hosts, a user’s homeManager content goes into the host, and home-manager is active where a user lives.

before/modules/igloo.nix
{
den.aspects.igloo = {
nixos =
{ pkgs, ... }:
{
environment.systemPackages = [ pkgs.hello ];
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "/dev/fake";
fsType = "auto";
};
};
provides.to-users.homeManager =
{ pkgs, ... }:
{
home.packages = [ pkgs.vim ];
};
};
}
before/modules/tux.nix
{ den, ... }:
{
den.aspects.tux = {
includes = [
den.batteries.define-user
den.batteries.primary-user
];
homeManager =
{ pkgs, ... }:
{
home.packages = [ pkgs.htop ];
};
};
}

A battery is an aspect that you include. In yen you write the aspect once, without at, and each user of it adds itself to at. at from several files is a union.

modules/batteries/define-user.nix
# den.batteries.define-user: an aspect. Who includes it is its `at`, from any file.
{ yen, ... }:
{
yen.aspects.define-user = {
nixos =
{ user, ... }:
{
users.users.${user.name} = {
name = user.name;
home = "/home/${user.name}";
isNormalUser = true;
};
};
homeManager =
{ user, ... }:
{
home.username = user.name;
home.homeDirectory = "/home/${user.name}";
};
};
}

In Den, a quirk is structured data that aspects emit on a key registered in den.quirks, and pipes are policy effects that route it: pipe.expose up to the host, pipe.collect across hosts, pipe.filter, pipe.as. By default a quirk stays in its own scope. From Den’s docs:

den.quirks.firewall = { description = "Firewall port declarations"; };
den.aspects.nginx = { firewall = { ports = [ 80 443 ]; }; };
den.aspects.networking.nixos = { firewall, lib, ... }: {
networking.firewall.allowedTCPPorts = lib.concatMap (f: f.ports or []) firewall;
};

In yen a quirk is a sink. Where its values land is the tree: an element goes up to the nearest scope that has the sink. A user’s ports reach its host with no policy, which is Den’s pipe.expose. entries keeps who gave each value, so a filter or a rename is a plain function in the reader:

modules/quirks.nix
# Den quirks and pipes, in yen: a quirk is a sink; where it lands is the tree, not a policy.
{ yen, lib, ... }:
{
# den.quirks.firewall: a sink on the host. A user has no `firewall` sink, so a user's ports go up
# to its host by routing: Den's `pipe.expose` is the default here.
yen.kinds.host.sinks.firewall = {
reduce = yen.lib.reduce.entries; # each value with the scope that gave it: provenance, as in pipe.collect
nixos =
{ firewall, ... }:
{
networking.firewall.allowedTCPPorts = lib.unique (lib.concatMap (e: e.value.ports) firewall);
};
};
# producers do not know the consumer: they give `firewall` elements
yen.aspects.ssh = {
at = yen.kinds.host;
firewall.ports = [ 22 ];
};
yen.aspects.tux-dev-server = {
at = yen.user.tux;
firewall.ports = [ 3000 ]; # from a user, lands on each of its hosts
};
# pipe.filter / pipe.as: a plain reader. Here: the ports that users opened, for a report
yen.kinds.host.sinks.firewall.out =
{ host, firewall, ... }:
{
userPorts.${host.name} = lib.concatMap (e: e.value.ports) (
lib.filter (e: e.from.kind == "user") firewall
);
};
}
Denyen
den.quirks.<q>a sink <q> on the kind that consumes it
emit on a quirk keyan aspect key with the sink’s name
consume with { firewall, ... }:the same: a sink is an arg
pipe.expose (user to host)routing: the user has no such sink, its host has one
pipe.collect, pipe.broadcasta sink at a common ancestor (the fleet), read by each host
pipe.filter, pipe.asa function over the reduced value, with entries for provenance
a wall: a value that must not leave its scopea sink with no keys

A Den policy is a function from context to effects, such as host-to-users that resolves each user under its host. The policy graph (flake, system, host, user, home) is Den’s structure. In yen you write that structure as spawns: yen.kinds.host.spawns.user in fleet.nix above is Den’s host-to-users. Conditional policies are a spawns that reads data or yen.lib.holds. Include and exclude effects are at, and not.

Contribute Community Sponsor