Refactoring Dotfiles into a Dendritic Layout

12 min read

I recently reorganized my dotfiles repo into a Dendritic-style layout. The interesting part was not the rename spree. It was the change in what the repository means when you open it.

Before, the repo was mostly shaped around hosts plus a big configs/ tree. That worked for a long time. But as the flake grew across NixOS, nix-darwin, Home Manager, custom packages, patches, and a pile of shared service logic, the old structure started making more and more questions annoying to answer.

Where does this behavior belong?

Is this reusable or host-local?

Should a new machine copy that chunk from another host, or import something more general?

Why does this thing live under configs/ if it is clearly not just “configuration” in the narrow sense?

The Dendritic refactor improved a lot of that. It also introduced some real costs. This post is not “everyone should organize dotfiles this way.” It is a higher-level look at what got clearer, what setting up a new host looks like now, and why the clearer architecture also buys you a certain amount of boilerplate and indirection.

What Dendritic means here

Dendritic is not a framework. It is not a new library. It is a repo organization pattern built around flake-parts-style modules and feature-oriented composition.

The basic idea is that you stop treating your repository primarily as:

  • a set of hosts
  • plus a bag of helper files

and start treating it as:

  • a set of reusable aspects and services
  • grouped by whether they are public, private, or inventory data
  • with hosts acting more like manifests that opt into those pieces

That distinction matters because it changes the default question from:

“Which host owns this logic?”

to:

“Which feature or aspect should define this logic?”

For a small repo, that can be overkill. For a repo that has crossed into multi-host, multi-platform, partly-public, partly-private territory, that question is often the more useful one.

Old shape versus new shape

The old repo had a familiar feel:

.
├── flake.nix
├── hosts/
│   ├── goose/
│   ├── ij-desktop/
│   ├── khosu/
│   └── ...
└── configs/
    ├── programs/
    ├── profiles/
    ├── users/
    ├── server.nix
    ├── network.nix
    ├── secrets.nix
    └── ...

That is a perfectly reasonable place to start. Hosts are visible. Shared things exist. Nothing feels especially abstract.

The problem is that configs/ becomes a junk drawer. Languages, user defaults, deployment helpers, shared nix settings, patches, service building blocks, and private inventory all end up adjacent even though they are not really the same kind of thing.

The new layout is more opinionated:

.
├── flake.nix
├── hosts/
│   ├── goose/
│   ├── ij-desktop/
│   ├── khosu/
│   └── ...
└── modules/
    ├── community/
    │   ├── home/
    │   ├── nixos/
    │   ├── darwin/
    │   ├── lib/
    │   ├── packages/
    │   └── patches/
    └── private/
        ├── inventory/
        ├── nixos/
        ├── darwin/
        ├── home/
        └── shared/

This split does two important things immediately.

First, it separates publicly reusable modules from repo-private composition. Second, it gives inventory data its own home instead of letting it blur into general-purpose modules.

That alone made the repo easier to read.

What got clearer

The biggest gain was not “more reuse,” although there is more reuse. The biggest gain was clarity of intent.

In the new tree, directories and file names communicate purpose more directly:

  • modules/community means reusable surface area I would feel good exporting
  • modules/private means repo-specific composition I want to reuse internally
  • modules/private/inventory means facts about this fleet, not abstract modules

That is much better than a flat configs/ namespace where everything looks like just another config file.

It also made several kinds of questions easier to answer.

Easier question: is this public, private, or inventory?

This used to be fuzzy. A helper might be generic in spirit but buried next to host-specific assumptions. A user registry might live near modules that actually describe behavior. Network facts might be imported like they were just another feature.

Now the repo answers that more directly:

  • public reusable composition goes in modules/community
  • private reusable composition goes in modules/private
  • shared facts about hosts and users go in modules/private/inventory

That makes maintenance easier because the placement itself carries architectural meaning.

If a file lands in the wrong layer, it feels wrong immediately.

Easier question: where should a new behavior go?

Under the old layout, the easiest move was often to open the closest host file and add more logic there.

That works until it doesn’t. Host files slowly become half manifest, half implementation, and then you start copying chunks between them because some other host wants “basically the same thing.”

With the new structure, the default move is more often:

  1. decide whether the behavior is public, private, or inventory
  2. decide whether it is a service, aspect, profile, shared helper, package, or patch
  3. make the host opt into it

That is a more structured workflow. It creates a little more friction up front, but it produces cleaner boundaries over time.

Easier question: what does this host actually do?

One of the most noticeable improvements is that host files got thinner.

A host like goose now reads more like a statement of composition:

imports = [
  modules.public.nixos.aspects.serverBase
  (import modules.private.nixos.aspects.managedRemoteHost {
    host = "goose";
    sopsFile = ../../secrets/goose.yaml;
  })
  modules.private.nixos.aspects.gooseServices
  modules.public.nixos.services.smsGatewayClient
  (import modules.public.nixos.services.wanFailover { ... })
  ./hardware-configuration.nix
  ./networking.nix
];

That is a nicer top-level read than a long host file that mixes identity, hardware, service definitions, package choices, firewall details, and helper logic all in one place.

You can look at the host and quickly understand its shape:

  • base server behavior
  • remote-host management behavior
  • host-specific service bundle
  • explicit shared services
  • hardware and networking

That is a good trade. The host becomes easier to scan because implementation has moved somewhere named.

Easier question: how do I set up a new host now?

This is where the new layout feels most obviously different.

Before, adding a new host often meant copying an existing host, then editing it until it fit. You could do that fast, but it encouraged silent inheritance by copy-paste. The host would work, but the structure would not necessarily tell you what was reusable and what was accidental.

Now the setup path is more deliberate.

At a high level, adding a host means:

  1. create the host directory
  2. decide which reusable aspects apply
  3. decide what inventory entries it needs
  4. add only the genuinely host-local configuration in the host file

That feels a bit more like architecture and a bit less like cloning a previous machine.

For example, a new server host does not need to rediscover how to be “a server” from scratch. It can import a named base aspect. A workstation does not need to inline shared Nix CLI defaults, shared shell behavior, or local flake deployment setup if those already exist as named pieces.

This is the part I like most: new hosts are less about copying a specimen and more about selecting building blocks.

Over time, some things become much easier to understand

This is the main payoff.

When the repository grows, understanding is less about reading every host and more about understanding the vocabulary of the repo:

  • what an aspect means
  • what belongs in a service module versus a profile
  • where inventory lives
  • which pieces are intended to be shared across NixOS, Darwin, Home Manager, or packages

Once that vocabulary stabilizes, the codebase gets easier to navigate because concepts have homes.

You stop finding the same kind of logic in three or four different places under slightly different names. You stop wondering whether a host-local tweak is actually the canonical implementation of a broader idea. You stop treating the flake.nix file as a manual import spreadsheet.

That last point matters more than it sounds. A smaller, more manifest-like flake.nix is not just aesthetically nicer. It reduces import churn. It reduces the feeling that every new module requires touching central wiring by hand. And it helps the flake describe the repo instead of micromanaging it.

Cross-platform sharing got cleaner too

One reason the old structure was getting strained is that the repo is not just NixOS hosts.

It spans:

  • NixOS
  • nix-darwin
  • Home Manager
  • helper libraries
  • custom packages
  • patches

Those are different configuration classes, but they often want to express the same feature-level concern. A workstation baseline is not only a NixOS thing. A deploy helper is not only a host concern. Shared development ergonomics may affect Home Manager and system modules together.

The Dendritic layout gives those things more coherent homes. You can keep related ideas near each other even when they cut across configuration classes.

That does not eliminate complexity, but it makes the complexity easier to name.

The tradeoff: you buy clarity with more structure

This is where the cost shows up.

The new layout is clearer in the large, but it is not automatically simpler in the small.

Sometimes the old answer to “where do I put this?” was just “put it in the host.” That is crude, but it is low-friction. The new answer may involve:

  • creating a new aspect
  • giving it a good name
  • deciding whether it is public or private
  • importing it in the right place
  • possibly adding inventory support

That is more boilerplate.

Not absurd boilerplate, but real boilerplate. You are making more small files. You are adding more named composition points. You are spending more effort on repo shape.

For a repo at the right scale, that cost is worth paying. For a smaller repo, it can feel like architecture cosplay.

Indirection is the other real cost

Thin hosts are nice to read, but they move behavior elsewhere. That means tracing behavior can become harder.

If you are new to the repo and you open a host file, you might see clean imports and named aspects, but not the full behavior. To understand what the machine actually does, you have to follow the graph:

  • from host
  • to aspect
  • to service tree
  • to shared helpers
  • maybe to inventory data

That is a better model once you know the repo. It is not always a better first impression.

So the trade is basically this:

  • host-centric layout gives you more immediate locality
  • dendritic layout gives you better long-term semantic organization

There is no free lunch there.

Naming becomes architecture

This is one of the more interesting side effects.

When the repo is built around aspects and module trees, names matter more. A weak name creates confusion far beyond one file. A good name becomes a durable concept that helps the rest of the repo make sense.

That means the refactor shifts some of the burden from file placement to naming quality.

If an aspect is called something vague like common or defaults, it does not help much. If it is named for a clear responsibility like serverBase, managedRemoteHost, or workstationSecrets, then the host composition starts reading like intent instead of implementation trivia.

This is good architecture pressure, but it is still pressure. You have to keep the vocabulary disciplined.

Newcomers may feel more friction

This structure is not as immediately obvious as “there are some hosts and a configs directory.”

A newcomer now has to learn:

  • the community/private/inventory split
  • what counts as an aspect
  • where shared behavior is expected to live
  • which modules are meant to be reused and which are just internal glue

That is a steeper conceptual ramp.

The payoff is that once they learn it, the repo is more coherent. But the up-front learning cost is real, and I would not pretend otherwise.

Auto-imported module trees make this even more true. They keep the flake smaller and reduce manual wiring, which I like, but they also make the repo feel a little more magical. Explicit imports are noisier, but they are easier to explain in one sentence.

Again: tradeoff, not free win.

Verification matters more during refactors like this

Even though I am not focusing this post on the migration itself, one lesson from the refactor is worth calling out because it connects directly to the structure question.

This style of reorganization makes semantic equivalence easy to intend and easy to get slightly wrong.

Most of the repo kept the same normalized configuration shape after the rewrite, which is exactly what I wanted. But careful comparison still caught a couple of genuine drifts:

  • ij-desktop briefly lost nix-command flakes
  • shared node-exporter factoring briefly implied port 9100 in a way that needed tightening back up

Those are not catastrophic bugs. They are exactly the kind of subtle drift you get when you move behavior into reusable layers.

That does not argue against the layout. It argues for stronger verification when you reorganize into this style. The more you rely on reusable composition, the more you need confidence that the composition still evaluates to what you think it does.

So was it worth it?

For this repo, yes.

Not because Dendritic is universally superior. Not because host-centric repos are wrong. Not because more module trees automatically mean better architecture.

It was worth it because this flake had already crossed the point where host-local logic, private inventory, reusable modules, public exports, and cross-platform sharing were all competing inside one old layout. The repo wanted stronger boundaries, and the Dendritic split gave it those.

What I gained was mostly clarity:

  • clearer separation between public, private, and inventory concerns
  • thinner hosts that read more like manifests
  • easier reuse across NixOS, Darwin, Home Manager, packages, patches, and helpers
  • better path semantics and less junk-drawer ambiguity
  • a smaller, more declarative flake.nix

What I paid was also real:

  • more conceptual overhead
  • more indirection
  • more naming burden
  • more boilerplate around introducing new concepts cleanly

That feels like the honest summary.

If your repo is still basically one or two machines with a few shared modules, I would not rush into this. A straightforward host-centric tree may still be the right tool. But if your flake is starting to feel like a mixed pile of hosts, helpers, secrets, service trees, and cross-platform behavior, then reorganizing around clearer module layers can buy back a lot of understanding.

Just do not mistake that for simplicity.

It is a trade from one kind of complexity to another. In this case, I think it was the right trade.