Skip to content

Class Modules

Den aspects can be written as functions that receive context arguments ({ host }, { host, user }, etc.). The two-layer form works and is perfectly valid:

# Two-layer form
{ host }: {
nixos = { config, pkgs, ... }: {
networking.hostName = host.name;
};
}

As a convenience, you can also write class modules in flat form, mixing context args and module-system args in a single function:

# Flat form -- context args and module-system args side by side
{
nixos = { host, config, pkgs, ... }: {
networking.hostName = host.name;
};
}

The pipeline inspects builtins.functionArgs, identifies which args exist in the current context, pre-applies those values, and advertises only the remaining args to the module system via lib.setFunctionArgs. The module never knows it was wrapped.

  1. builtins.functionArgs module returns the full set of declared args.
  2. Each arg name is checked against the current context: a key is a context arg if it exists in the pipeline state.
  3. Context args are pre-applied. The remaining args are advertised to the module system.
  4. At call time, the wrapper merges the module-system args with the pre-applied context args and calls the original function.

A static aspect whose nixos class module requests host directly:

{
den.aspects.my-aspect = {
nixos = { host, config, ... }: {
networking.hostName = host.name;
};
};
}

Request both host and user when a policy provides both in context:

{
nixos = { host, user, config, ... }: {
users.users.${user.name}.description = "${host.name}/${user.name}";
};
}

Both home and user come from context:

{
homeManager = { user, config, ... }: {
home.username = user.name;
};
}

When all function args are context args (no module-system args at all), the function is called directly and its return value becomes the class module. This handles the pattern where a function returns another function:

{
nixos = { host }: { config, ... }: {
networking.hostName = host.name;
};
}

Here { host } has no ... and no module-system args. The pipeline calls it with { host = ctx.host; } and uses the returned function as the class module.

No outer parametric wrapper needed — context flows through the pipeline’s scope handlers:

{
den.aspects.static-networking = {
nixos = { host, config, ... }: {
networking.hostName = host.name;
};
};
}

The NixOS module system passes extra args (config, options, lib, pkgs, modulesPath, and anything from specialArgs or _module.args) to every module function. Without ..., the module system’s extra args cause an eval error. This is already conventional for NixOS modules, but worth noting since the flat form makes it easy to forget.

The exception is full application — if every arg is a context arg (e.g., { host }:), the function is called directly by Den and never reaches the module system, so ... is not needed.

Note that for aspect-level context functions (the outer wrapper), ... is not needed and should be omitted — these receive only the context args they declare.

A collision occurs when den wants to inject an arg (e.g., host) but the module system also provides a value for the same name — via specialArgs or _module.args. By default, this throws an error to force an explicit resolution.

Collision strategy is resolved from three levels (first match wins):

LevelWhere to setScope
Aspectmeta.collisionPolicy on the aspectAll class modules in that aspect
EntitycollisionPolicy on the entity schema (den.schema.host)All uses of that entity kind
Globalden.config.classModuleCollisionPolicyEntire flake (default: "error")
ValueBehavior
"error"Throw an eval error (default)
"den-wins"Den value takes precedence; lib.warn emitted
"class-wins"Module-system value takes precedence; lib.warn emitted
# Aspect level
den.aspects.my-aspect.meta.collisionPolicy = "den-wins";
# Entity level (all hosts)
den.schema.host.collisionPolicy = "den-wins";
# Global
den.config.classModuleCollisionPolicy = "den-wins";

When a class module requires an entity arg that isn’t in the current context (e.g., { user, config, ... } at a host scope where no user exists), the module is silently skipped. A trace message is emitted but no error is raised.

This is by design — it lets you write a single aspect with class modules that target different scopes:

den.aspects.my-aspect = {
# Applied at host scope (host is always present)
nixos = { host, config, ... }: {
networking.hostName = host.name;
};
# Applied only at user scope (skipped at host scope)
homeManager = { user, config, ... }: {
home.username = user.userName;
};
};

If your class module isn’t taking effect, check whether all its entity args are in context at the scope where it’s being resolved.

Contribute Community Sponsor