Core concepts
Environment
The following is an exploration of what an Environment is conceptually and how it used in Flutter Forge.
Concept
Conceptually an Environment is an entity that holds all the information for a Flutter Forge component to be able to access external dependencies. Basically, a mechanism to facilitate dependency injection into a Flutter Forge Component in a formal way.
In Practice
An Environment in practice is simply a class that facilitates accessing the dependencies that need to be formally injected into the Component. There are no technical restrictions on this class. It doesn't need to extend a base class or anything. It is just a class that you define and create.
This freedom is also what makes the concept of Environments so difficult to get a grasp on initially. To aid with this let's take a look at a few examples to help solidify how simple this really is.
No Dependencies
In the simplest case, like a Counter Widget, there are no dependency that need to be injected. In this case we simply define our Environment to be an empty class. This would look something like the following.
class CounterWidgetEnvironment {
const CounterWidgetEnvironment();
}
You can probably actually get away with this using the default constructor as follows for this particular case.
class CounterWidgetEnvironment {}
I generally use the first approach as a starter because you will have to make an explicit constructor when you declare your first dependency.
Need a Dependency
We might be in a scenario where we need to inject a dependency to facilitate getting a name from some service.
People have a tendency of trying to inject entire services. Flutter Forge environments are of course capable of facilitating this, as an environment is simply a class.
Doing this might look something like the following if you want to get a new instance of SomeExternalService
every time you call the getSomeExternalService
method.
@immutable
class YourComponentEnvironment {
Environment({required this.getSomeExternalService});
final SomeExternalServiceInterface Function() getSomeExternalService();
}
Or like this if you want the same instance every time you access someExternalService
.
@immutable
class YourComponentEnvironment {
Environment({required this.someExternalService});
final SomeExternalServiceInterface someExternalService;
}
Unnecessary Coupling
Despite the fact that you are able to inject an entire service via the mechanisms above, you generally want to avoid it. This is because it unnecessarily increases the coupling of the component to the external dependency.
To understand this a little better let's look at the scenario where we need to fetch a value from a service. Let's say in our contrived example we need to simply fetch a name from the external service.
If we do that by injecting the entire service as we did in the examples above. The component will now know about the entire service and all of its methods and properties. One of those methods facilitates fetching the name that our component cares about. So our component now also has to know which method is used to fetch the name, how to use it, as well as be able to provide any dependencies that method may require. That means the component would have to now know about the dependencies of its dependency just because it has a dependency, not because it actually needs those things. If you continue to follow this pattern you can rat-hole for a very long time.
Coupling can be thought of as knowledge of one piece of software about another. So far we have talked about this approach of injecting an entire service, and we can see just from the light exploration above that it results in the component having a lot of coupling/knowledge about that dependency and the dependency's dependencies, etc. All of which makes the component more and more strongly coupled to the service.
Not only that, it makes testing much more difficult. For example to stub out a particular method on a service you have to provide an entire stub of the service not just that one method.
Architecturally we know that having software loosely coupled is the goal and generally leads to a better path. So what can we do within Flutter Forge and the Environment concept to help create more loosely coupled components.
It turns out it is pretty simple. When we develop components we need to develop them in isolation, pretending we know nothing about the software outside of them, and explicitly, intentionally, not worrying about how we are going to integrate them with the rest of the software.
If we continue our example thinking in that manner we know that our Component simply needs a name from something external. Therefore, we formally define that need as part of our interface via our Environment as follows.
@immutable
class Environment {
final FutureOr<String> Function() getName;
const Environment({required this.getName});
}
This simple change of defining a function, getName
to get the name means that the component itself no longer has any knowledge of the external service. It just knows that it can call getName
as an async function to get the name. If the component has some data that needs to be used as a dependency to get the name. No problem we would just change it to be something like the following.
@immutable
class Environment {
final FutureOr<String> Function(String userId) getName;
const Environment({required this.getName});
}
Now the component is much more loosely coupled as it only has knowledge about the things it needs to have knowledge about and nothing more.
Accessing Environment
The other side of the coin is understanding where you will be able to access this injected environment. The only place that Environment is exposed is within an Effect which is another concept that has its own explanations.