Skip to content

Commit

Permalink
README.md finished, API finished for 1.0
Browse files Browse the repository at this point in the history
  • Loading branch information
divmgl committed Oct 26, 2023
1 parent 40ca946 commit 99a3b7d
Show file tree
Hide file tree
Showing 11 changed files with 405 additions and 82 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
README.md
224 changes: 222 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import { Container } from "nwire"

const context = Container
//
.register("prisma", new PrismaClient())
.register("redis", new Redis())
.group("services", (container: Container) =>
Expand All @@ -36,7 +35,228 @@ class UsersService {

## API

(Coming soon)
`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 `Context`s from them.

#### Creating a `Container`

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

```tsx
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:

```tsx
const context = Container
.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 simply grab the `Context`.

#### `Container.register`

Registers a dependency with the container.

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

The second argument is a function that returns the dependency.

Optionally you have access to the fully resolved `Context` at reolution time, in case you wish to do something with it:

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

> ⚠️ Due to `nwire`'s design, the `Context` that's sent to the dependency when it's resolved will always be the fully registered one.
This may not match with what the compiler sees (as TypeScript is only able to see what's been registered up until the point you called `register`). For instance, the following results in a compiler error:

```tsx
const context = Container
.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))
```

One way to get around this is to explicitly let the `Container` know what kind of context you'll be building using the `build` method:

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

However, we've included a method to avoid this boilerplate altogether:

#### `Container.instance`

Your goal will often be to simply 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:

```tsx
Container.instance("users", UsersService) // => Container
```

When the `users` dependency is used, `nwire` will create a new `UsersService` class with the resolved `Context` as the first parameter:

```tsx
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 argument to the constructor:

```tsx
Container.instance("users", UsersService, {
cookieSecret: process.env.COOKIE_SECRET,
})
```

This avoids the TypeScript compiler errors described in previous example, but more importantly you can infer the type of `Context` from the `Container` without defining it ahead of time and then share that with the rest of your application:

```tsx
export function createAppContext() {
return (
Container
//
.register("prisma", () => new PrismaClient())
.instance("users", UsersService)
.instance("tasks", TasksService)
.context()
)
}

export type AppContext = ReturnType<typeof createAppContext>
```
```tsx
type AppContext = {
prisma: PrismaClient
users: UsersService
tasks: TasksService
}
```
#### `Container.context`
Creates a new `Context` class. This is the class you're meant to pass around to all of your dependencies. It's reponsible for resolving dependencies:
```tsx
const context = Container
// ... 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.

#### `Container.group`

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

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

And now all services will be nested under `services`:

```tsx
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:

```tsx
const context = Container
//
.group("services", (services: Container) =>
services
//
.singleton("users", UsersService)
.singleton("tasks", TasksService)
)
.context()

type AppContext = typeof context
```
```tsx
type AppContext = {
services: {
users: UsersService
tasks: TasksService
}
}
```
```tsx
// Two contexts are used for resolution here: `context` and `services`
context.services.users.findOne("123")
```
### Lifetime of a dependency
`nwire` will resolve dependencies for you lazily and keep an instance of the dependency as a singleton by default.
```tsx
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:
```tsx
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 `prisma` dependency is either resolved through the `Container` using `Container.resolve` or through the `Context` using `context.prisma`.
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 provide an initial context:
```tsx
const context = Container.build<AppContext>
.register("users", (context) => new UsersService(context), { transient: true }))
.context()
```

## What is dependency injection?

Expand Down
4 changes: 2 additions & 2 deletions packages/example-fastify/src/createServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ describe("server", function () {
beforeEach(async (context) => {
const container = Container
//
.singleton("tasks", SQLiteTaskStore)
.singleton("tasksCreator", TasksCreator)
.instance("tasks", SQLiteTaskStore)
.instance("tasksCreator", TasksCreator)

const server = createServer(container.context())
await server.ready()
Expand Down
8 changes: 4 additions & 4 deletions packages/example-fastify/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Container } from "nwire"
import { createServer } from "./createServer"
import { AppContext, createServer } from "./createServer"
import { SQLiteTaskStore } from "./SQLiteTaskStore"
import { TasksCreator } from "./TasksCreator"

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

const server = createServer(context)
Expand Down
17 changes: 11 additions & 6 deletions packages/nwire/dist/Container.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
type Context = {
[key: string]: unknown;
};
export type Singleton<TValue> = {
export type Instance<TValue> = {
new (context: any, ...args: any[]): TValue;
};
type Flatten<T> = {} & {
Expand All @@ -10,17 +10,22 @@ type Flatten<T> = {} & {
type MergeContext<TExisting, TKey extends string, TValue> = Flatten<TExisting & {
[P in TKey]: TValue;
}>;
type RegistrationOptions = {
transient?: boolean;
};
export declare class Container<TContext extends Context = {}> {
private _registry;
private _map;
private _transient;
constructor();
static build<TContext extends Context = {}>(): Container<TContext>;
context(rootContext?: {}): TContext;
context(rootContext?: Context): TContext;
group<TNewKey extends string, TNewContext extends Context>(key: TNewKey, decorator: (container: Container<TContext>) => Container<TNewContext>): Container<MergeContext<Context, TNewKey, TNewContext>>;
static group<TNewKey extends string, TNewContext extends Context>(key: TNewKey, decorator: (container: Container<Context>) => Container<TNewContext>): Container<MergeContext<Context, TNewKey, TNewContext>>;
singleton<TNewKey extends string, TValue>(key: TNewKey, ValueClass: Singleton<TValue>, ...args: any[]): Container<MergeContext<TContext, TNewKey, TValue>>;
static singleton<TNewKey extends string, TValue>(key: TNewKey, ValueClass: Singleton<TValue>, ...args: any[]): Container<MergeContext<Context, TNewKey, TValue>>;
register<TNewKey extends string, TValue>(key: TNewKey, value: TValue): Container<MergeContext<TContext, TNewKey, TValue>>;
static register<TNewKey extends string, TValue>(key: TNewKey, value: TValue): Container<MergeContext<Context, TNewKey, TValue>>;
instance<TNewKey extends string, TValue>(key: TNewKey, ValueClass: Instance<TValue>, ...args: any[]): Container<MergeContext<TContext, TNewKey, TValue>>;
static instance<TNewKey extends string, TValue>(key: TNewKey, ValueClass: Instance<TValue>, ...args: any[]): Container<MergeContext<Context, TNewKey, TValue>>;
register<TNewKey extends string, TValue>(key: TNewKey, value: (context: TContext) => TValue, { transient }?: RegistrationOptions): Container<MergeContext<TContext, TNewKey, TValue>>;
static register<TNewKey extends string, TValue>(key: TNewKey, value: (context: Context) => TValue, options?: RegistrationOptions): Container<MergeContext<Context, TNewKey, TValue>>;
unregister<TNewKey extends string>(key: TNewKey): Container<Omit<TContext, TNewKey>>;
static unregister<TNewKey extends string>(key: TNewKey): Container<Omit<Context, TNewKey>>;
resolve<T>(key: keyof TContext): T;
Expand Down
2 changes: 1 addition & 1 deletion packages/nwire/dist/index.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { Container, Singleton } from "./Container";
export { Container, Instance as Singleton } from "./Container";
Loading

0 comments on commit 99a3b7d

Please sign in to comment.