Skip to content

romeerez/dimpl

Repository files navigation

dimpl

Simple dependency injection for TypeScript and JavaScript.

  • Works with functions and classes.
  • Dependencies stay visible in plain code.
  • No decorators.
  • No metadata reflection.
  • No class-only container model.

dimpl treats a function or class as the dependency key. The first time you ask for it, the container creates an instance and caches it. Every later lookup in the same container returns the same instance.

import { dimpl } from "dimpl";

const di = dimpl();

const Config = () => ({
  dbUrl: process.env.DATABASE_URL ?? "postgres://localhost/app",
});

const Db = (config = di.get(Config)) => {
  return connect(config.dbUrl);
};

const Users = ({ db } = di.deps({ db: Db })) => {
  return {
    findById(id: string) {
      return db.user.findUnique({ where: { id } });
    },
  };
};

const users = di.get(Users);

Installation

npm install @romikus/dimpl

Motivation

With dimpl, dependencies are just default parameters:

const UsersService = (
  { db, logger } = di.deps({
    db: Db,
    logger: Logger,
  }),
) => {
  return {
    list() {
      logger.info("Listing users");
      return db.query("select * from users");
    },
  };
};

Single dependency:

const StripeClient = (config = di.get(Config)) => {
  return new Stripe(config.stripeSecretKey);
};

Class dependency:

class Clock {
  now() {
    return new Date();
  }
}

const Tokens = ({ clock } = di.deps({ clock: Clock })) => {
  return {
    issue() {
      return { issuedAt: clock.now() };
    },
  };
};

Startup side effects:

const app = di.get(App);

di.wire(HealthController, AuthController, UsersController);

serve({ fetch: app.fetch, port: 3000 });

Controllers can register routes when they are instantiated. Services can stay lazy until something needs them.

request-scoped lifetime

dimpl is designed only around a singleton runtime. Every dependency resolved by a container is cached as one instance for that container.

Disregarding of a DI library, AsyncLocalStorage is a much more convenient way of handling request-scoped or more granular scoped dependencies anyway.

You can use a dimpl singleton instance of AsyncLocalStorage to retrieve request-scoped dependencies from it.

For example, imagine a pino logger is request-scoped. When a request starts, you create a child logger with userId. Services use that child logger without becoming request-scoped themselves.

import { AsyncLocalStorage } from "node:async_hooks";
import pino, { type Logger } from "pino";

type RequestScope = {
  logger: Logger;
};

const RootLogger = () => pino();

const RequestScope = () => {
  return new AsyncLocalStorage<RequestScope>();
};

const RequestLogger = (
  { rootLogger, requestScope } = di.deps({
    rootLogger: RootLogger,
    requestScope: RequestScope,
  }),
) => {
  return {
    get() {
      return requestScope.getStore()?.logger ?? rootLogger;
    },
  };
};

const OrdersService = (
  { logger } = di.deps({
    logger: RequestLogger,
  }),
) => {
  return {
    createOrder(userId: string) {
      logger.get().info({ userId }, "Creating order");
      // ...
    },
  };
};

const requestScope = di.get(RequestScope);
const rootLogger = di.get(RootLogger);
const ordersService = di.get(OrdersService);

app.use(async (req, res, next) => {
  const userId = await getUserId(req);
  const logger = rootLogger.child({ userId });

  requestScope.run({ logger }, () => {
    next();
  });
});

app.post("/orders", async (req, res) => {
  const userId = await getUserId(req);
  ordersService.createOrder(userId);
  res.sendStatus(201);
});

The container still has singleton services. The request-specific value lives in AsyncLocalStorage, and services read it at call time.

Async Constructors

dimpl does not support async constructors in any special way for now. This keeps the library simple, and async dependency construction is rare enough that it may not be worth adding container-level machinery for it.

When a dependency must be awaited before use, do that in your app setup and seed the resolved instance with di.set.

For example, imagine an AMQP library requires an async connection before anything can publish messages:

import { connect, type Channel } from "amqplib";

export const Amqp = (): Channel => {
  throw new Error("amqp instance must be set in the setup");
};

export const NotificationsService = (amqp = di.get(Amqp)) => {
  return {
    async sendWelcomeEmail(userId: string) {
      await amqp.sendToQueue("emails", Buffer.from(JSON.stringify({ type: "welcome", userId })));
    },
  };
};

export async function setup() {
  const connection = await connect(process.env.AMQP_URL);
  const channel = await connection.createChannel();

  di.set(Amqp, channel);

  return {
    notificationsService: di.get(NotificationsService),
  };
}

The throwing factory is just the dependency key and type source. If setup forgets to provide the real instance, the app fails with a clear error.

Unit Testing

Because dependencies are normal function parameters, the simplest test does not need a container at all. Pass every dependency explicitly.

import { describe, expect, it, vi } from "vitest";

const AuthService = (
  { users, tokens } = di.deps({
    users: UsersRepo,
    tokens: TokenService,
  }),
) => {
  return {
    async login(email: string, password: string) {
      const user = await users.verifyPassword(email, password);
      return tokens.sign(user.id);
    },
  };
};

it("logs in a user", async () => {
  const users = {
    verifyPassword: vi.fn().mockResolvedValue({ id: "user_1" }),
  };

  const tokens = {
    sign: vi.fn().mockReturnValue("jwt"),
  };

  const auth = AuthService({ users, tokens });

  await expect(auth.login("a@example.com", "secret")).resolves.toBe("jwt");
  expect(users.verifyPassword).toHaveBeenCalledWith("a@example.com", "secret");
  expect(tokens.sign).toHaveBeenCalledWith("user_1");
});

Use di.set when you want most of the dependency graph to stay real, but one or two dependencies should be mocked. di.deps will keep resolving everything normally, and the dependencies you seeded with di.set will be returned instead of being created.

it("uses a real dependency graph with one mocked dependency", async () => {
  const di = dimpl();

  const Db = () => connect(process.env.DATABASE_URL);

  const Logger = () => ({
    info: vi.fn(),
  });

  const UsersRepo = (
    { db, logger } = di.deps({
      db: Db,
      logger: Logger,
    }),
  ) => ({
    async findById(id: string) {
      logger.info(`Loading user ${id}`);
      return db.users.findById(id);
    },
  });

  const fakeDb = {
    users: {
      findById: vi.fn().mockResolvedValue({ id: "user_1" }),
    },
  };

  di.set(Db, fakeDb);

  const users = di.get(UsersRepo);

  await expect(users.findById("user_1")).resolves.toEqual({ id: "user_1" });
  expect(fakeDb.users.findById).toHaveBeenCalledWith("user_1");
  expect(di.get(Logger).info).toHaveBeenCalledWith("Loading user user_1");
});

Example App

See example/ for a sample API using dimpl.

It shows:

  • example/src/di.ts: exporting one app container.
  • example/src/server.ts: getting the app and wiring controllers.
  • example/src/modules/auth/auth.controller.ts: injecting multiple dependencies with di.deps.
  • example/src/infrastructure/db.ts: injecting a single dependency with di.get.
import { dimpl } from "dimpl";

Creating a Container

import { dimpl } from "dimpl";

export const di = dimpl();

Each call to dimpl() creates an independent container with its own cache.

const appDi = dimpl();
const testDi = dimpl();

const Db = () => ({ connected: true });

appDi.get(Db) === appDi.get(Db); // true
appDi.get(Db) === testDi.get(Db); // false

Use this when:

  • You want one container for the whole app.
  • You want a fresh container per test.
  • You want distinct containers for multiple apps, tenants, workers, or integration-test scenarios.

API

dimpl()

Creates a new DI container.

const di = dimpl();

The returned container has four methods:

  • get
  • deps
  • set
  • wire

It also owns its own instance cache. No state is shared between containers.

di.get(fnOrClass)

Creates or returns the cached instance for one dependency.

const Config = () => ({ port: 3000 });

const config = di.get(Config);

Use get when:

  • You need one dependency.
  • You are bootstrapping the app.
  • A factory depends on one other dependency.

With classes:

class Logger {
  info(message: string) {
    console.log(message);
  }
}

const logger = di.get(Logger);

get is lazy. The function or class is called only once per container, then cached.

di.deps({ name: fnOrClass })

Creates or returns multiple dependencies as a typed object.

const Service = (
  { db, logger } = di.deps({
    db: Db,
    logger: Logger,
  }),
) => {
  return {
    run() {
      logger.info("Running");
      return db.query("select 1");
    },
  };
};

Use deps when:

  • A factory needs more than one dependency.
  • You want a readable dependency list at the top of a function.
  • You want TypeScript to infer a named object of dependency instances.

The object keys are yours. The values are dependency factories or classes. The returned object has the same keys, but each value is the resolved instance.

deps can also handle circular dependencies when access is delayed until after construction. See Circular Dependencies.

di.set(fnOrClass, instance)

Stores an instance for a dependency key.

const fakeLogger = {
  info() {},
};

di.set(Logger, fakeLogger);

di.get(Logger) === fakeLogger; // true

Use set when:

  • You want to override a dependency in a test.
  • You already created an instance yourself.
  • You want to provide an adapter from outside the container.

Call set before the dependency is resolved if you want consumers to receive the override.

di.wire(...fnOrClass)

Eagerly instantiates dependencies and returns nothing.

di.wire(HealthController, AuthController, ProductsController);

Use wire when:

  • A dependency exists for side effects.
  • Controllers register routes during construction.
  • Startup should fail immediately if a dependency cannot be created.

wire uses the same cache as get and deps. If something was already created, it will not be created again.

dimplCircularDepError

dimplCircularDepError is exported for code that wants to recognize container-level circular dependency failures.

import { dimplCircularDepError } from "dimpl";

Most application code does not need to catch it. Prefer structuring circular references so they are accessed lazily through methods or getters.

Circular Dependencies

If two services depend on each other, di.get inside those services will not work. It tries to resolve the dependency immediately, so the container detects that the first service is still being created and throws a dimplCircularDepError.

const A = (b = di.get(B)) => ({
  name: "a",
  b,
});

const B = (a = di.get(A)) => ({
  name: "b",
  a,
});

di.get(A); // throws dimplCircularDepError

Use di.deps for circular dependencies, and keep access lazy. A service cannot read a dependency that is still being constructed. That includes destructuring it or reading it in the factory body.

const A = (deps = di.deps({ b: B })) => {
  return {
    name: "a",
    getB() {
      return deps.b;
    },
  };
};

const B = (deps = di.deps({ a: A })) => {
  deps.a; // throws: A is not ready yet

  return {
    name: "b",
  };
};

Access the circular dependency later from a method or getter:

const A = (deps = di.deps({ b: B })) => ({
  name: "a",
  getB() {
    return deps.b;
  },
});

const B = (deps = di.deps({ a: A })) => ({
  name: "b",
  getA() {
    return deps.a;
  },
});

const a = di.get(A);
const b = di.get(B);

a.getB() === b; // true
b.getA() === a; // true

For circular dependencies, avoid destructuring the circular dependency in the parameter list:

// Avoid this for circular dependencies.
const B = ({ a } = di.deps({ a: A })) => ({
  useA() {
    return a;
  },
});

Destructuring reads a during construction. Keep the deps object and read deps.a later.

Patterns

Export One App Container

// di.ts
import { dimpl } from "dimpl";

export const di = dimpl();

Then import it from factories:

import { di } from "./di";

export const Orders = ({ db, payments } = di.deps({ db: Db, payments: Payments })) => {
  return {
    create() {
      // ...
    },
  };
};

Prefer Default Parameters

Default parameters make production code automatic and tests explicit.

export const Orders = (
  { db, payments } = di.deps({
    db: Db,
    payments: Payments,
  }),
) => {
  // ...
};

In production:

const orders = di.get(Orders);

In tests:

const orders = Orders({ db: fakeDb, payments: fakePayments });

Keep Constructors Simple

Classes are supported, but dimpl constructs them without arguments.

class Ids {
  create() {
    return crypto.randomUUID();
  }
}

di.get(Ids);

If a dependency needs other dependencies, a factory function with default parameters is usually clearer.

const Orders = ({ db, ids } = di.deps({ db: Db, ids: Ids })) => {
  // ...
};

License

ISC

About

Simple dependency-injection library

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors