You Need a Declarative and Project-Scoped Development Environment

  • 6 min read

Two of the most important qualities of a good development environment are being declarative and project-scoped.

What Does "Declarative" Mean?

Many developers who haven’t fully adopted a declarative setup might think it means listing required command-line tools in a file that a provisioning tool installs for you.

But that’s only part of the story. Consider the following questions:

Being truly declarative means having a single source of truth for all the tools you need — including their exact versions and configurations — and relying on the provisioning tool to install, configure, and remove them. Regardless of your current system state, it should bring everything into alignment with your specification in as few steps as possible.

Why Be Declarative?

First and foremost, it makes the development environment reproducible. Another developer can use your configuration to provision their system and end up with the exact same tools, eliminating an entire class of "works on my machine" bugs.

It also makes setting up a new machine incredibly fast. Whether you're reinstalling your OS or switching laptops, running just a few commands gets you back to a fully functional dev environment. It's like having a backup — not of your data, but of your entire working context.

But the real magic happens when this declarative setup is also project-scoped.

What Does "Project-Scoped" Mean?

A project-scoped development environment is one of those things that, once you have tried it, you can't live without. The concept is simple: whenever you're "in" a project — meaning you've entered the folder in a terminal or opened it in an editor — only the tools that project needs become available. When you switch projects, your environment automatically updates to reflect the new set of dependencies.

For example:

  1. Enter ~/website. Running node --version returns v22.15.1; python3 fails with "command not found".
  2. Enter ~/llm. node --version now returns v24.1.0; python3 --version returns Python 3.9.6
  3. Exit to your home directory, and those tools become available (optional, but beneficial. We will cover it later).

There's no need to read documentation or install anything manually. Each project declaratively declares its dependencies, and the environment adapts automatically.

So how do you make this happen?

Nix as a Solution

Nix is built exactly for this. It's designed from the ground up to be declarative.

Nix installs each package into a unique path like:

/nix/store/rs83v3ivkadsk9p1wk9qrzr3af26x829-coreutils-9.6

where rs83v... is a unique identifier capturing all inputs of this package: the package's own source code, dependencies and build instructions. When it builds a package, the build environment will, as the official docs say: "only find resources that have been declared explicitly as dependencies. There’s no way it can build until everything it needs has been correctly declared. If it builds, you will know you’ve provided a complete dependency list."

This design is a lot like Git commits, and it has powerful implications:

You can use Nix as a standalone package manager, but the canonical way is to use it through NixOS. NixOS makes the whole operating system declaratively configurable, and in turns makes it possible to install packages declaratively. Refer to the official manual on how to install NixOS. For macOS, check out nix-darwin. For Windows, checkout NixOS-WSL.

Once Nix id set up, your system configuration lives in configuration.nix. Let's examine how to install tools with Nix declaratively.

Global Tools

Some tools, like Git, are useful across all projects. With Nix, making them globally available is as easy as using the following configuration:

{
  environment.systemPackages = with pkgs; [
    git
  ];
}

The configuration is written in the Nix language. Its syntax is really simple. You can probably learn it in under 20 minutes, though mastering the language takes longer.

Project-Specific Tools

In order for the the development environment to update automatically for projects, you'll need nix-direnv. Thanks to Nix, setting it up only requires a couple lines of configuration:

{
  environment.systemPackages = with pkgs; [
    git
  ];
+ programs.direnv = {
+    enable = true;
+    nix-direnv.enable = true;
+ };
}

Refer to nix-direnv's manual on how to write the configuration file for a project. TLDR: it's either a flake.nix or shell.nix file inside the project folder, and the configuration is typically something like (with boilerplate code omitted):

{
  devShell = with pkgs; mkShell {
    packages = [
      nodejs_24
      python313
    ];
  };
}

Advantages of Nix

There are many benefits with this Nix setup, especially when keeping global tools minimal:

Disadvantages of Nix

A lot of Nix users have a love-hate relationship with Nix. Nix is known to have a steep learning curve. Many Nix users tend to copy and paste Nix configurations from the internet and feel stuck once anything stops working. It's not entirely their fault.

Nix's documentation's currently quite fragmented. There exist multiple official documentation sites, all overlapping with each other. Yet ironically, how to package software is actually not well covered. A lot of times, you need to dive into the source code of an existing package to understand how its done, and it's not always applicable.

The Nix language is also not a simple language. It's functional and lazily evaluated. A lot of people who are not familiar with functional programming might struggle to get used it. And the situation is exacerbated by Nix's standard library, which is integrated within the official package repository nixpkgs. The library is quite complex but not thoroughly documented.

Finally, Nix is currently in the middle of transitioning to flakes, a new way of using Nix. This feature has been marked experimental for many years, but a lot users have switched to it. This creates a lot of confusions for new comers, especially when reading tutorials or community posts that mix old and new styles.

With that said, these drawbacks are most non-technical and can improve over time. The benefits still outweighs.

Other Solutions

There are many other tools exist that can do provisioning, but hardly any of them tick all boxes. Docker is a string contender. It however has a few drawbacks with regard to setting up a development environment:

Conclusion

In this article, I’ve outlined what I believe makes for an ideal development environment: one that is declarative, project-scoped, and ultimately reproducible. Nix enables all of these qualities with a unique approach that eliminates configuration drift, streamlines onboarding, and simplifies environment management across machines.

While Nix has its rough edges — especially around documentation and learning curve — its power and flexibility are unmatched once you get comfortable with its model. For developers who value automation, precision, and reproducibility, investing in Nix is well worth the effort.