Dependency Injection in Base, what, why, and how
A simple idea wearing a heavy name, and the quiet machinery that makes the rest of Base legible.
Dependency Injection in Base: what, why, and how
If you have used Base for an afternoon, you have used dependency injection without noticing. That is on purpose. This post pulls back the curtain on the machinery so that when you do need to reach for it, you know exactly where the levers are.
What it is
Dependency injection is a simple idea wearing a heavy name. Instead of a class reaching out to grab the things it needs, it declares what it needs, and something else hands those things over.
The class that grabs:
The class that declares:
The second version does not know where UserRepository comes from. It does not know if it is the real one talking to PlanetScale or a fake one in a test. It just knows it needs one. Base's injection container is the something else that hands it over.
Why Base leans on it
Three reasons, and they compound.
Your logic stays portable. The same decorated class can answer an HTTP request, an RPC call, and a GraphQL query without changing, because none of those transports are baked into it. The class asks for what it needs; the framework supplies it in whatever context the class runs in.
Testing stops being a fight. When a class declares its dependencies instead of grabbing them, a test can hand it different ones. No module-mocking gymnastics, no monkey-patching globals. You construct the class with the doubles you want and assert on the result.
Wiring is not your job. You do not maintain a file that says "this service needs that repository which needs this connection." You decorate, and Base reads the decorations and builds the graph. The wiring is derived from the code, so it cannot drift out of sync with the code.
Why it is mostly hidden
Most of the time you write @Injectable() on a class, list it in your settings, and declare its dependencies in the constructor. That is the whole interaction. Base resolves the graph for you. You rarely touch the container directly, because the common path does not need you to.
The container is there when you do need it, but the design goal is that you reach for it seldom. If you find yourself wrestling with injection on an everyday feature, that is usually a sign the shape is fighting you, not that you need more DI.
How: the decorators
Base wraps a mature injection library and renames its decorators to match Base's convention (capitalized, same name). The vocabulary you will actually use:
@Injectable(), mark a class so its dependencies can be injected. This is the one you reach for most.@Inject(token), a constructor parameter decorator for when the type alone is not enough to identify what to inject (an interface, a named value).@Provider(token), declare a method that produces a value to bind to a token. Use it to teach the container how to build something it cannot construct on its own.@Singleton(), one instance for the life of the process.@InjectOptional,@InjectAll,@InjectLazy: the variations that allow missing, collect every binding for a token, or defer construction until first use.
Tokens in Base always carry their type. You inject by class constructor, by a typed injection key, or by a typed binding. Bare strings are deliberately not accepted as tokens, so that every injection point keeps its static type and the compiler can check it. A lint rule even verifies the injected type matches the parameter it lands in.
A real provider, straight from the examples:
The method returns the value; @Provider(token) binds it; anything that injects that token receives it.
How: scopes
A scope answers the question "how long does this instance live, and who shares it?" Base gives you three you will reach for:
- Transient is the default. Decorate a class with
@Injectable()and every time it is resolved you get a fresh instance. Cheap, stateless, no surprises. - Singleton (
@Singleton()) gives you one shared instance for the whole process. Reach for it when constructing the thing is expensive or it genuinely holds shared state. - Worker-scoped gives you one instance per worker container rather than one globally. It is the right middle ground when you want sharing within a worker but not across the whole runtime.
Independent execution scopes are the quiet payoff here. Because resolution happens against the right container for the context a request runs in, two requests handled by the same worker do not step on each other's scoped state. You get isolation without writing isolation code.
The mental model to keep
Declare what you need, decorate so Base can see it, and let the container build the graph. You only step down to the container itself for the uncommon cases. Everything else is the framework doing the wiring you used to do by hand, derived from the code so it can never lie about itself.