Graphs
Introduction
In Object Oriented Programming, programs are organized around objects, where each object has a specific purpose. These objects can require other objects to perform their responsibilities. The required objects are called dependencies. Providing these dependencies manually is a tedious and error-prone process. The dependency injection pattern is a way to automate this process so you can focus on the logic of your application instead of writing boilerplate code.
Before you can inject dependencies into hooks, components and classes, the dependencies first need to be declared so Obsidian knows how to construct them. In Obsidian, dependencies are declared in classes called "Graphs". Graphs create a centralized place where dependencies are defined. This makes them a powerful tool for understanding the relationships between objects in your program.
Declaring dependencies in a graph
The snippet below shows a basic example of a Graph. It defines two dependencies, httpClient
and databaseService
.
import {Singleton, Graph, ObjectGraph, Provides} from 'react-obsidian';
@Singleton() @Graph()
export class ApplicationGraph extends ObjectGraph {
@Provides()
httpClient(): HttpClient {
return new HttpClient();
}
@Provides()
databaseService(): DatabaseService {
return new DatabaseService();
}
}
Graphs must be annotated with the @Graph
decorator. In this example we chose to annotate the class with the @Singleton
decorator as well, which means that the graph and the dependencies it provides will only be constructed once.
Dependencies are constructed in methods annotated with the @Provides
annotation. The @Provides
annotation is used to tell Obsidian that the method is a dependency provider. From now on we'll refer to these methods as providers. Obsidian uses the provider's method name as the dependency's name. In this example, the httpClient
provider method provides the httpClient
dependency. The databaseService
provider method provides the databaseService
dependency.
Once your graph is declared you can use it to inject dependencies into the various constructs that form your application:
The term "graph" comes from graph theory. Obsidian constructs Directed Acyclic Graphs (DAGs) to represent the dependencies between objects. This type of graph ensures there are no circular dependencies between objects which cause call stack overflows and other unexpected bugs.
Specifying relationships between dependencies
Some of the services defined in your graphs may be independent, meaning they don't require any dependencies to be constructed. However, most of the time, services will require other services to perform their responsibilities. In these cases, you can specify the dependencies of a service as arguments in the provider and Obsidian will resolve them automatically.
import {Singleton, Graph, ObjectGraph, Provides} from 'react-obsidian';
@Singleton() @Graph()
export class ApplicationGraph extends ObjectGraph {
@Provides()
httpClient(): HttpClient {
return new HttpClient();
}
@Provides()
databaseService(): DatabaseService {
return new DatabaseService();
}
@Provides()
appInitializer(httpClient: HttpClient, databaseService: DatabaseService): AppInitializer {
return new AppInitializer(httpClient, databaseService);
}
}
Providers are evaluated lazily. This means that a provider is evaluated only when the dependency it provides is requested. Dependencies that are not used in the application, will never be constructed.
Graph types
There are two types of graphs in Obsidian: A singleton graph and a lifecycle-bound graph.
The singleton graph
Applications typically have at least one singleton graph. These graphs are used to provide dependencies that are used throughout the application. These dependencies are usually singletons, which means they should only be constructed once. The ApplicationGraph
in the example above is a singleton graph.
To declare a singleton graph, annotate the graph class with the @Singleton
decorator.
The lifecycle-bound graph
Lifecycle-bound graphs are used to provide dependencies that are shared between components and hooks in a specific UI scope.
Obsidian supports injecting two types of UI scopes:
- Feature scope (default): A feature scope is a scope that is shared between multiple screens. For example, a user authentication flow might consist of multiple screens that share the same dependencies. In this case, you can use a lifecycle-bound graph to provide the dependencies for the entire flow. When declaring a feature-scoped graph, a single instance of the graph is created and shared between all the components and hooks that request it.
import {LifecycleBound, Graph, ObjectGraph, Provides} from 'react-obsidian';
@LifecycleBound({scope: 'feature'}) @Graph()
class AuthGraph extends ObjectGraph {
@Provides()
userService(): UserService {
return new UserService();
}
}
- Component scope: A component scope is a scope that is shared between a component and its children. For example, a form component might have multiple input fields that share the same dependencies. In this case, you can use a component-scoped lifecycle-bound graph to provide the dependencies for the form.
import {LifecycleBound, Graph, ObjectGraph, Provides} from 'react-obsidian';
@LifecycleBound({scope: 'component'}) @Graph()
class FormGraph extends ObjectGraph {
@Provides()
validationService(): ValidationService {
return new ValidationService();
}
}
Lifecycle-bound graphs are feature-scoped by default.
Passing props to a lifecycle-bound graph
When a graph is created, it receives the props of the component or hook that requested it. This means that the graph can use the props to construct the dependencies it provides. The @LifecycleBound
in the example below graph provides a userService
which requires a userId
. The userId
is obtained from props.
import {LifecycleBound, Graph, ObjectGraph, Provides} from 'react-obsidian';
type HomeScreenProps {
userId: string;
}
@LifecycleBound() @Graph()
class HomeGraph extends ObjectGraph<HomeScreenProps> {
private userId: string;
construct(props: HomeScreenProps) {
super(props);
this.userId = props.userId;
}
@Provides()
userService(): UserService {
return new UserService(this.userId);
}
}
The lifecycle of a lifecycle-bound graph
Lifecycle-bound graphs are created when they are requested and are destroyed when the last component or hook that requested them is unmounted. This means that the dependencies provided by a lifecycle-bound graph are shared between components and hooks within the same UI scope and are destroyed when the UI scope is destroyed.
Graph composition
Graph composition is a powerful feature that allows you to create complex dependency graphs by combining smaller graphs. Composing graphs is useful when you want to reuse a graph in multiple places. For example, you might have a singleton graph that provides application-level dependencies. You might also have a lifecycle-bound graph that provides dependencies for a specific UI flow. You can compose these graphs together so that the lifecycle-bound graph can also inject the dependencies provided by the singleton graph.
Subgraphs
The most common method to compose graphs is to pass a subgraphs
array to the @Graph
decorator. The subgraphs
array contains the graphs you want to "include" in your graph.
In the example below we declared a lifecycle-bound graph called LoginGraph
. This graph provides a single dependency called loginService
which has a dependency on httpClient
. Since httpClient
is exposed via the ApplicationGraph
, we included it in the subgraphs
array of our graph.
import {Graph, ObjectGraph, Provides} from 'react-obsidian';
import {ApplicationGraph} from './ApplicationGraph';
@LifecycleBound() @Graph({subgraphs: [ApplicationGraph]})
export class LoginGraph extends ObjectGraph {
@Provides()
loginService(httpClient: HttpClient): LoginService {
return new LoginService(httpClient);
}
}
Abstract graphs
Abstract graphs are graphs that are not instantiated directly. Instead, they are used as a base for other graphs. Abstract graphs are useful when you want to define a set of dependencies that are shared between multiple graphs.
In the example below we declared an abstract graph called ScreenGraph
. This graph provides a single dependency called screenLogger
which is used to log messages from the screen. We want to show the name of the screen in the log messages, so the ScreenLogger
requires the name of the screen as a constructor argument.
The screenName
provider method is marked as abstract
which means that it must be implemented by the parent class. This allows us to create multiple graphs that extend the ScreenGraph
and provide the screen name.
import {Graph, ObjectGraph, Provides} from 'react-obsidian';
export abstract class ScreenGraph extends ObjectGraph {
@Provides()
screenLogger(screenName: string) {
return new ScreenLogger(screenName);
}
abstract screenName(): string; // This method must be implemented by the parent graphs
}
The following two graphs extend the base ScreenGraph
. Each graph provides a different screen name and a service that is specific to that screen.
- HomeGraph
- ProfileGraph
import {Graph, ObjectGraph, Provides} from 'react-obsidian';
@Graph()
export class HomeGraph extends ScreenGraph {
@Provides()
override screenName() {
return 'HomeScreen';
}
@Provides()
homeService(screenLogger: ScreenLogger): HomeService {
return new HomeService(screenLogger);
}
}
import {Graph, ObjectGraph, Provides} from 'react-obsidian';
@Graph()
export class ProfileGraph extends ScreenGraph {
@Provides()
override screenName() {
return 'ProfileScreen';
}
@Provides()
profileService(screenLogger: ScreenLogger): ProfileService {
return new ProfileService(screenLogger);
}
}
Because abstract graphs aren't instantiated directly, they don't need to be annotated with the @Graph
decorator. Abstract providers aren't annotated with the @Provides
decorator for the same reason.
Typed dependencies
The DependenciesOf
utility type creates a new type consisting the dependencies provided by a graph. This type can be used to type the dependencies of hooks or props required by components. This utility type takes two arguments: the graph and a union of the keys of the dependencies we want to inject.
In this example we create a type called ApplicationDependencies
which contains the dependencies httpClient
and databaseService
from the ApplicationGraph
graph.
// {httpClient: HttpClient, databaseService: DatabaseService}
type Dependencies = DependenciesOf<ApplicationGraph, 'httpClient' | 'databaseService'>;
In cases where a graph has subgraphs, we can pass an array of graphs to the DependenciesOf
utility type to create a type that contains the dependencies from all the graphs. Using the LoginGraph
from the example above, we create a type that contains dependencies from both the LoginGraph
and the ApplicationGraph
:
// {httpClient: HttpClient, loginService: LoginService}
type Dependencies = DependenciesOf<[LoginGraph, ApplicationGraph], 'httpClient' | 'loginService'>;