Dependency Injection and Wiring
Relevant source files
The following files were used as context for generating this wiki page:
Ghostbird employs a lightweight, custom-built dependency injection (DI) system to manage object creation and their interdependencies. This system is a core architectural feature designed to promote the Dependency Inversion Principle (DIP), enhancing testability and maintainability. The primary goal is to make the codebase more resilient to breaking changes in the Thunderbird API.
The DI mechanism is centered around a "convention over configuration" approach, where classes are automatically discovered and wired together based on static properties and constructor signatures. All automatic instantiation is confined to the root/ module, specifically within the startup_*.ts files, which act as the project's Composition Root. This avoids the use of a third-party DI library, providing a solution tailored to the project's needs.
Sources: doc/faq-architectural.md, doc/design.md:80-84
Core Concepts
Automatic Instantiation
The system automatically discovers and instantiates classes that are marked with a static isSingleton property. The startup_*.ts files scan for these classes and manage their lifecycle. This automates the new calls, but dependencies are still explicitly declared in class constructors.
static isSingleton: boolean: This property signals to the DI container that the class should be managed.- If
true, a single instance of the class is created and shared (singleton scope). - If
false, a new instance is created every time it's requested as a dependency (transient scope). - If the property is missing, the class is ignored by the automatic instantiation process.
- If
Sources: doc/faq-architectural.md:5-7, doc/design.md:121-125, src/root/util/wireless.ts:13-19
Composition Root
All object graph construction is centralized in the root/startup/startup_*.ts files. Each of these files corresponds to a top-level entry point of the extension (background.ts, compose.ts, options.ts). This practice ensures that the core application logic remains decoupled from the specifics of object creation.
Sources: doc/design.md:117-119, doc/faq-architectural.md:40-42
Dependency Inversion Principle (DIP)
The DI system is a practical application of DIP. By having high-level modules depend on abstractions (interfaces or simple classes) that are injected, rather than concrete low-level implementations, the system becomes more modular and easier to test. This is particularly important for isolating the application from the volatile Thunderbird MailExtension API.
Sources: doc/faq-architectural.md:23-25, doc/design.md:80-84
The Wiring Mechanism
The wiring process is handled by a set of utility functions and interfaces primarily located in src/root/util/. The wireless function is the high-level entry point that orchestrates the entire process based on conventions.
Sources: src/root/util/wireless.ts, src/root/util/wire.ts
Class Discovery and Dependency Resolution
The wireless function scans provided modules for exported classes that have the static isSingleton property. For each discovered class, it parses the constructor to determine its dependencies.
This diagram illustrates the discovery and instantiation flow for a dependency.
Diagram based on logic from doc/design.md and src/root/util/wire.ts.
A key convention is that dependency names are inferred from property assignments within the constructor. The parseConstructorForDependencyNames function uses a regular expression to extract these names.
// src/root/util/wireless.ts:93-104
function parseConstructorForDependencyNames(ctor: { length: number }): string[] {
if (ctor.length === 0) {
return []
}
// Extract property initializations to get names.
// I believe Rolldown keeps property names.
const matches = /\bconstructor\s*[(]\s*[{]?\s*[^{}]+[}]?[^{]+[{](https://github.com/exteditor/ghostbird/blob/HEAD/%5B%5E%7D%5D%2B)/
.exec(ctor as unknown as string)?.[1]
?.matchAll(/\bthis[.]#?([^\s=]+)\s*=/g)
if (!matches) {
throw Error(`Failed to parse ${ctor}`)
}
return Array.from(matches, (m) => m[1] ?? "")
}
Sources: src/root/util/wireless.ts:93-104
Core Interfaces and Classes
The DI system is built upon a few key interfaces and a central Wire class.
| Interface/Class | File | Description |
|---|---|---|
IClassInfo<T> | wire.ts | Contains metadata about a class to be registered, including its name, constructor, dependencies, and lifecycle (isSingleton). |
IRegistry<T> | wire.ts | A map-like interface for storing dependency registration queries. It maps a dependency name to a ResolveQuery. |
IResolver<T> | wire.ts | Defines methods (resolveOne, resolveAll) for retrieving instances from the container. |
IWire<T> | wire.ts | The main DI container interface, providing the wire method to instantiate a class with its dependencies. |
Wire<T> | wire.ts | The concrete implementation of IWire and IResolver. It manages instance caching and construction. |
Sources: src/root/util/wire.ts:14-89
This class diagram shows the relationships between the core components.
Sources: src/root/util/wire.ts
The wireless Function
The wireless function provides a convention-based wrapper around the core wire function. It automates the process of collecting class information from modules.
The general flow is as follows:
wirelessis called with a list of modules.- It iterates through the modules, calling
listClassesto find all classes with theisSingletonstatic property. - For each class, it creates an
IClassInfoobject, parsing its constructor for dependency names. - It passes this collection of
IClassInfoobjects to the lower-levelwiredfunction (which useswire). wiredregisters all classes and their aliases in theIRegistry.- Finally,
wirelessreturns aninjectorfunction that can be used to create instances. This injector is also registered in the container itself under the key"$injector".
Sources: src/root/util/wireless.ts:32-46
Configuration by Convention
Classes control their registration and dependency injection behavior through static properties.
| Property | Type | Description |
|---|---|---|
isSingleton | boolean | Required. If true, the class is a singleton. If false, it's transient. |
wantArray | boolean | Optional. If true, the constructor receives an array of all dependencies registered with a given name. Useful for aggregates. |
aliases | string | string[] | Optional. Provides one or more alternative names under which the class can be resolved as a dependency. |
Sources: src/root/util/wireless.ts:13-27, doc/design.md:125-141
Dependency Graph and Analysis
To provide visibility into the automatically wired dependencies and to aid in debugging, the project includes a dependency reporting tool.
Running the test suite (yarn test) generates a build/test/dependency_tree.md file. This file contains a Mermaid diagram of the full dependency graph and an example startup function, which is useful for understanding the object graph.
This functionality is implemented in src/test/util/dependency_reporter.ts and triggered by src/test/startup.test.ts.
The process is as follows:
- The
startup.test.tstest collects all modules. - It calls
collectDependenciesto build an adjacency list of dependencies from the registry. - It then calls
dumpDependencies, which usesmakeMermaidto generate the graph visualization anddumpStartupCodeto create the example code. - The final markdown content is written to a file.
Sources: doc/faq-architectural.md:17-19, src/test/util/dependency_reporter.ts:12-21, src/test/startup.test.ts:53-59
This diagram shows the flow of dependency report generation.
Sanity Testing
While the automatic wiring sacrifices some compile-time type safety, this is partially mitigated by sanity tests. The tests in src/test/startup.test.ts ensure that all registered dependencies can be successfully resolved and instantiated, catching issues like missing definitions or dependency cycles early. The test also checks that all declared dependencies in a constructor are actually used within the class body.
Sources: doc/faq-architectural.md:11-13, src/test/startup.test.ts:44-51, 80-92