Happy Path, Escape Hatch, and the Space Between
When you design an extensibility API, you face a tension: make it easy, or make it powerful? We think the answer is both — but not all at once.
Here's how we think about it at VillageSQL, illustrated through VDFs, our modern replacement for MySQL's UDF system.
The God Function
MySQL has User-Defined Functions: your C/C++ code, loaded from a shared library, available in SQL. Wildly useful functionality in an API that's frustratingly general. Look at the signature of an example UDF:
extern "C" double myfunc_double(UDF_INIT *initid, UDF_ARGS *args,
unsigned char *is_null, unsigned char *error);
What does it take? What does it return? Instead of being able to figure it out at a glance, you have to read deeply in the body of the function.
UDFs also have an init function. That's responsible for, well, a lot:
- Validate argument count
- Check and coerce argument types
- Set result metadata (max_length, decimals, maybe_null)
- Allocate per-statement memory
- Set determinism
- Return errors by strcpy-ing into a fixed-size char buffer
My software engineering craft leveled up when a coworker (hi Matthieu!) shared a French proverb:
Une place pour chaque chose, et chaque chose a sa place — a place for everything, and everything in its place.
Framework friction is a tax on extensibility. And the UDF framework adds friction to both implementation and runtime. Because UDFs support the general, dynamic case, the framework is blind. It sees an opaque function pointer and can't introspect to optimize.
The Happy Path
VillageSQL allows extenders to add functionality via VDFs (VillageSQL Defined Functions). Defining a VDF uses a modern C++ API that captures more intent (all snippets assume using namespace vsql;):
void add(IntArg a, IntArg b, IntResult out) {
if (a.is_null() || b.is_null()) { out.set_null(); return; }
out.set(a.value() + b.value());
}
make_func<&add>("add")
.returns(INT)
.param(INT)
.param(INT)
.deterministic()
.build();
Declaration becomes self-documenting: two ints in, one int out. Null handling is explicit. No casting, no manual packing. Registration uses a builder pattern, so each concern becomes a separate declaration.
The key mechanism: extensions pass data instead of code. .param(INT) stores a type descriptor. The framework reads that metadata at runtime and handles type validation, argument marshaling, and type conversion — work that would otherwise require hand-written init code.
Data means VillageSQL can introspect, optimize, and help in cases where MySQL gets stuck in a Turing Tarpit.
This is the Happy Path. It shows users how good it can be.
The Space Between
OK, so, we're done? VillageSQL understands everything about your code and optimizes it perfectly? Everything's puppies and rainbows?
No, of course not. We can think of lots of reasonable use cases that we don't support.
Take a function like CONCAT(a, b, c, ...), that has repeated params of one type. It would be easy to support like:
void my_concat(RepeatedArg<StringArg> args, StringResult out);
Or heterogeneous varargs — after some fixed params, accept anything:
void my_func(IntArg fixed, VarArgs<StringArg> rest, IntResult out);
We could design either of these. But we're not going to. Yet.
Why? Because we don't know which patterns users will actually need. Every use case we support takes effort we could spend on other functionality. Worse, adding one feature might conflict with another capability we find out is more important.
The wrong abstraction now means a bad API we support forever. And what we'd build today based on speculation will be worse than if we can focus our DX-centered API design expertise on real user feedback.
This is the Space Between: a deliberate holding pattern. We know the Happy Path doesn't cover everything. We're being patient about what to extend next.
The Escape Hatch
We're able to be patient because of the Escape Hatch. The Escape Hatch is an alternative to the Happy Path that ensures the long tail of use-cases is unblocked.
For example, here's how to implement a varargs SUM using the Escape Hatch. prerun() is analogous to UDF's init — you get full control:
void my_sum_prerun(PrerunArgs args, PrerunResult result) {
for (size_t i = 0; i < args.size(); i++) {
if (!args.type_at(i).is_int()) {
result.error("my_sum: all arguments must be INT");
return;
}
}
}
void my_sum(VarArgs args, IntResult out) {
long long total = 0;
for (auto a : args) {
total += a.as_int();
}
out.set(total);
}
make_func<&my_sum>("my_sum")
.returns(INT)
.varargs()
.prerun<&my_sum_prerun>()
.build();
You manage your own type checking, your own memory, your own everything.
Pascal said "I'm sorry this letter isn't shorter"; well, the Escape Hatch is the too-long letter: not ergonomic, but it exists until we can make something better.
The Escape Hatch serves two purposes:
- Unblock users: They can accomplish what they need right now.
- Gather data: Their pain points tell us what the Happy Path should become next.
Implementing both a Happy Path and an Escape Hatch costs slightly more in the short term. We choose this approach so we can avoid the technical debt that comes with supporting an API designed for corner cases without the benefit of user feedback.
Make Escape Hatches Visibly Escape-y
This matters: users should know they're using an Escape Hatch. Name it differently. Document it differently. Make it clear that if you're reaching for this, we want to hear from you.
Talk to us on Discord. Tell us what you need. We want to keep your use case in mind as we design the next extension of the Happy Path.
The Reveal
Here's the thing: beat three enables beat two.
You can afford to be patient about what to build next — the Space Between — because nobody is stuck waiting. The Escape Hatch exists. Users can get their work done. And while they do, they're showing you exactly what the Happy Path needs to grow into.
We care about extensibility APIs that are not just functional, but elegant — today, and as your needs grow. That's not abstract: Steve, our CTO, was TL on BigTable and Colossus at Google; I built Tilt, where the Tiltfile is an ergonomic DSL for configuring your dev environment.
- Happy Path: Shows users how good it can be.
- Escape Hatch: Lets them get work done today.
- Space Between: The patience to wait for users to tell us what they need in the Happy Path
The Escape Hatch enables the patience. The patience produces the right API.
Get started with VillageSQL at villagesql.com