Describe your intent. Let Weaver handle the rest.
đź‘€ Overview
Weaver is a dependency injection library for Flutter and Dart designed to reduce one thing above all else: mental overhead. It stays out of your way, removes hidden rules, and lets you focus on building features instead of managing wiring.
It does not rely on the widget tree or BuildContext, and it frees you from timing issues where dependencies may or may not be ready when accessed. There are no fragile placement requirements, no guessing games, and no race conditions to think about.
By fully decoupling dependency registration, injection, and scope management, Weaver introduces a calmer and more predictable mental model for DI in Flutter. You describe what exists, when it exists, and where you need it. Weaver takes care of the rest — quietly and predictably.
🤔 Why dependency injection feels harder than it should
If you have used dependency injection in Flutter long enough, this probably feels familiar.
At first, things seem simple. You add a Provider, a BlocProvider, or a use a library like get_it, and everything works. But as the app grows, dependency injection slowly turns into something you manage rather than something that just works.
You start moving providers higher in the widget tree "just in case." You wrap entire subtrees to avoid ProviderNotFoundException. You worry about whether a dependency is created before a widget builds. You delay logic, add loading states, or restructure code simply to make DI behave.
None of this feels like business logic, yet it takes a surprising amount of mental energy.
The problem is not Flutter, and it is not that libraries like Provider or get_it are bad. The problem is that several responsibilities are tightly coupled together or sometimes the solutions are just not there:
- where a dependency is provided
- when it is created
- how long it should live
- who is allowed to access it
When these concerns leak into your UI and application logic, dependency injection stops being a tool and starts becoming a constant background concern.
Weaver was designed by starting from this question:
What if dependency injection required almost no thinking at all?
What if you could describe intent instead of wiring, lifecycles instead of placement, and let the system handle the rest?
đź§ A belief that shaped Weaver
There is a belief I have carried for years while building software.
If a solution is truly good, it should handle all valid cases of the problem, not just the most common ones.
I once worked with a CTO who told me something I never fully accepted: that when it comes to problems like state management, there is no solution that fits all cases. That every application is different, and every solution inevitably breaks down somewhere.
I refused to accept that.
Not because problems are simple, but because many of them are poorly defined.
When you reduce a problem to its essence, understand its true boundaries, and describe it precisely, patterns emerge. At that point, a general solution becomes possible.
This mindset heavily influenced how Weaver was designed. Instead of reacting to individual edge cases, it starts by asking a simpler question: what is dependency injection really about?
Defining the problem correctly is often more important than writing the solution. In programming, I have found this to be a recurring truth. One sentence I often repeat is this:
One developer’s solution is another developer’s problem.
What works cleanly for one developer may create friction for another, simply because perspectives differ.
Weaver aims to avoid becoming that problem by simplifying dependency injection down to its core ideas and handling the complexity centrally, rather than spreading it across your codebase.
✨ Key Benefits
1. Context-free dependency access
Weaver completely removes dependency injection from the widget tree. There is no BuildContext, no inherited widgets, and no fragile placement rules. Dependencies can be registered and retrieved from anywhere in your codebase, making architectural boundaries clearer and reducing accidental coupling between UI and application logic.
2. No race conditions, ever
With APIs like getAsync() and widgets such as RequireDependencies, Weaver eliminates an entire class of timing bugs. You can safely request dependencies before they are registered, and Weaver will resolve them as soon as they become available. This removes common issues like ProviderNotFoundException and awkward initialization ordering.
3. Safe and predictable widget building
RequireDependencies allows widgets to declare what they need and build only when those dependencies are ready. Widgets become declarative about their requirements instead of defensive about missing state. This leads to simpler build methods and a much more relaxed mental model when composing UI.
4. True separation of responsibilities
Weaver intentionally decouples three concerns that are often tangled together:
- registering dependencies
- injecting dependencies
- managing lifecycles and scopes
Each concern has a clear, focused API. This separation makes large applications easier to reason about, easier to refactor, and safer to evolve over time.
5. First-class scoped dependencies
Weaver treats scopes as a core concept rather than an afterthought. Dependencies can live globally or only within well-defined lifecycles such as authentication, admin mode, or feature-specific flows. Entering and leaving scopes is explicit, observable, and deterministic, which maps naturally to real business logic.
6. Lazy by default, efficient by design
Dependencies can be registered lazily and instantiated only when actually needed. Combined with scopes, this avoids unnecessary object creation and keeps memory usage under control without adding complexity for the developer.
7. Clear handling of multiple instances
Named dependencies make it straightforward to work with multiple instances of the same type. Code generation turns these named registrations into expressive, type-safe getters, improving readability and eliminating stringly-typed lookups throughout the codebase.
8. Built for testing and observability
Weaver makes testing easier by supporting reassignment of dependencies and full resets of the container state. Observers allow you to react to registrations and unregistrations, making application state changes visible and debuggable instead of implicit and hidden.
9. A calmer mental model for Flutter DI
Perhaps the biggest benefit is intangible: Weaver reduces cognitive load. There is no need to remember where providers are placed, when objects are created, or which part of the tree owns what. You describe what exists, when it exists & where you need it and Weaver takes care of the rest.
📦 Installation
Before diving into examples, add Weaver to your project.
dependencies:
weaver: ^x.y.z # for Dart-only projects
flutter_weaver: ^x.y.z # for Flutter projects
dev_dependencies:
build_runner:
weaver_builder: ^x.y.z
Once added, you can start describing dependencies and scopes without worrying about wiring.
🔄 From wiring dependencies to describing intent
Most dependency injection setups start by asking how things should be wired.
Where should this provider live? Which widget owns it? Do I create it eagerly or lazily? What happens if another widget needs it earlier than expected?
These are implementation questions, but they tend to leak everywhere.
Weaver flips this around. Instead of thinking about wiring, you describe intent:
- what dependency exists
- when it should exist
- and where it is needed
Everything else becomes an implementation detail handled by the system.
To see the difference, let’s look at a simple, familiar example.
A common setup with Provider
Imagine a simple UserBloc that depends on a UserRepository.
With a widget-tree-based approach, you might end up with something like this:
MultiProvider(
providers: [
Provider<UserRepository>(
create: (_) => UserRepository(),
),
Provider<UserBloc>(
create: (context) => UserBloc(
repository: context.read<UserRepository>(),
),
),
],
child: const MyApp(),
);
This works, but notice what you are now responsible for:
- ordering providers correctly
- making sure nothing accesses
UserBloctoo early - placing this high enough in the widget tree
- refactoring widget structure carefully to avoid breakage
The code is not wrong. It is just mentally noisy as applications grow.
The same intent with Weaver
With Weaver, the same setup becomes a description of intent rather than wiring:
weaver.register(UserRepository());
weaver.register(() => UserBloc(useRepository: weaver.get()));
And wherever you need UserBloc:
final userBloc = weaver.get<UserBloc>();
There is no widget placement to think about, no ordering anxiety, and no concern about timing. If the dependency exists, you get it. If it does not yet exist, Weaver resolves it when it becomes available.
The code is shorter, calmer, and focused on what the app needs, not how it is wired.
đź§© Building widgets with RequireDependencies
A common source of DI pain in Flutter is that widgets often need dependencies that are not guaranteed to exist yet.
Maybe a bloc is registered later during startup. Maybe it is created only after a user authenticates. Maybe it lives inside a scope that you enter and leave based on business logic.
In widget-tree DI, this typically turns into placement anxiety and defensive code: you move providers around, add guards, and hope you never hit ProviderNotFoundException in the wrong navigation path.
RequireDependencies is Weaver’s way of making this problem disappear.
You simply declare what the widget requires. Weaver will rebuild when those dependencies become available, and you get a single isReady flag to drive the UI.
Example: a profile page that requires authentication
Imagine ProfilePage needs a UserBloc, but UserBloc is registered only after the app enters an authentication scope.
With Weaver, the widget does not need to know where UserBloc comes from, when it is registered, or whether the scope is currently active. It only declares the requirement.
class ProfileGate extends StatelessWidget {
const ProfileGate({super.key});
@override
Widget build(BuildContext context) {
return RequireDependencies(
weaver: weaver,
dependencies: const [
DependencyKey(type: UserBloc),
],
builder: (context, child, isReady) {
if (!isReady) {
return const Center(child: CircularProgressIndicator());
}
// At this point, UserBloc is guaranteed to be available.
return const ProfilePage();
},
);
}
}
The important part is not the loading spinner. The important part is the mental model:
- you do not wire providers into the widget tree
- you do not guess whether registration has already happened
- you do not care whether the dependency is global or scoped
You just declare what is required. When it becomes ready, your widget builds.
This is how Weaver keeps dependency injection out of your way, without adding new rules to remember.
⏳ Imperative waits with getAsync()
Sometimes you are not inside the widget tree. You are in startup code, in a service, in a background task, or in an event handler where you want to write imperative code.
That is where weaver.getAsync() shines.
It works like weaver.get(), except it returns a Future and waits until the dependency becomes available. You can call it before the dependency is registered, and it will resolve the moment registration happens.
Example: handling a notification click
A very common real-world case is handling a notification tap when the app is launched from the background or a terminated state.
At that moment, the app may not have finished bootstrapping yet, and many dependencies may not be registered. Still, you want to react to the notification reliably.
Future<void> handleNotificationClick(NotificationPayload payload) async {
// Ask for the dependency immediately.
final userBlocFuture = weaver.getAsync<UserBloc>();
// App startup continues elsewhere. At some point, after auth or setup,
// the dependency is registered.
// This might happen seconds later.
// weaver.register(UserBloc());
// Wait until UserBloc becomes available.
final userBloc = await userBlocFuture;
// Now it is safe to handle the notification.
userBloc.openFromNotification(payload);
}
When to use which
- Use
RequireDependencieswhen building widgets. - Use
getAsync()when you are writing imperative code outside the UI.
🎯 Scoped dependency management
Many dependencies should not live for the entire lifetime of your app.
Some only make sense in a specific context: when a user is authenticated, when an admin panel is active, or when a particular feature flow is entered.
Weaver treats scopes as a first-class concept. When defining a scope, you only need to care about three things:
- which dependencies should exist in that scope
- what should happen when the scope is entered
- what should happen when the scope is left
Everything else is handled for you.
Defining a scope
You define a scope by annotating a class. Inside it, you register dependencies that should live for the duration of that scope.
@WeaverScope(name: 'auth')
class _AuthScope {
@OnEnterScope()
Future<void> onEnter(Weaver weaver, int userId) async {
weaver.register(UserBloc(userId: userId));
}
@OnLeaveScope()
Future<void> onLeave(Weaver weaver) async {
weaver.unregister<UserBloc>();
}
}
After running code generation, Weaver creates everything needed to manage this scope.
Entering and leaving a scope
weaver.addScopeHandler(AuthScopeHandler());
// Enter the scope
weaver.enterScope(AuthScope(userId: 42));
// Leave the scope
weaver.leaveScope(AuthScope.scopeName);
A real-world example: reacting to authentication state
In real applications, scopes are usually driven by business state rather than manual calls.
A common pattern is reacting to an authentication bloc or cubit and entering or leaving scopes automatically based on the user’s status.
weaver.get<AuthBloc>().stream.listen((state) {
if (state.isAuthenticated && !weaver.authScope.isIn) {
// User just authenticated → enter auth scope
weaver.enterScope(AuthScope(userId: state.user.id));
}
if (!state.isAuthenticated && weaver.authScope.isIn) {
// User logged out → leave auth scope
weaver.leaveScope(AuthScope.scopeName);
}
});
When the scope is entered, its dependencies become available. When the scope is left, they are removed automatically.
The important part is that usage stays completely decoupled.
When you use UserBloc, you do not need to know:
- when the scope was entered
- where the dependency was registered
- how its lifecycle is managed
If the scope is active, the dependency exists. If it is not, it does not.
That is the power of Weaver’s scope management: explicit lifecycles without hidden complexity.
🏷️ Named dependencies and generated accessors
Sometimes you need more than one instance of the same type for different purposes. Instead of creating wrapper classes or relying on comments and conventions, Weaver lets you name dependencies explicitly.
At the simplest level, you can register a named dependency by providing a name when registering:
weaver.register<String>('token-value', name: 'auth-token');
weaver.register<String>('user-id-123', name: 'user-id');
And retrieve them by name:
final token = weaver.get<String>(name: 'auth-token');
final userId = weaver.get<String>(name: 'user-id');
This already removes ambiguity, but Weaver goes one step further.
Code-generated accessors for named dependencies
Weaver can generate type-safe, intention-revealing accessors for named dependencies. Instead of passing strings around, you declare named dependencies using annotations, and Weaver generates direct getters for you.
@NamedDependency(name: 'user-profile')
Profile _userProfile() {
return Profile();
}
@NamedDependency(name: 'admin-profile')
Profile _adminProfile() {
return Profile();
}
After running code generation, these become available as:
final profile = weaver.named.userProfile;
final adminProfile = weaver.named.adminProfile;
The result is clearer code, fewer mistakes, and a much stronger signal of intent.
Named dependencies inside scopes
The same idea applies to scopes. You can define named dependencies inside a scope, and Weaver will generate scoped accessors for them as well.
For example, if a scope defines a named dependency called userId, it can be accessed like this:
final id = weaver.authScope.userId;
You do not need to manage registration or cleanup manually. When the scope is active, the accessor is valid. When the scope is left, the dependency is gone.
This keeps even advanced use cases consistent with the same calm mental model Weaver applies everywhere else.
🌿 Closing thoughts
Dependency injection should not be something you constantly think about. It should not shape how you structure your widgets, force you to worry about timing, or leak lifecycle concerns into places where they do not belong.
Weaver was built around a simple goal: reduce mental overhead. By separating responsibilities, making scopes explicit, and letting you declare intent instead of wiring, it allows dependency injection to fade into the background where it belongs.
When DI stays out of your way, you spend less time managing infrastructure and more time building features that actually matter.
If this mental model resonates with you, you can explore the full documentation, examples, and API reference here:
👉 https://pub.dev/packages/weaver
Weaver is opinionated by design, but that opinion is simple: dependency injection should feel calm, predictable, and almost invisible.
Top comments (0)