v0.1 is tagged. The skeleton stands. And the original plan said v0.2 would be graphs — dependency graphs, project graphs, the whole resolution layer.
But after researching it, I realized graphs are useless at this stage. There is no real workspace to resolve yet. No integrations to connect. The graph would just be a piece of code that does nothing because it has nothing to operate on. It would exist purely to satisfy the roadmap, not to solve an actual problem.
So I skipped it. Graphs will come later, in a future release, when there are enough moving parts to make resolution meaningful. For now, the real next step is integrations — the things that actually fill the workspace. That became the new v0.2 target.
And the first question was: how do integrations even enter the system?
The answer started with a problem I kept hitting in earlier rewrites — three separate registration paths. Integrations had one way in, adapters had another, and runtimes were their own thing. Every time I added something, I had to remember which path it belonged to. It was manageable at the scale of v0.1, but I could already feel it becoming a mess.
So I asked: what if everything entered the same way?
The Unit System
That is the core idea behind what I am calling the Unit System. One array in the config. One resolver in the Kernel. Three typed helpers — defineIntegration(), defineAdapter(), defineRuntime() — for type safety. But underneath, they all produce the same thing: a VelnoraUnit.
The Kernel does not care what kind of unit you are. It collects them all, resolves dependencies, and initializes in the correct order. Kind only matters for when you init — runtimes first, then adapters, then integrations — and for the specific hooks you expose.
This felt right immediately. The config went from three separate concepts to one flat array. And the Kernel went from three separate loaders to one resolver.
Dependencies Without Imports
The part that took the most thinking was inter-unit communication. Units should never import each other directly — that would create hard coupling and break the whole plugin model. But they need to talk to each other. A Kubernetes integration needs Docker. A Spring Boot integration needs a JVM runtime.
The solution is a capability-based system. Each unit declares what it provides (capabilities) and what it requires. The resolver matches them by string. If you require 'docker', any unit that has 'docker' in its capabilities array satisfies it.
But here is where it gets interesting — the type safety.
I built a global type registry using TypeScript declaration merging. When you install @velnora/integrations-docker, its type declarations automatically register DockerApi into Velnora.UnitRegistry. Then when you call ctx.query('docker') inside another unit, TypeScript knows the return type is DockerApi. No imports. No type casting. Just install the package and the types flow.
Hard dependencies (requires) return the API directly. Soft dependencies (optionalRequires) return T | undefined. The generics on the define helpers infer the literal strings from the arrays, so TypeScript knows exactly which queries are safe and which might be undefined.
This is the kind of thing that makes me love TypeScript. The type system is doing real architectural work here — it is enforcing the dependency contract at compile time.
Composite Units
One design decision I am particularly happy with: composite units. A single unit can bundle child units inside itself using a units[] array. The user adds one thing to the config, and the Kernel unpacks everything.
Docker is the perfect example. As a user, you call docker() and you get both the integration (build images, push, create networks) and the runtime (Docker Engine). One function call. The Kernel flattens the tree, resolves dependencies across all of them, and inits in order.
This keeps the config clean. The user does not need to know the internal structure of what they are adding. They just add docker() and everything works.
First Interfaces: RuntimeUnit & PackageManager
With the design in place, it was time to write actual types. The first two interfaces I implemented were RuntimeUnit and PackageManager.
A RuntimeUnit defines what a language runtime looks like to the Kernel — its toolchain, its supported package managers, and a resolvePackageManager() method that figures out which one to use for a given project directory. The PackageManager interface captures the basics: install, add, remove, and how to resolve the lock file. Nothing fancy — just enough contract for the Kernel to work with any runtime without knowing its internals.
defineRuntime — The First Helper
With the interfaces in place, the next step was defineRuntime() — the first of the three typed factory helpers.
The reason this one came first is simple: runtimes are the bottom of the stack. Everything else depends on them. An adapter needs to know what runtime it is orchestrating. An integration needs to know what language capabilities are available. If the runtime layer is not solid, nothing above it can be trusted.
defineRuntime() is a thin function. You pass it your runtime definition — name, version, capabilities, toolchain, package managers — and it gives you back a VelnoraUnit. That is it. No magic. No hidden state. The function exists purely for type inference — so TypeScript can capture the exact literal strings you declared in requires and capabilities, and carry that information all the way through to ctx.query() later.
This is the pattern all three helpers will follow. defineAdapter() and defineIntegration() will look almost identical in shape. The only difference is the kind-specific fields each one adds. But the contract is the same: you define what you are, what you provide, and what you need. The Kernel handles the rest.
What Is Still Open
There are questions I have not answered yet. Should capabilities support versions — like 'jvm@21'? How should the resolver pick between multiple providers of the same capability when both Node and Bun provide 'javascript'? Should there be lifecycle hooks for cross-unit events?
These are real design questions, not implementation details. They will shape how the Unit System scales. But the core is solid: one array, capability-based resolution, type-safe queries, composite unpacking, and now the first real types and factory in place. The runtime layer has a shape — the rest of the unit kinds will follow the same pattern.
Top comments (0)