Containers

Milan Herda

November, 2025

We will be talking about:

  • DI*
  • IoC*
  • DIP*
  • Service Container
  • DIC
  • Dependency Injection
  • Inversion of Control
  • Dependency Inversion Principle
  • Service Container
  • Dependency Injection Container (DI Container)

* covered in my previous presentation about Dependency Injection

What have we learned previously

Inversion of Control is about taking control from small units and giving it to the bigger units (application, or framework in the end).

Dependency Injection is about providing dependencies to functions, objects or modules. It is just passing arguments to functions.

Dependency Inversion Principle tells us to depend on abstractions, not on concrete implementations.

Container

Chapter 4:

I want to display the name of the currently logged in user.

import type { DbQuery } from "./db";
import type { User, UserData, UserFactoryFunc } from "./userFactory";

export interface UserRepository {
    getUser: (userId: number) => User;
    getAllUsers: () => User[];
}

export default function createUserRepository(
    db: DbQuery,
    createUser: UserFactoryFunc,
) {
    const repo: UserRepository = {
        getUser(userId: number): User {
            // ...
        },

        getAllUsers(): User[] {
            const rows = db.select("*").from("users").execute<UserData>();

            if (!rows) {
                throw new Error("User not found");
            }

            return rows.map((row) => createUser(row));
        },
    };

    return repo;
}

UserRepository from previous lesson

function App() {
    const user = userRepository.getUser(session.userId);

    return (
        <>
            <h1>User data</h1>
            <div className="card">{user.getFullname()}</div>
        </>
    );
}

export default App;

Our App code

Where do we get the userRepository from?

It is our dependency, so we should ask for it in function arguments

import type { UserSession } from "./services/session";
import type { UserRepository } from "./services/userRepository";

function App({
    userRepository,
    session,
}: {
    userRepository: UserRepository;
    session: UserSession;
}) {
    const user = userRepository.getUser(session.userId);

    return (
        <>
            <h1>User data</h1>
            <div className="card">{user.getFullname()}</div>
        </>
    );
}

export default App;

Our App code

When we meticulously apply IoC, DI and DIP, where does it end?

When all functions are asking for dependencies, something somewhere must be ultimately responsible for creation of all of them.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";

createRoot(document.getElementById("root")!).render(
    <StrictMode>
        <App />            
    </StrictMode>,
);

Our bootstrap code

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";

createRoot(document.getElementById("root")!).render(
    <StrictMode>
        <App 
            userRepository={userRepository}
            session={session}
        />            
    </StrictMode>,
);

Our bootstrap code

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import createSession from "./services/session";

const session = createSession();

createRoot(document.getElementById("root")!).render(
    <StrictMode>
        <App 
            userRepository={userRepository}
            session={session}
        />            
    </StrictMode>,
);

Our bootstrap code

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import createSession from "./services/session";
import createUserRepository from "./services/userRepository";

const userRepository = createUserRepository(dbConnection, createUser);

const session = createSession();

createRoot(document.getElementById("root")!).render(
    <StrictMode>
        <App 
            userRepository={userRepository}
            session={session}
        />            
    </StrictMode>,
);

Our bootstrap code

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import createSession from "./services/session";
import createUserRepository from "./services/userRepository";
import createConnection from "./services/db";
import createUser from "./services/userFactory";

const dbDsn = "mysql://user:password@localhost/myDb";

const dbConnection = createConnection(dbDsn);

const userRepository = createUserRepository(dbConnection, createUser);

const session = createSession();

createRoot(document.getElementById("root")!).render(
    <StrictMode>
        <App 
            userRepository={userRepository}
            session={session}
        />            
    </StrictMode>,
);

Our bootstrap code

How many responsibilities does this code have?

Rendering the application

Construction of dependencies

What we usually do with multiple responsibilities?

We split them to their own functions, or modules.

Let's separate depedencies creation to its own file

import createSession from "./services/session";
import createUserRepository from "./services/userRepository";
import createConnection from "./services/db";
import createUser from "./services/userFactory";

const dbDsn = "mysql://user:password@localhost/myDb";

const dbConnection = createConnection(dbDsn);

const userRepository = createUserRepository(dbConnection, createUser);

const session = createSession();

export default {
    db: dbConnection,
    userRepository,
    session,
}

"Dependencies creation" file

import createSession from "./services/session";
import createUserRepository from "./services/userRepository";
import createConnection from "./services/db";
import createUser from "./services/userFactory";

const dbDsn = "mysql://user:password@localhost/myDb";

const dbConnection = createConnection(dbDsn);

const userRepository = createUserRepository(dbConnection, createUser);

const session = createSession();

const container = {
    db: dbConnection,
    userRepository,
    session,
};

export default container;

"Dependencies creation" file

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import container from './container';

createRoot(document.getElementById("root")!).render(
    <StrictMode>
        <App 
            userRepository={container.userRepository}
            session={container.session}
        />            
    </StrictMode>,
);

Bootstrap code

We have created a new object holding references to all services our application needs and uses

It contains services...

Service Container

therefore we can call it...

It contains services...

It contains services...

Service Container

  • object providing access to other services
  • usually created once and used as a singleton
  • in order to access a service, each service has to have a name
  • container hides the implementation details to its clients. Client does not need to know:
    • how the service is created,
    • what are its dependencies,
    • what implementation it is
    • as long as the contract is honored

Should I just create a container and put all services of my application there?

Yes

What if I have like, hundreds or thousands of them?

It would be even better

How it can be better? Would not it be a huge object?

Yes

Service Containers are usually not implemented as simple objects.

They provide services through access methods, which allow them to use some optimization techniques.

Service Container v2

  • we make list of services private
  • and add two methods
    • getService
    • registerService
// src/framework/createContainer.ts

interface ServiceContainer {
    getService(name: string): any;
}

interface MutableServiceContainer extends ServiceContainer {
    registerService(name: string, service: any): MutableServiceContainer;
}

function createContainer(): MutableServiceContainer {
    const services: Record<string, any> = {};

    return {
        registerService(name, service) {
            services[name] = service;

            return this;
        },
        getService(name) {
            if (services[name]) {
                return services[name];
            }

            throw new Error(`No service with name ${name}`);
        },
    };
}

export default createContainer;

Service Container v2

// src/container.ts

import createContainer from "./framework/containerV2";
// ... other imports

const container = createContainer();

const dbDsn = "mysql://user:password@localhost/myDb";
const db = createConnection(dbDsn);

container.registerService("userFactory", createUser);

container.registerService("db", db);

container.registerService(
    "userRepository",
    createUserRepository(db, createUser),
);

container.registerService("session", createSession());

export default container;

Usage

Problem?

We need to know the exact order of service creation

Service Container v3

  • let's create a service only when something asks for it
  • it would allow us to not care about the order creation
  • it would also save some resources (like db connections until they are needed, etc)
// src/framework/createContainer.ts

interface ServiceContainer {
    getService(name: string): any;
}

interface MutableServiceContainer extends ServiceContainer {
    registerService(name: string, service: any): MutableServiceContainer;
}

function createContainer(): MutableServiceContainer {
    const services: Record<string, any> = {};

    return {
        registerService(name, service) {
            services[name] = service;

            return this;
        },
        getService(name) {
            if (services[name]) {
                return services[name];
            }

            throw new Error(`No service with name ${name}`);
        },
    };
}

export default createContainer;

Service Container v3

// src/framework/createContainer.ts

interface ServiceContainer {
    getService(name: string): any;
}

type ServiceFactoryFunc = (container: ServiceContainer) => any;

interface MutableServiceContainer extends ServiceContainer {
    registerService(
        name: string,
        serviceFactory: ServiceFactoryFunc,
    ): MutableServiceContainer;
}

function createContainer(): MutableServiceContainer {
    const services: Record<string, any> = {};

    return {
        registerService(name, service) {
            services[name] = service;

            return this;
        },
        getService(name) {
            if (services[name]) {
                return services[name];
            }

            throw new Error(`No service with name ${name}`);
        },
    };
}

export default createContainer;

Service Container v3

// src/framework/createContainer.ts

//...
type ServiceFactoryFunc = (container: ServiceContainer) => any;
// ...

function createContainer(): MutableServiceContainer {
    const services: Record<string, any> = {};
    const factories: Record<string, ServiceFactoryFunc> = {};

    return {
        registerService(name, serviceFactory) {
            factories[name] = serviceFactory;

            return this;
        },
        getService(name) {
            if (services[name]) {
                return services[name];
            }
          
            if (factories[name]) {
                services[name] = factories[name](this);

                return services[name];
            }


            throw new Error(`No service with name ${name}`);
        },
    };
}

export default createContainer;

Service Container v3

// src/container.ts

import createContainer from "./framework/containerV2";
// ... other imports

const container = createContainer();

const dbDsn = "mysql://user:password@localhost/myDb";

container.registerService("userRepository", (container) => {
    return createUserRepository(
        container.getService("db"),
        container.getService("userFactory"),
    );
});

container.registerService("userFactory", () => {
    return createUser;
});

container.registerService("db", () => {
    return createConnection(dbDsn);
});

container.registerService("session", () => {
    return createSession();
});

export default container;

Usage

Problem?

What if there are circular dependencies?

Service Container v4

  • let's add a check for circular dependencies
  • it manifests itself when container is asked for a service and during its creation it is asked for it once again
// src/framework/createContainer.ts

function createContainer(): MutableServiceContainer {
    // ...

    return {
        // ...
        getService(name) {
            if (services[name]) {
                return services[name];
            }
          
            if (factories[name]) {
                services[name] = factories[name](this);

                return services[name];
            }

            throw new Error(`No service with name ${name}`);
        },
    };
}

Service Container v4

// src/framework/createContainer.ts

function createContainer(): MutableServiceContainer {
    // ...
    const waitingStack: string[] = [];

    return {
        // ...
        getService(name) {
            if (services[name]) {
                return services[name];
            }
          
            if (waitingStack.find((n) => n === name)) {
                throw new Error(
                    `Circular dependencies between [${waitingStack.join(",")}, ${name}]`,
                );
            }
          
            if (factories[name]) {
                waitingStack.push(name);
                services[name] = factories[name](this);
                waitingStack.pop();

                return services[name];
            }

            throw new Error(`No service with name ${name}`);
        },
    };
}

Service Container v4

// src/container.ts

import createContainer from "./framework/containerV2";
// ... other imports

const container = createContainer();

const dbDsn = "mysql://user:password@localhost/myDb";

container.registerService("userRepository", (container) => {
    return createUserRepository(
        container.getService("db"),
        container.getService("userFactory"),
    );
});

container.registerService("userFactory", () => {
    return createUser;
});

container.registerService("db", () => {
    return createConnection(dbDsn);
});

container.registerService("session", () => {
    return createSession();
});

export default container;

Usage (same as before)

Problem?

Browser bundle size

Service Container v5

  • let's not import everything right away
  • add runtime import support
// src/framework/createContainer.ts

type ServiceFactoryFunc = (container: ServiceContainer) => Promise<any>;

function createContainer(): MutableServiceContainer {
    // ...
    
    return {
        // ...
        async getService(name) {
            if (services[name]) {
                return services[name];
            }
          
            if (waitingStack.find((n) => n === name)) {
                throw new Error(
                    `Circular dependencies between [${waitingStack.join(",")}, ${name}]`,
                );
            }
          
            if (factories[name]) {
                waitingStack.push(name);
                services[name] = await factories[name](this);
                waitingStack.pop();

                return services[name];
            }

            throw new Error(`No service with name ${name}`);
        },
    };
}

Service Container v5

// src/container.ts

import createContainer from "./framework/containerV2";

const container = createContainer();

container.registerService("userRepository", async (container) => {
    const { default: createUserRepository } = await import(
        "./services/userRepository"
    );

    return createUserRepository(
        await container.getService("db"),
        await container.getService("userFactory"),
    );
});

container.registerService("userFactory", async () => {
    const { default: createUser } = await import("./services/userFactory");

    return createUser;
});

// ...

export default container;

Usage

Problem?

Plenty of them

Other problems to solve

Depending on your use case:

  • Proper TypeScript support
  • Do not sent server code to browser
  • React Server Components
  • How to use async getService method and non-async React components together
  • ...

Service Locator

Chapter 5:

Container is created at the top of my application. 

I need a service from it in a component half-a-bazillion layers down.

Do I have to pass the service down through all intermediate layers?

No.

You can import container there and ask for services you need

// src/ui/User/Dashboard/Badges.ts

import container from '@local/container';

export default async function UserBadges() {
    const session = await container.getService('session');
    const badgesRepository = await container.getService('badgesRepository');
  
    const badges = badgesRepository.getUserBadges(session.userId);
  
    // ...
}

Service Locator

Service Locator

We are trying to locate the service in the code of our function.

And we are locating it using the Service Container

So we have all the benefits of not having to care about implementations, lazy loading etc.

Looks great

?

Service Locator

This is an antipattern. Avoid it if you can.

// src/ui/User/Dashboard/Badges.ts

import container from '@local/container';

export default async function UserBadges() {
    const session = await container.getService('session');
    const badgesRepository = await container.getService('badgesRepository');
  
    const badges = badgesRepository.getUserBadges(session.userId);
  
    // ...
}

What does this function need to work properly?

// src/ui/User/Dashboard/Badges.ts

import container from '@local/container'; 

export default async function UserBadges() {
    // ...
}

What does this function need to work properly?

We have absolutely no idea.

DI Container

Chapter 6:

Dependency Injection Container

DI Container

Implementation of Service Container

DI Container

Implementation of Service Container

DI Container

Usage of Service Container

In such a way that almost all services are registered

And their dependencies provided as arguments are also obtained from container

Its code does not have to be different from what we already saw

// src/container.ts

import createContainer from "./framework/container";


const container = createContainer();

container.registerService("userRepository", (container) => {
    return createUserRepository(
        container.getService("db"),
        container.getService("userFactory"),
    );
});

// ...

DI Container

But DI Containers usually provide more advanced features like:

  • autowiring of provided services
    • container will read parameters and will construct factory method by itself with the correct arguments
  • autodiscovery of services
    • container will look into provided folders, register all services it finds and autowires them

Most DI Containers in JS work only with classes

Autowiring is handled with the help of decorators

Autowiring for factory functions or Module Pattern modules can be solved by bundler, custom configuration or by comparing TypeScript shapes

Configuration

Chapter 7:

The code needed for service registration is often very similar

We can take advantage of that and make registration less verbose for developers

container.registerService("userRepository", (container) => {
    return createUserRepository(
        container.getService("db"),
        container.getService("userFactory"),
    );
});
userRepository:
  path: '@local/service/userRepository'
  params:
    db: @db
    createUser: @userFactory
container.registerService("userRepository", (container) => {
    return createUserRepository(
        container.getService("db"),
        container.getService("userFactory"),
    );
});
userRepository:
  path: '@local/service/userRepository'

With autowiring in place

What have we learned so far

Inversion of Control is about taking control from small units and giving it to the bigger units (application, or framework in the end).

Dependency Injection is about providing dependencies to functions, objects or modules. It is just passing arguments to functions.

Dependency Inversion Principle tells us to depend on abstractions, not on concrete implementations.

What have we learned so far

Service Locator is using the Container in a bad way. Having only half of the benefits and hiding function dependencies.

Service Container is an object encapsulating all services your application needs and providing access to them.

Dependency Injection Container is advanced usage of a Service Container where it handles injection of dependencies for all services.

Main befetits

Main benefits of having a Service Container are:

  • dependencies of every service are visible from its parameters
  • we can replace implementation of a service without the need to touch the rest of the application
    • providing we honor the same contract

Thank you!

Any questions?

Containers

By Milan Herda

Containers

  • 42