Living with your (tech) choices

I've run into a few scenarios recently that have got me thinking about the choices we make in software, and how we have to live with them. Just like in life. A few examples:

Sometimes it's tooling...

Docusaurus project

Recently I led a project where we needed a docs site. I chose Docusaurus for this. I think Docusaurus is awesome—you can have a fully working site in minutes, allowing you to focus on your content. Of course, the tradeoff for this, like all frameworks, is customizability. You probably don't want to retain the default UI, or else we'd have a bunch of boring, identical sites. Docusaurus supports theme customisation in three major ways:

  • You can customise some basic things easily in the config file. To place a navbar item on the left or right, this works. Easy as cake. Even a non-dev can do it.
  • You can override some CSS in the custom.css file. To change the default primary colour of the site, you'll change the --ifm-color-primary CSS variable. Needs a bit more technical knowledge, but still simple.
  • You can override the theme components with yours. This is for when you really need to change the layouts (for instance, reposition the table of contents or search bar). For this, you'll need to write some React and/or CSS.

At a point, we wanted to position some navbar items in the centre. Left or right positioning is simple (the first method), but Docusaurus doesn't support "center". To do that, we would need to override the Navbar component. Understandably, there were some complaints about having to write additional React/CSS. But I don't think it's a problem.

The best frameworks try to make the most common use cases as easy as possible, while still letting you change other stuff. Docusaurus does this better than many alternatives. VuePress doesn't let you pick left or right positions. Hugo and Eleventy don't provide any styling by default. With these frameworks, you know up front that you're writing that HTML and CSS yourself.

So, yes, we're limited by our tools. The tools decide what use cases are "common" enough for them to optimize for. We either get with the plan or work around it. But if the benefits we get from the tool outweigh the added stress in places where we deviate from the plan, it's worth it.

Darklang

I've been following Dark off and on over the past year or so, as a pretty ambitious concept. The founder wrote a brutally honest post on their decision to leave OCaml. It's best to read the original article itself, but here's the summary: OCaml was a cool language that they loved, but as they tried to build more with it, they began to encounter its limits — lack of libraries, poor learning materials, poor tooling, and a focus on type theory over useful products.

They've decided on F# now, and I hope things work out better there.

Sometimes it's design...

Tentacle

Last week, I added site favicons to Tentacle. It was pretty easy, thanks to the awesome blog post, but I faced one major limitation. For this approach to work there needed to be a relation from a user's subscription to the blogs it contained. That is $subscription->blogs should be an array of Blog objects. That way, on the dashboard, I could simply fetch the user's subscription and render each blog with its favicon, something like:

// in the Subscription class:
public function blogs()
{
    // Fetch a subscription, its blogs and favicons in a single SQL query.
    return $this->hasMany(Blog::class)->with('media'); 
}

// in the view:
@foreach($subscription->blogs as $blog)
<img src="{{ $blog->getFirstMediaUrl("favicon") }}"/> {{ $blog->url }}
@endforeach

However, my original database design didn't allow for this. I had broken the rules of data normalisation. I had blogs and subscriptions tables, but rather than having a blog_subscription to link both by ID, I was simply storing the list of blog URLs in the subscription as a JSON array. This meant that $subscription->blogs was always an array of strings (the blog URLs). This made things more difficult, especially as my database had subpar support for JSON queries, so I was not going to be able to achieve my "single query" goal, and might even need an extra SQL query for each blog!

I did find a workaround, eventually. I added a virtual attribute to the subscription that would fetch the blog objects based on the simple blogs URL array. That way I could get the media at once, too. It was an extra query, but just one.

// in the Subscription class:
public function getBlogsDetailsAttribute()
{
    return Blog::whereIn("url", $this->blogs)->with('media')->get();
}

// in the view:
@foreach($subscription->blogs_details as $blog)
<img src="{{ $blog->getFirstMediaUrl("favicon") }}"/> {{ $blog->url }}
@endforeach

A simple 1-hour task ended up taking several hours due to a design choice. But that doesn't mean the design was bad. It just wasn't optimised for this use case. The design works perfectly for the se case it was designed for: retrieving subscriptions and the included blogs without needing to join across three tables. I'd only consider changing the design if more needs like this continue to come up in the future.

Deno

I've been digging through the Deno GitHub issues lately and learnt lots of interesting things.

The cool thing about starting a "do-over" project like Deno is that you get to design things in what you think is the "correct" way. You can look at your predecessors (Node/NPM in this case) and decide to avoid their mistakes. You aren't bound by their historical problems. For instance, in Deno, the CLI args array (Deno.args) holds only the actual script arguments, unlike Node.js and older platforms where argv often includes the script path and executable path, and you have to do argv.slice(2) to get to the actual script arguments.

Deno also saw how NPM packages often got compromised and had unfettered access to your system, so it came up with an explicit permissions model: to allow a script to access a file, you need to pass --allow-read; to allow network access, --allow-net, and so on. They also didn't want a single point of failure for everyone (if NPM goes down, everyone is affected), so they went with URL imports — no package.json or npm install; just import the file in your code directly from the URL.

Deno also comes with first-class TypeScript support, and some of it is written in TypeScript, so you can have static type safety built-in.

I think all of these are great! But there have been downsides as a result of these decisions:

  • With URL imports, you go from trusting one person (NPM) to trusting multiple people, who can change what a URL points to and serve malicious code. Some sort of verification is needed. And so Deno had to add lockfiles.
  • To allow a script to run programs, you need to pass --allow-run. But with that permission, the script could, unbeknownst to you, run other scripts (and even itself) with higher permissions. There isn't an easy answer for this.
  • Direct URL imports mean managing dependencies is the user's problem. For example, versioning — every time you want to upgrade a dependency, you must find all files referencing that URL and change the version number. Deno introduced the deps.ts convention and import maps to help with this, but those come with their issues.
  • Deno had to switch their internal TypeScript code to JavaScript, because of compile-time and runtime performance problems.

In summary...

Working on a new thing is awesome. It involves a lot of choices, such as in tooling and design. These choices make and break us. So make choices carefully, but don't be scared. Every piece of software — even the popular, succesful ones — is limited by the choices the creators made, and they have to live with it.

Understand your tradeoffs, and acknowledge that they might come back to bite you in the balls one day. For better or worse, we're the victims of our design, but that won't stop us.



I write about my software engineering learnings and experiments. Stay updated with Tentacle: tntcl.app/blog.shalvah.me.

Powered By Swish