Posted by jelder 4/2/2025
https://en.wikipedia.org/wiki/Rule_of_least_power
I build lots of DSLs/configuration languages but Im pretty militant about killing them if requirements dictate a need for anything resembling loops or conditionals. Those are the the klaxon warning bells telling you that you should just be writing code/a library.
- DSL as functions T, args* -> T, where T is your configuration type — or, as I like to call it, a plan. Since under function composition it's insensitive to composition order (associativity of the monoid), you can layer DSL functions on top of each other freely.
- Literal values as implicit functions. Once the plan is built and before it is run/compiled/interpreted, I cast any literal value in the datatype to a function returning that value. It's a design principle that allows me to hard-code behavior when a literal value is not enough by just swapping it with a lambda.
- Once this kind of homogeneity is ensured, and given the points above, I can extend my DSL and the behavior it describes with point-free function combinators. I get conditionals, advanced composition (parallelism for instance) , instrumentation (debugging), etc... without burdening my DSL with ad-hoc, invasive implementations. More importantly I can reuse these facilities across DSLs.
I’m heavily down the path of composability, it’s all ended up very monadic but I’ve resisted the DSL idea. One thing that I’ve still to solve is some kind of polyglot binding. If I’m saying no dsl and you get to express config in a regular programming language, it’d be nice if I could say your home / preferred language. Every way I’ve come up with for that so far just sucks.
Oftentimes, changing the envvar value doesn’t propagate without a container restart anyway.
I wish that instead of all these vaults and similar tools, cloud providers would let me pipe in a flat text file and read config from there.
Anything that requires a more complex setup should live with the code.
In many scenarios, the increased cognitive burden that comes with these hierarchical config files, DSLs, and rule engines is just not worth the yield. Redeploying isn’t hard unless we’re talking about a multi-region, globally distributed system.
A little config.py file gets imported by everything in the project. It contains nothing but assignments to config variables. No functions. Nothing dragged in from a file. Just variable assignments.
It's easy to understand, easy to update, and everyone understands this much python.
I think of it as an algebra of configurations and there are at least two existing languages that implement this ideal. One of them is Jsonnet and the other is internal to Google (and might have influenced Jsonnet).
That said, obviously a good language is not enough. You still need good judgement about which stuff goes into configuration.
Yes this gives you more power to change things with config instead of code, but it means that from now on you have to treat those values as abstract quantities rather than as something you can actually reason about.
Prefer to put configuration near where it is used. Prefer to put utility functions near where they are used. A single page of code is your "cache" and accessing information in the same page is way faster than having to look elsewhere, and that's even if you already know where to look.
Obviously you need to make exceptions for things that genuinely need to be configured elsewhere, but if it doesn't need to be configured elsewhere, please just configure it right by where you use it. It makes debugging and understanding the code a lot easier.
Option 1:
sub truncate {
my ($self, $str, %opts) = @_;
my $max_length = $opts{max_length} // $self->max_length // get_optional_config('max_length') // 15;
return substr($str, 0, $max_length);
}
Option 2: sub truncate {
my ($self, $str) = @_;
my $max_length = 15;
return substr($str, 0, $max_length);
}
In option 1 you have 3 different places to specify max_length (and you just know that $self->max_length is going to look in more than one place as well...). Trying to divine the actual behaviour of truncate() from this code is very difficult, and it gets worse for functions that do more complicated things and are configured by multiple interacting parameters.In option 2 you know it truncates at 15 characters, no exceptions.
> In the pub after work someone quips, “we’re back where we started four years ago, hard coding everything, except now in a much crappier language.”
For me it's not the same. Four years ago - you hardcoding values in (probably) compiled language. So, you were need to recompile it each time the value changes. Now - you writing (probably) in interpretet DSL. So, your compiled app can reload it at runtime.
Not sure if I agree with this. A proper designed DSL has the advantage of being much closer to the domain of the problem it is supposed to solve. Your code written in the DSL now might end up as 'hard coded' part of the application, but it likely conveys much more meaning in much less code because it is tailored to the core functionality of the application.
When you chain these functions together into business logic they will be just as readable as the DSL would have been. But you still get an IDE with code completion, debugging, etc.