ADR-009: Thin Orchestrator Pattern for Service Stacks¶
Status: Accepted Date: 2025-12-09 Context: Module design for multi-service stacks
Context¶
Some services work together as cohesive stacks:
- Observability: Loki + Promtail + Grafana + Prometheus
- Media automation: Sonarr + Radarr + SABnzbd + Plex
The question is how to organize the NixOS modules for these stacks.
The "God Module" Anti-Pattern¶
An early approach was to create meta-modules that re-expose all options from underlying services:
# BAD: God module that proxies everything
options.modules.services.observability = {
loki = {
port = mkOption { ... };
retentionDays = mkOption { ... };
storagePath = mkOption { ... };
# ... 50 more options copied from loki module
};
grafana = {
port = mkOption { ... };
oidc = mkOption { ... };
# ... 100 more options copied from grafana module
};
};
This approach has significant problems:
- Dual maintenance: Options defined in two places
- Documentation drift: Meta-module docs diverge from underlying modules
- Type synchronization: Changes in underlying module break meta-module
- Obscures direct usage: Users forget they can configure services directly
Decision¶
Use a "thin orchestrator" pattern that only provides cross-cutting concerns, not option re-exposure.
Thin Orchestrator Responsibilities¶
- Master enable toggle - Turn the whole stack on/off
- Component toggles - Enable/disable individual services
- Cross-cutting wiring - Connections between services (Promtail → Loki URL)
- Stack-level concerns - Auto-discovery, shared alerts
What Thin Orchestrators Do NOT Do¶
- ❌ Re-expose individual service options
- ❌ Create wrapper types for underlying options
- ❌ Document options that belong to underlying modules
Consequences¶
Positive¶
- No option duplication: Options defined once in service modules
- Clear separation: Orchestrator handles wiring, services handle config
- Smaller code: Observability orchestrator is ~190 lines (vs 876 god-module)
- Direct customization: Users configure services directly when needed
- Easier maintenance: Changes to Loki don't break orchestrator
Negative¶
- Two-level configuration: Enable via orchestrator, customize via service
- Discovery challenge: Users must know they can configure services directly
- Wiring complexity: Cross-service dependencies explicitly managed
Mitigations¶
- Document the pattern clearly in README
- Provide examples showing orchestrator + direct configuration
- Use
mkDefaultfor orchestrator-provided values (user overridable)
Implementation¶
Thin Orchestrator Structure¶
{ config, lib, ... }:
let
cfg = config.modules.services.observability;
in
{
options.modules.services.observability = {
enable = lib.mkEnableOption "observability stack";
# Component toggles ONLY - no option re-exposure
loki.enable = lib.mkOption { type = lib.types.bool; default = cfg.enable; };
promtail.enable = lib.mkOption { type = lib.types.bool; default = cfg.enable; };
grafana.enable = lib.mkOption { type = lib.types.bool; default = cfg.enable; };
prometheus.enable = lib.mkOption { type = lib.types.bool; default = false; };
# Stack-level concerns only
autoDiscovery.enable = lib.mkOption { type = lib.types.bool; default = true; };
};
config = lib.mkIf cfg.enable {
# Enable services - they configure themselves
modules.services.loki.enable = cfg.loki.enable;
modules.services.promtail.enable = cfg.promtail.enable;
modules.services.grafana.enable = cfg.grafana.enable;
# Cross-cutting wiring
modules.services.promtail.lokiUrl = lib.mkIf (cfg.promtail.enable && cfg.loki.enable)
"http://127.0.0.1:${toString config.modules.services.loki.port}";
# Auto-configure Grafana datasources
modules.services.grafana.autoConfigure = {
loki = lib.mkDefault cfg.loki.enable;
prometheus = lib.mkDefault cfg.prometheus.enable;
};
};
}
Host-Level Usage¶
# Enable stack with thin orchestrator
modules.services.observability.enable = true;
# Customize individual services DIRECTLY (not through orchestrator)
modules.services.loki.retention = 30;
modules.services.grafana.oidc = { ... };
modules.services.promtail.extraScrapeConfigs = [ ... ];
When to Use Thin Orchestrators¶
✅ Use when:
- Multiple services form a logical stack
- Services need wiring between each other
- You want a simple "enable the whole stack" toggle
- Cross-cutting concerns need coordination
❌ Don't use when:
- Services are independent
- No cross-service wiring needed
- Simple host-level config suffices
Reference Implementation¶
- Observability Stack:
modules/nixos/services/observability/default.nix - ~190 lines (vs 876 in god-module version)
- Enables: Loki, Promtail, Grafana, optionally Prometheus
- Wires: Promtail → Loki, Grafana datasources
- Provides: Auto-discovery of metrics endpoints