Skip to content
/ nwire Public

Simplified dependency injection in TypeScript

Notifications You must be signed in to change notification settings

divmgl/nwire

Repository files navigation

nwire

Tests

nwire is a dependency injection container with a strongly-typed fluent API that makes it easier to write large type-safe applications.

import { Container, Service } from "nwire"

type MyTypedContext = {
  banner: string
  my: MyService
}

export class MyService extends Service<MyTypedContext>() {
  helloWorld() {
    return this.banner
  }
}

const context = Container.new()
  .register("banner", () => "Hello world!")
  .singleton("my", MyService)
  .context()

console.log(context.my.helloWorld()) // => console output: "Hello world!"

Installation

npm i nwire
yarn add nwire
pnpm add nwire

Getting Started

1. Create a type for your Context:

import Mailgun from "mailgun.js"

type AppContext = {
  mailgun: Mailgun
  users: UsersService
  email: EmailService
  registration: RegistrationService
}

💡 nwire can infer the context type for you, but this is a simplified example.

2. Create services.

A service is a class that has a dependency on your AppContext. nwire provides a Service class factory for you to use right away:

import { Service } from "nwire"

export class UsersService extends Service<AppContext>() {
  // Dependencies in your container are now available as getters on this.
  async findOne(id: string) {
    return await this.db.users.findOne({ where: { id } })
  }
}

export class EmailService extends Service<AppContext>() {
  async send(to: string, subject: string, body: string) {
    return this.mailgun.createMessage({ to, subject, body })
  }
}

// ...

3. Create a Container, register your dependencies and generate a Context:

// createAppContext.ts
import Mailgun from require("mailgun.js")
import formData from "form-data"

function createAppContext() {
  return Container.new<AppContext>()
    .register("mailgun", () => new Mailgun(formData))
    .singleton("users", UsersService)
    .singleton("email", EmailService)
    .singleton("registration", RegistrationService)
    .context()
}

export { createAppContext }

// Note: you can also try to omit the type and let TypeScript infer it for
// you. Using this approach will avoid having to modify multiple places to 
// introduce/remove dependencies.

function createAppContext() {
  return Container.new()
    .register("mailgun", () => new Mailgun(formData))
    .singleton("users", UsersService)
    .singleton("email", EmailService)
    .singleton("registration", RegistrationService)
    .context()
}

type AppContext = ReturnType<typeof createAppContext>

export { createAppContext, AppContext }

// ⚠️ Use this approach with caution as it can lead to circular references.

4. Pass it everywhere:

Use this Context in your entire app. It's meant to be passed around to all of your entrypoints and classes that need dependencies:

// server.ts
import { createAppContext } from "./createAppContext"
// ...

const context = createAppContext()

const server = new AwesomeHttpFrameworkServer()

server.use((req, res, next) => {
  // You decide when the context is added to the request to be used downstream
  req.context = context
  next()
})

server.get("/users/:id", async (req, res) => {
  const user = await req.context.users.findOne(req.params.id)
  res.send(user)
})
// EmailWorker.ts
import { AppContext } from "./AppContext"
import { Service } from "nwire"
// ...

export class EmailWorker extends Service<AppContext>() // Note the parens
  worker: Worker

  constructor(context: AppContext) {
    super(context)
    
    this.worker = new Worker({ 
      connection: redis, 
      handler: async (payload) => {
        await this.email.send(payload.to, payload.subject, payload.body)
      }
    })
  }
// Pass the container to literally anything

API

nwire has two high-level concepts: the Container and the Context. The Container allows you to compose a strongly-typed Context, and the Context is the proxy that resolves dependencies for you lazily. The Context lives within the Container (as a closure) and interacts with the registration of your dependencies behind the scenes.

When using the library you likely won't have to think about these semantics, but we figured it's important to understand how it works under the hood.

Container

The Container class is the main entrypoint for nwire. It provides a fluent API for registering dependencies and creating Contexts from them.

Creating a Container

You can use new Container() to create a container:

const container = new Container()

container.register("prisma", () => new PrismaClient())
container.register("redis", () => new Redis())

const context = container.context()

In a majority of cases you'll be creating a single container, registering a bunch of dependencies, and then grabbing the generated Context. For this reason we've included static methods that return a new container and are chainable, so you can write your code like this instead:

const context = Container.new()
  .register("prisma", () => new PrismaClient())
  .register("redis", () => new Redis())
  .context()

The choice is yours: you can keep the Container around in case you want to register more dependencies later, or you can create the Context immediately and use that everywhere.

Container.register

Registers a dependency with the container. The first argument is an accessor key that you'll use to access the dependency later, and the second argument is a factory function that returns the dependency:

Container.register("prisma", () => new PrismaClient()) // => Container

The factory function is called with the fully resolved Context as the first argument. This allows you to pass the Context to your dependencies:

Container.register("users", (context) => new UsersService(context)) // => Container

The Context that's sent to the dependency will be fully setup.

TypeScript

This works out of the box for JavaScript users, but what about TypeScript users? The first thing you'll run into is that while the Context is fully resolved, TypeScript doesn't yet know about all of the registrations. For instance, the following results in a compiler error:

const context = Container.new()
  .register("tasksCreator", (context) => new TasksCreator(context))
  // Argument of type '{}' is not assignable to parameter of type 'AppContext'.
  // Type '{}' is missing the following properties from type 'AppContext': tasks, tasksCreator
  .register("tasks", (context) => new SQLiteTaskStore(context))

This is because TasksCreator is asking for a fully typed context but the context at the time of registration is empty ({}). There's two main ways to overcome this:

  • If you prefer to keep a static type with your dependencies explicitly listed out, use Container.new<YourContext>() when creating the container to explicitly define the context from the beginning.
  • Use Container.singleton which handles this out of the box. You can read more about it in the Singletons section.

Container.singleton

Your goal will often be to pass in the fully resolved Context to classes. For this reason nwire provides a function that will create a new instance of your class with a fully resolved Context whenever the dependency is resolved:

Container.new().singleton("users", UsersService) // => Container

Now when context.users is accessed, nwire will call new UsersService(context) where context is a fully resolved context from your Container. It'll take the resulting instance and register it under the users name as a singleton. Follow-up calls will access the singleton instance that was created with the dependency was first resolved.

Using single This avoids the TypeScript typing issues as the interface expects the Container to be fully registered at the time of resolution.

const user = await context.users.findOne("123")

// Equivalent without nwire, sans singleton setup:
const users = new UsersService(container.context())
const user = await users.findOne("123")

You can also pass in additional arguments to the constructor:

Container.new().singleton("users", UsersService, { cookieSecret: process.env.COOKIE_SECRET })

Container.instance [deprecated]

An alias for Container.singleton.

Container.group

Sometimes you'll want to group things together within the Container. You could technically do this:

const context = Container.new()
  .register("services", (context) => ({
    users: new UsersService(context),
    tasks: new TasksService(context),
  }))
  .context()

And now all services will be nested under services:

context.services.users.findOne("123")

However, this has a big issue: once you access service for the first time you make an instance of every single class all at once.

nwire provides a solution for this: Container.group. Container.group creates a nested Container that will only resolve when you access properties within it. The nested container will be passed as the first argument to the function you pass in:

const context = Container.new()
  .group("services", (services) =>
    services
      .singleton("users", UsersService)
      .singleton("tasks", TasksService)
  )
  .context()

type AppContext = typeof context
type AppContext = {
  services: {
    users: UsersService
    tasks: TasksService
  }
}
// Two containers are used for resolution here: the root container and the nested `services` container
context.services.users.findOne("123")

Context

The Context class is the proxy that the Container produces. This class allows you to access your dependencies using the names you registered them with:

const context = Container.new()
  .register("users" /** Registry name */, () => new UsersService())
  .context() // Proxy created at this point

const user = await context.users.findOne("123")

The object returned by Context is a shallow object one-level deep with getters. For instance, considering the following type:

type AppContext = {
  services: {
    users: UsersService
    tasks: TasksService
  },
  events: {
    profileUpdated: ProfileUpdatedEvent
  }
}

The shallow object returned by Context will be:

{ services: [Getter], events: [Getter] }

It's designed like this to circumvent situations where the underlying operations done to your packages could enumerate them.

Container.context

Creates a new Context class. This is the class you're meant to pass around to all of your dependencies. It's responsible for resolving dependencies:

const context = Container.new()
  // ... lots of registrations here
  .register("users", () => new UsersService())
  .context()

const user = await context.users.findOne("123")
// `users` is resolved lazily.

nwire will only resolve dependencies when they're needed. This is an intentional design decision to avoid having to instantiate the entire Container, which is especially useful for tests. However, the type that .context() outputs will always be the fully typed Context.

Container.context<T>: ⚠️ Needed for TypeScript

It's recommended you pass an explicit type to the context function.

export type AppContext = {
  users: UsersService
  tasks: TasksService
  tasksCreator: TasksCreator
}

const context = Container.new()
  .register("tasksCreator", (context) => new TasksCreator(context))
  .register("tasks", (context) => new SQLiteTaskStore(context))
  .context<AppContext>()

Doing so helps you avoid circular dependencies.

Lifetime of a dependency

nwire will resolve dependencies for you lazily and keep an instance of the dependency as a singleton by default.

container.register("randomizer", () => new RandomizerClass())
container.resolve<RandomizerClass>("randomizer").id // => 353
container.resolve<RandomizerClass>("randomizer").id // => 353

Unless unregistered, the dependency will be kept in memory for the lifetime of the Container.

However, you can create transient dependencies by specifying the { transient: true } option:

container.register("randomizer", () => new RandomizerClass(), {
  transient: true,
})
container.resolve<RandomizerClass>("randomizer").id // => 964
container.resolve<RandomizerClass>("randomizer").id // => 248

nwire will invoke this function when the randomizer dependency is either resolved through the Container using Container.resolve or through the Context using context.randomizer.

There is currently no API for transient instance registrations, so if you do want to create a unique instance on every call you'll need to do it using register:

const context = Container.new<AppContext>()
  .register("users", (context) => new UsersService(context), { transient: true }))
  .context()

Service

Generally you want to pass your Context around to all of your class constructors and services to enable the lazy resolution of dependencies. You can do this manually by passing the Context as the first argument to your constructor:

export class MyService {
  constructor(protected context: MyTypedContext) {}
}

Now you can register the MyService class in nwire as a singleton and nwire will take care of the rest.

This will work fine for a while but then you'll run into several issues:

  • You'll have to remember to instrument the Context around to all of your dependencies
  • You'll need to call this.context to access your dependencies every time, which looks very verbose
  • this.context lives in the class and can be replaced at any moment.

To overcome these challenges you'll eventually land on a pattern that looks like this:

export class Service {
  constructor(private _context: MyTypedContext) {}
  
  // No longer possible to clobber `context`
  get context() {
    return this._context
  }

  // Can use `this.users` instead of `this.context.users`
  get users() {
    return this.context.users
  }
}

export class MyService extends Service {
  // Optional constructor but you'll need to remember it in situations where arguments are involved
  constructor(private _context: MyTypedContext, private serviceName: string) {
    super(_context)
    // Do something with `serviceName`
  }
}

This works, but again you'll outgrow this once you add more dependencies and your container gets more complex.

For this reason nwire provides a base class named Service which takes care of all of these concerns for you:

import { Service } from "nwire"

export class MyService extends Service<MyTypedContext>() { // Note the parens
  helloWorld() {
    return this.context.banner;
  }
}

Classes that extend the Service class will fit neatly into the Container.prototype.singleton API:

const context = Container.new()
  .register("banner", () => "Hello world!")
  .singleton("my", MyService) // No type errors
  .context()

context.my.helloWorld() // => console output: "Hello world!"

Service is a class factory that will take your Context and create getters for it. This way you don't have to write context getters for all of your dependencies:

export class UsersService extends Service<MyTypedContext>() {
  async findOne(id: string) {
    // No need to call `this.context.prisma.users.findOne`
    return await this.db.users.findOne({ where: { id } })
  }
}

export class UserUpdaterService extends Service<MyTypedContext>() {
  async update(id: string, name: string) {
    const existingUser = await this.users.findOne(id)
    if (!existingUser) throw new Error("User not found")
    return await this.db.users.update({ where: { id }, data: { name }})
  }
}

const context = Container.new()
  .singleton("users", UsersService)
  .singleton("userUpdater", UserUpdaterService)
  .context()

What is dependency injection?

Dependency injection is the process of keeping your components loosely coupled. This pattern makes it easy to swap out the underlying implementations of dependencies as long as the contracts stay the same.

An example

Consider a UsersService:

class UsersService {
  constructor(private psql: Postgres) {}

  async find(id: string) {
    return await this.psql.query("SELECT * FROM id WHERE id = ?", [id])
  }

  // ... other functions that use this.psql //
}

In this contrived example, the users service is tightly coupled to the Postgres client library. This means that if in the future you wanted to change the underlying access implementation, you'd have to introduce another dependency in the constructor, thus now coupling both libraries tightly to the service:

class UsersService {
  constructor(private psql: Postgres, private prisma: Prisma) {}

  async find(id: string) {
    return await this.prisma.users({ where: { id } })
  }
}

You can overcome this by using a repository:

class UsersService {
  constructor(private users: UserRepository /* Interface or type */) {}

  async find(id: string) {
    return await this.users.findOne(id)
  }
}

By passing in a repository to the constructor that encapsulates the data access concerns we can change the underlying implementation without affecting dependent services.

However, we still have an issue: we have to manually pass in UserRepository every single time:

const userRepository = new UserRepository()
const usersService = new UsersService(userRepository)

Additionally, if you pass in a variety of different dependencies often to services, you'll end up with a god object of all of your dependencies:

const dependencies = {
  usersService: new UsersService(userRepository),
  ticketingService: new TicketingService(),
}

const server = new Server(dependencies) // Massive object that's already been resolved

This is where dependency injection comes in. Dependency injection is the process of resolving a necessary dependency when needed. It works by registering all of your dependencies within a container and when a dependency is needed the framework will resolve it for you.

With nwire

Consider the previous example in nwire:

const context = Container.new()
  .singleton("users", UsersService)
  .register("prisma", new PrismaClient())
  .register("psql", new Postgres())

const user = await context.users.find("123")

nwire keeps a list of your registrations and makes them available to your services as needed. When the UsersService calls users.findOne, nwire will lazily return an instance of UserRepository.

⚠️ nwire contexts are not normal objects. nwire uses a proxy under the hood to evaluate your dependencies as needed. This is an intentional design decision to avoid having to instantiate the entire Container for tests.

License

MIT