I want to write down how like to deal with projects in software development. These things might be obvious to some, a debatable point for others and blasphemy to many.

Source code

The basic unit of software is a directory of source code. Preferrably, this is git repository, so the code is versioned and can be rolled-back, and worked on concurrently with other team members.

It should not contain binaries for any tools or testing, because any file in here is meant to be understood.

There can be “excluded” sub directories in the file system that contain build artifacts, but they are never checked into source control, so they are not really in the repository.

In the root directory, there should be a README.md, which describes what this project is, how it can be used and how it works.

I prefer the code to be formatted with a code formatter. Code carries semantic informataion, which is unaffected by the exact syntax of the code. I want to write sloppily formatted code and then rely on the formatter to make it pretty and uniform. And exact style does not matter - you will get used to it. It only matters that it is uniform over the whole codebase.

Development shell

Then there is the development shell, by which I mean a collection of tools needed to work with the project. This includes any compilers, interpreters, libraries, language servers, test frameworks or build tools. Traditionally, this part is specified in README.md or CONTRIBING.md as a list of things needed. I prefer a programatic apprach and my current favourite is a Nix Flake. It would look like this:

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/25.05";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs =
    { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
      in
      {
        devShells.default = pkgs.mkShell {
          buildInputs = [
            pkgs.cargo
            pkgs.rustc
            pkgs.clippy
            pkgs.rustfmt

            pkgs.rust-analyzer

            pkgs.just
            pkgs.cargo-nextest
            pkgs.cargo-insta
          ];
        };
      }
    );
}

This example specifies that we need the rust toolchain, rust-analyzer, just and a few testing cargo crates.

That’s right, I include language servers and code formatters in the “dev shell”. They should not be provided by the IDE, but specified by the code directory. That’s because the version of these tools might be important or the team working on this codebase might change which tools are being used (for example, code foramtters).

I’m not set on using Nix flakes, but I do value following properties of dev shells:

  • shell can be built on any machine, regardless of Linux/Mac distribution,
  • shell can be reproduced with exactly same tool versions, now or anytime in the (not so distant) future,
  • shells can easily be modified and updated,

I don’t like depending on any tools from the host machine, even if “everyone has them installed”. Even if everyone has make or jq or grep, I prefer to specify this explictly. This also helps with providing environment in which CI can run.

I very much don’t like adding the tools into the repository directly (looking at you gradlew). If every tool would be doing this, it would be a mess.

I don’t like toolchain managers for one specific language, (such as rustup or nvm), because they cannot deliver all tools needed.

Another thing I like to use is direnv, which loads the dev shell when I cd into the code directory. It reads the .envrc file that contains use flake directive and then invokes nix to load the shell.

Entrypoint

To get started with development easily, I like to have file that describes commands to run when work with the codebase.

Generally, this includes building, testing, linting or formatting.

Traditionally, this was described in README.md as a set of codeblocks that one would copy-paste into the command line. I prefer using just, which is a simplified make that just runs commands. In the root of project, it would have such justfile:

_default:
    just --list

test:
    cargo nextest run --no-fail-fast
    cargo insta review

fmt:
    cargo fmt

generate:
    lutra codegen lutra/ ./src/generated.rs
    cargo fmt -p lutra-bin

pull-request: generate fmt test
    echo 'Ok'

This contains the following recipes:

  • just test, which runs the tests,
  • just fmt, which formats the codebase,
  • just generate, which generates some code,
  • just pull-request, which can run in CI on each pull request,
  • and the default command that just lists all available commands.

It can get more comlicated, but it should cover most of what people are running in day-to-day workflows.

Editors

Notably, I do not count code editors into the “development shell”. That’s because the editor is a very personal choice and cannot be unified over all code contributors.

Also, any config files & directories (.vscode/, .helix/) should be excluded, since developers will probably not want to have exactly the same editor config. There can be templates for language servers, or other useful options one might want to get started with.