This is part 1 of 3 — continue the same article, no intro or recap.
The Role of Dependency Injection in Modern Web Development
Dependency Injection (DI) has been a staple of object-oriented programming for years, particularly in server-side and native applications. At its core, it is a mechanism for realizing Inversion of Control (IoC) between a class and its collaborators. As enterprise-oriented front-end frameworks such as Angular and Ember have gained traction, the notion of a DI container has become familiar territory for a growing number of web developers.
Our engineering team, coming largely from an enterprise Java background, has consistently witnessed the architectural advantages that DI brings to an application. It should come as no surprise, then, that we adopted this approach within the ag-Grid codebase. Yet DI remains a somewhat abstract and elusive concept — which is precisely why it can feel so difficult to grasp. In fact, there’s an entire book dedicated to this single pattern and the accompanying DI (IoC) Container.
Reading that book, however, reveals that much of its content is only tangentially relevant to front-end development. The discussions lean heavily toward native application architectures built with Java or C#. When it comes to understanding why DI matters in web applications, the available literature is sparse. Most explanations you’ll encounter focus almost exclusively on how DI simplifies unit testing. But it’s worth emphasizing that the scope of DI goes far beyond testability alone.
The goal of this article is to clarify why a web application might need DI in the first place. I’ll illustrate this by walking through the reasoning behind introducing DI into the ag-Grid stack and how we put it into practice. To close out, I’ll present a high-level architectural view of the DI Container that we built from scratch within the grid itself.
With that, let’s dive in.
Why Dependency Injection matters for web apps and ag-Grid
Across the many articles written on DI, two principal advantages tend to surface repeatedly: promoting loose coupling between pieces of code, and simplifying how tests are set up. The second benefit—especially the ability to swap in stubs—becomes obvious the moment you try it. The first, however, is more abstract and demands a bit of theoretical grounding to fully appreciate.
Software requirements shift constantly, which means we spend a fair amount of our time refactoring and adjusting existing code. Loose coupling—writing code where components don’t depend tightly on one another—serves as a powerful ally in this ongoing effort. When two code blocks are loosely coupled, altering one rarely forces a change in the other. The fewer places you must touch to implement a change, the more straightforward the maintenance becomes. In short, the key payoff of loose coupling is improved maintainability across the entire solution.
In statically typed OOP languages like Java or C#, one way to foster loose coupling is to code against interfaces rather than concrete classes. This is exactly where DI shines. A quote from this article on DI containers captures the idea well:
DI containers assume you already have abstractions in place. To put it plainly, the primary reason for using a container is to preserve loose coupling in your code. We achieve this by designing our types so they avoid direct dependencies on other concrete classes. Whether a type sits high or low in the hierarchy, the container expects it to rely on abstractions rather than on other concrete implementations.
But web applications don’t really have interfaces in the traditional sense. Every class in JavaScript is concrete. So does DI still deliver loose coupling in a web context? At ag-Grid, we believe it does.
You’ve likely encountered the Law of Demeter (LoD), sometimes called the principle of least knowledge. In broad strokes, the LoD is a particular flavor of loose coupling. Following this law helps you stay loosely coupled by making sure an object knows as little as possible about the inner structure or properties of other objects. That minimal knowledge includes the details of how another object’s dependencies are constructed. Without DI, though, the opposite tends to happen—information about how to locate a dependency and how to build it spreads throughout your codebase.
Let’s look at a concrete example.
Inside ag-Grid, there’s a RowRenderer service that relies on ColumnController, GridApi, and EventService. Naturally, we need to get references to these services inside the RowRenderer class somehow. Here’s what the code would look like if we skipped DI for RowRenderer:
class RowRenderer {
private columnController: ColumnController;
private gridApi: GridApi;
private eventService: EventService;
constructor() {
this.columnController = new ColumnController();
this.gridApi = new GridApi();
this.eventService = new EventService();
}
}
In this version, we simply instantiate every object RowRenderer needs directly inside its constructor. Several issues emerge with this approach. First, the newly created instances are private to the class—if another class needs them, it has to build its own copies, leading to duplicated code and wasted memory. Second, if any of these services must be singletons, this pattern won’t hold up. Third, mocking services that are constructed inside a class during tests becomes quite awkward to arrange. Finally, RowRenderer ends up knowing far too much about how its dependencies are constructed. If you alter the number or order of parameters in any of the services RowRenderer depends on, you’ll have to hunt down and update every place those services are used. Wiring dependencies this way violates the Law of Demeter and results in tight coupling. But realistically, without Dependency Injection, it’s the best you can do.
With that in mind, ag-Grid embraced the DI pattern from day one. Initially, we followed what’s known as Pure DI, where the application code itself handles all component creation explicitly. No external tools or dedicated wiring modules are involved—just plain code that defines how components are made and connected.
In the early ag-Grid versions, this is how RowRenderer received its dependencies. Every class exposed an init method through which its dependencies were handed in:
class RowRenderer {
private columnController: ColumnController;
private gridApi: GridApi;
private eventService: EventService;
public init(columnController, gridApi, eventService) {
this.columnController = columnController;
this.gridApi = gridApi;
this.eventService = eventService;
}
}
A separate function named setupComponents took care of instantiating and wiring all the dependencies together:
export class Grid {
private setupComponents(...) {
// create all the services
var columnController = new ColumnController();
var gridApi = new GridApi();
var eventService = new EventService();
// intialize the service by passing the dependencies
rowRenderer.init(
...,
columnController,
gridApi,
eventService,
...
);
}
}
You could think of this function as a Composition Root—the single place in an application where the entire object graph is assembled in one go. Because the Composition Root builds the whole graph, it has the full context at its disposal, which lets it make smart calls about which dependency should go where.
This style of dependency wiring resolved most of the problems described earlier. But if you dig into the [setupComponents](https://github.com/ag-grid/ag-grid/blob/3.3.0/src/ts/grid.ts#L180) source, you’ll notice just how much code lives inside it—the function spans 140 lines. That’s a hefty chunk of code. You don’t need to read every line to see that it demands ongoing maintenance. Hidden within is a large object graph, with some shared sub-graphs, and since everything is created with the new keyword, any change to a constructor signature forces you to update this code or it won’t compile.
Beyond that, dependency chains can get deeply nested, making manual wiring increasingly unwieldy. Consider this snippet as an illustration:
var svc = new ShippingService(new ProductLocator(),
new PricingService(), new InventoryService(),
new TrackingRepository(new ConfigProvider()),
new Logger(new EmailLogger(new ConfigProvider())));
So, after version 3, it became evident that we needed a dedicated module to handle dependency wiring and solve these problems systematically. That’s exactly what a Dependency Injection Container provides.
Before we dive into the Container, I’d like to touch on how DI simplifies testing. Testing boils down to instantiating small slices of your application, running them through various scenarios, and checking the output. If that initial instantiation step is painful, testing quickly becomes a chore.
To test a class, for instance, you have to create an instance of it, which means its constructor gets invoked. Ideally, you’d want that constructor to contain nothing but simple assignments to keep test setup easy. Without DI, though, constructors end up full of logic for locating and building dependencies. DI removes that extra work from the constructor, making the code far simpler to test. And when a standalone DI container handles dependencies for you, you can focus your tests purely on business logic—wiring is the container’s job and should just work.
Bringing a DI container into ag-Grid
Now, at last, we can talk about DI Containers in earnest. As I showed above, with Pure Dependency Injection, application code explicitly creates every component. There’s no specialized module devoted to wiring dependencies. But Pure DI is uncommon. Most frameworks and applications include a helper module called a DI Container that automates instantiation and wiring. Angular and Ember both have DI Containers.
Composing object graphs by convention with a DI Container offers a rare chance to move infrastructure concerns into the background.
The container knows how to construct all your objects and their dependencies, so you can fetch a fully wired object with just a single call. It automatically creates an instance of the requested class and injects all required dependencies through the constructor at runtime, then cleans it up at the proper time. This spares us from having to create, maintain, and manage objects manually—because doing that correctly is genuinely difficult.
Every service can have different requirements. Some need to exist as a single shared instance across the app; others should get a fresh instance each time they’re requested; still others might want instances scoped to a particular cell or some other arbitrary boundary. Satisfying these varied needs while keeping things loosely coupled demanded a substantial amount of code that swelled in both size and complexity with each change.
The DI container was intended to replace that code. Since ag-Grid’s philosophy is to keep zero external dependencies, we built our own container implementation. We modeled it after the IoC container in the Spring framework. Because we develop in TypeScript, which supports decorators (annotations), we were able to carry over some design decisions from Spring. In particular, we use the @Bean decorator to mark a service as managed by the DI container and @Autowired to specify dependencies the container should inject.
This is how the code looks today:
@Bean("rowRenderer")
export class RowRenderer extends BeanStub {
@Autowired("columnController") private columnController: ColumnController;
@Autowired("gridApi") private gridApi: GridApi;
@Autowired("eventService") private eventService: EventService;
}
There are no assignments inside the service anymore—the container handles all of that. In earlier versions, we also had that setupComponents function responsible for instantiation and wiring. In the current version, that logic has moved inside the DI container, implemented as the Context class. All we need to do in the Grid is define our services (beans) and create the container instance:
export class Grid {
private context: Context;
...
constructor() {
...
const contextParams = {
beans: [
ColumnController, GridApi, EventService,
...moduleBeans
],
...
};
this.context = new Context(contextParams, ...);
}
}
With the DI container in place, the code has become much more readable, easier to reason about, and simpler to test.
Before we examine how it all works under the hood, here’s a note on terminology that I came across here. Throughout this article, I’ve referred to the Dependency Injection Container, but Inversion of Control Container might be a more accurate label. The linked article explains this in depth, but the essence is:
Using the word “injection” highlights the process of building the dependency graph, while overlooking the rest of what the container does—most notably managing the entire lifecycle of an object, not merely its construction.
Feel free to pick whichever term resonates with you. We’re now set to explore the inner workings of the DI (IoC) container inside ag-Grid.
Understanding the DI (IoC) Container Implementation in ag-Grid
Every DI container needs to support a straightforward lifecycle that developers can rely on. The core responsibilities include:
- Bean configuration
The container needs a mechanism to know which concrete implementation should be created when a specific type is requested. This typically involves some form of type-to-implementation registration. - Bean instantiation
With an IoC container in place, manual object creation becomes unnecessary. The container handles instantiation behind the scenes. - Bean wiring
The container must offer methods to resolve a given type. When resolving, it creates the requested object, injects any required dependencies, and returns the fully constructed instance. - Bean lifecycle management
The container is responsible for overseeing the lifetime of dependent objects and cleaning them up when they are no longer in use.
Within the ag-Grid codebase, the Context class serves as the IoC container. It handles the instantiation, configuration, and assembly of services (beans). The container relies on configuration metadata to determine what objects to create, how to configure them, and how to wire them together. This metadata comes from TypeScript annotations and a JavaScript configuration object that the context receives during its initialization.
Container initialization and setup
The container gets created within the constructor of the main Grid service, which runs when the datagrid is initialized:
export class Grid {
private context: Context;
...
constructor() {
...
const contextParams = {
beans: [
ColumnController, GridApi, EventService,
...moduleBeans
],
...
};
this.context = new Context(contextParams, ...);
}
}
The constructor receives a configuration object called contextParams, which describes all the beans that the container needs to manage.
Describing beans
To define a service that the container should manage, two core annotations are used. The Bean annotation designates the name of the service within the container. The Autowired annotation declares a dependency on another bean. Here is an illustration of their usage:
@Bean("rowRenderer")
export class RowRenderer extends BeanStub {
@Autowired("columnController") private columnController: ColumnController;
}
The [Bean](https://github.com/ag-grid/ag-grid/blob/646ec8f0f580eb664d411b43ed907efdab64caf7/packages/ag-grid-community/src/ts/context/context.ts#L328) function appends metadata to the service's prototype class through the __agBeanMetaData property, which is created inside the getOrCreateProps utility:
export function Bean(beanName: string): Function {
return (classConstructor: any) => {
const props = getOrCreateProps(classConstructor);
props.beanName = beanName;
};
}
The [Autowired](https://github.com/ag-grid/ag-grid/blob/646ec8f0f580eb664d411b43ed907efdab64caf7/packages/ag-grid-community/src/ts/context/context.ts#L347) function appends the dependency's object name to the agClassAttributes array stored on the __agBeanMetaData property:
function autowiredFunc(...) {
...
props.agClassAttributes.push({
attributeName: methodOrAttributeName,
beanName: name,
optional: optional
});
}
During service instantiation, the container reads the information captured by these annotations.
Bean creation and dependency wiring
As soon as the container is created, it eagerly instantiates and wires all the beans:
export class Context {
...
public constructor(params: ContextParams, logger: ILogger) {
...
this.createBeans();
const beanInstances = this.getBeanInstances();
this.wireBeans(beanInstances);
}
}
The createBeans function produces instances of all beans that fall under the container's management. After all beans exist, the wireBeans function connects them and invokes lifecycle hooks such as preConstructMethods and postConstructMethods.
The actual wiring is carried out by the [autoWireBeans](https://github.com/ag-grid/ag-grid/blob/03fa336d353ca14786588cc273bf166b38609b56/packages/ag-grid-community/src/ts/context/context.ts#L171) function, which gets called from wireBeans. This function iterates through every service, pulls dependency metadata from agClassAttributes, locates the matching dependency service, and assigns it to the property indicated by the metadata. The implementation looks like this:
private autoWireBeans(beanInstances: any[]): void {
beanInstances.forEach(beanInstance => {
this.forEachMetaDataInHierarchy(beanInstance, (metaData: any, beanName: string) => {
const attributes = metaData.agClassAttributes;
if (!attributes) {
return;
}
attributes.forEach((attribute: any) => {
const otherBean = this.lookupBeanInstance(beanName, attribute.beanName, attribute.optional);
beanInstance[attribute.attributeName] = otherBean;
});
});
});
}
Since the container creates all services eagerly during container creation, there is no need to build a dependency graph for lazy instantiation scenarios.
Public container methods
The container also exposes a minimal API for retrieving bean instances or tearing down the container. A typical usage example involves requesting the eventService from the container:
private dispatchGridReadyEvent(gridOptions: GridOptions): void {
const eventService: EventService = this.context.getBean('eventService');
...
}
Calling destroy on the container instance triggers the preDestroyMethods lifecycle hook on all managed beans.
