the cabin

Would you call this function?

Preface

Recently, I designed and implemented the foundational blocks of a system at my company, inspired from my personal version of the system, with the help of a small team of coworkers. Although I laid the groundwork, most of the code that runs today is not written by me anymore and it's diverged from my personal version, especially with regards to softwarre design in a way that I find degrading to the long-term quality and maintainability of the system. I have a difficult time expressing this, partly because I'm not a very confrontational person plus I don't want to be a prick about controlling every minute detail on how the project should look like, partly because restructuring code for higher code quality doesn't align with corporate interests, and partly because I don't even know how to precisely codify my own standards.

I've written a lot of software in various languages, various frameworks, and various subjects. I don't claim to be an expert in everything that I write, but I believe that I've written enough software in a wide enough area that I've developed an aesthetic for good software. I feel out good software from bad software even if I'm not able to explain why, and I have strong reason to trust these instincts because following them has generally lead me to create more maintainable and well-written software over the course of thousands of hours. Yet, if I'm unable to express this private knowledge, no matter if my instincts are correct or not, I'm locked by myself in a Wittgensteinein box of unintelligibility. So I want to do some exercise to explain some of my practices and standards by starting a series of blog posts documenting software design patterns I've picked up. In doing so, I hope to free myself of long-held dogmas and gain insight into the limits of my beliefs.

Before I do that, I want to issue a disclaimer that I'm suspicious of general software design principles. There is a time and a place for everything, even the most hackiest code. In fact, I despised the university course I took on software design. "Clean code"? "SOLID principles"? "Liskov substitution principle"? To hell with that. I'll be the judge of what works and what doesn't. Moreover, I'm no stranger to hacking my way around programs. If I need to do some dirty patch of an upstream library to get something working, I'll do it. Or if I need to fix an urgent and otherwise hard-to-resolve bug on production, of course I'll throw out these stupid principles.

Yet, I find it hard to deny the recurring and overarching themes I find myself running into when designing software. I don't claim that my principles are the only way to write good software. My more modest statement is simply that, in my experience, these principles have lead me to write what I regard as better software. Or, an even more modest statement is simply that these principles have prevented my own stupidity. As I see it, this is the ultimate aim of software design.

Kablow! ...Oops

Take a look at this function signature. What do you think the function does?

async function gitClone(repository: string): Promise<void>;

It's pretty clear to me that it will do something along the lines of "spawn a git subprocess and clone a repository". Given the void return, a consumer wouldn't have access to whether the subprocess ran successfully, so I might expect that the function will throw an error if something went wrong in the subprocess. Let's see what the function actually does.

async function gitClone(repository: string): Promise<void> {
  const sql = new SqlConnection(process.env.DATABASE_URL);
  const res = await exec("git", ["clone", repository]);
  await sql.query(`
    insert into git_clones (repository, timestamp, exit_code)
    values (${repository}, now(), ${res.exitCode});
  `);
  if (res.exitCode !== 0) {
    throw new Error("git clone failed");
  }
}

...oh.

Well, we were partially right, but it seems to do a lot more than what we expected and it took us having to open up and read the function to figure this out. I guess... that's fine? I just have to be cautious about every function that this author wrote though. Seeing this, who knows what gitCheckout is doing?

async function gitCheckout(branch: string): Promise<void> {
  const sql = new SqlConnection(process.env.DATABASE_URL);
  const res = await exec("git", ["checkout", branch]);
  await sql.query("truncate table git_clones;");
  if (res.exitCode !== 0) {
    throw new Error("git clone failed");
  }
}

Okay, what the f**k is this? Sure, maybe there is legitimately a good reason why we're truncating git_clones every time we gitCheckout. Maybe there was some incident that necessitated this. But as a consumer of these functions, I've lost all trust! For all I know, calling a function turns the lights off in the next room or enters the nuclear launch codes! I have no way to reason about what anything in the code does anymore without going and opening through every single function and seeing exactly what they do. On a small scale, fine, I can read gitClone and gitCheckout, but on the scale of even a medium-sized codebase, reading through every single line of code is simply not realistic.

Chaff: Don't you even dare...

I know there will be some who would respond to the tune of "oh, but you can just get an LLM to read the lines for you and figure out what it does".

But why would you even need to rely on that in the first place? The synthesizing power of LLMs is not an excuse to write shitty code. It's like poking yourself with a knife and expecting your body to heal the wound.

Taking a step back, let's be charitable and assume we actually do need to interface with the DB for the git operations for whatever reason. My current frustration isn't that we're doing DB queries alongside git operations, it's that we're secretly doing DB queries alongside git operations. There's no way to tell what gitClone and gitCheckout is doing without going in and auditing the code closely because it severely violated our expectations. Poor Alice could call gitCheckout on her first day of her internship, not understanding its full consequences and cause another incident!

Let's propose a minimal redesign.

async function gitClone(
  repository: string,
  databaseUrl: string,
): Promise<void>;

Instead of reading the database URL from within the function by reaching for environment variables, it's now required as a parameter. If consumers so wish, they can read the environment variable themselves and pass it into this function.

It's helpful to think that these simply are the function's true parameters and all we've done is do a better job of surfacing them**.** After all, it's getting the database URL from somewhere. It was just previously hiding it. Sure, I still wouldn't touch this function with a ten-foot pole, but at least I can come to an informed judgement to refrain just by reading the function signature.

So, again, would you call this function?

async function gitClone(
  repository: string,
  databaseUrl: string,
): Promise<void>;

Conclusion

The trap of having implicit global dependencies that can be promiscuously and magically materialized from anywhere in the code – "from thin air" – is one that I see even well-rounded programmers fall into. The example I gave was admittedly contrived but it serves to show the general pattern and the harmful extremes of its nature. In practice, it takes mindfulness about the dependency graph that the program forms as well as careful judgement on the desired topology of the dependency graph to avoid this.

Note that it may, in fact, be desirable to have an implicit global dependency if it should be treated as a primitive to all entities in the relevant domain. For instance, it may be desirable for all entities to have access to a deltaTime() function within a game engine framework so each entity can do time-based physics calculations. When we allow for a global dependency, we're allowing a "depends-on" edge from every single module in the program to that dependency in the dependency graph. If these edges are not obvious, it causes confusion and leads to a broken domain model. Furthermore, stripping these edges or making them explicit is tedious, albeit fairly mechanical.

I feel the need to reiterate my stance on software design before closing off. Materializing hidden dependencies as I described in this post is neither necessary nor sufficient for good software. I'm skeptical "good software" is even well-defined as an objective concept. I simply want to share patterns I've found and I ask that the reader is mature enough to come to their own conclusions, both for this post and upcoming software design posts.