Skip to main content

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.

ApplicationGraph.ts
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:

Did you know?

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.

A graph that provides a service that depends on other services
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);
}
}
info

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:

  1. 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.
A feature-scoped lifecycle-bound graph
import {LifecycleBound, Graph, ObjectGraph, Provides} from 'react-obsidian';

@LifecycleBound({scope: 'feature'}) @Graph()
class AuthGraph extends ObjectGraph {
@Provides()
userService(): UserService {
return new UserService();
}
}
  1. 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.
A component-scoped lifecycle-bound graph
import {LifecycleBound, Graph, ObjectGraph, Provides} from 'react-obsidian';

@LifecycleBound({scope: 'component'}) @Graph()
class FormGraph extends ObjectGraph {
@Provides()
validationService(): ValidationService {
return new ValidationService();
}
}
info

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.

A lifecycle-bound graph
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.

LoginGraph.ts
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.

AbstractGraph.ts
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.ts
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);
}
}
note

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'>;