1.0.2 • Published 2 years ago

pnpmod v1.0.2

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

pnpmod_explanation

pnpmod

pnpmod (plug-and-play modules) is a tiny, flexible and developer-friendly modular architecture using dependency injection for writing easily extensible services in Typescript.

It consists of two base classes (Core and Module).

You extend a Core which provides your end-users with a call method to execute actions in your modules via a dot notation accessor; say for a email module with a send action, your users would call:

await core.call("email.send", { to: "john@doe.com", subject: "Hello", body: "Hello, John!" });

Everything about this call is type-safe, including the accessor (email.send gets mapped to the send action in your email module), its parameters and the return value.

Modules can depend on each other, and call each other's actions using the same call method:

// in a module..
await this.call("user.create", { email: "john@doe.com" }); // type safe!

These two classes are really all that's necessary to build a modular architecture for most small to middle sized services. You don't need decorators, or a complex framework, or a build step to generate (re-)usable code. If you've been turned off by dependency injection frameworks in the past, this might be for you.

Installation

npm i --save pnpmod

Introduction

pnpmod is opinionated to try to solve real problems in building out service-oriented architecture. It revolves around two simple concepts:

  • Modules which contain your business logic
  • Core which acts as the entry point for your end-users

It aims to address these problems, which can quickly become a headache when building out a service:

  • Naming conventions - It uses a dot notation accessor to call actions in modules, which is type-safe and easy to read, and guides you towards naming things after what they do instead of what they are.
  • Business logic ownership - Modules are self-contained and can be developed and tested independently of each other. They can be reused in other services, and can be easily (hot-)swapped out for other implementations.
  • Excessive hierarchy and boilerplate - Modules are simple classes that extend Module and define their actions in a simple actions property. There is no need for decorators, or a build step to generate code, or a complex framework.
  • Dependency management - Modules can depend on other modules, and can call each other's actions using the same call method. This enables constructing a composable, modular architecture for services.
  • Extensibility - By enforcing a simple interface for modules, you can keep your codebase clean and easy to understand, and can easily add new features to your service without breaking existing code.

How to use

Define modules

Modules are simply classes with a collection of actions that can be called by the Core or other modules. They are generally injected into your Core, but can also be injected into modules as dependencies (see below for Module injection).

Here, we define EmailModule and UserModule abstract classes that define the actions that our modules will provide. The UserModule depends on the EmailModule and will use it to send an email to the user when they are created.

// classes.ts
import { Module, type Modules } from 'pnpmod';

export abstract class EmailModule extends Module<Modules> {
  abstract actions: {
    send: (to: string, subject: string, body: string) => Promise<void>;
  };
}

export abstract class UserModule extends Module<
  // UserModule depends on EmailModule
  Modules & { email: EmailModule } 
> {
  abstract actions: {
    create: (email: string) => Promise<{ id: string, email: string }>;
  };
}

Actions

Define actions that can be called inside the actions property of your module class. The actions property is a map of action names to action functions. The action functions can be async and can return any type.

Optional: Create tests

This is a good time to create tests for your modules, as you know what they should get as input and what they should return as output.

Implement modules

We then define two concrete implementations of the email module, one for sending emails via SMTP and another for sending emails via Postmark.

// modules.ts
import { DatabaseModule, UserModule } from './classes';

export class SMTPModule extends EmailModule {
  actions = {
    send: async (to: string, subject: string, body: string): Promise<void> => {
      // Send email via SMTP
    },
  };
}

export class PostmarkModule extends EmailModule {
  actions = {
    send: async (to: string, subject: string, body: string): Promise<void> => {
      // Send email via Postmark
    },
  };
}

export class DefaultUserModule extends UserModule {
  actions = {
    create: async ({ email }: { email: string }): Promise<{ id: string; email: string; }> => {
      const user = { id: Date.now(), email };
      // call the email module's send action from within the user module
      await this.call("email.send", email, "Welcome!", "Welcome to pnpmod!" })
      return user;
    },
  };
}

Create a core

The Core is the central piece that you are going to extend as a base. It is responsible for connecting all modules and provides the call interface to your end-user to interact with them.

We create a Core class that will be the entry point for our end-users. We can add additional methods to it to provide additional functionality.

// core.ts
import { Core, type Modules } from 'pnpmod';
import { type UserModule, type EmailModule } from './classes';

export class AccountService extends Core<
  Modules & {
    user: UserModule,
    email: EmailModule
  }
> {
    someMethod() {
        // You can add methods to your core
        // to provide additional functionality
    }
}

Use your core

Finally, we can use our core to call actions on our modules. The call is type-safe, so if there is no create method, or user module, or the method has a different signature, the call will fail to compile in TypeScript and throw an error at runtime in JavaScript. Additionally, the type for the newUser variable will be correctly inferred from the return type of the method.

import { AccountService } from './core';
import { DefaultUserModule, SMTPModule } from './modules';

const userModule = new DefaultUserModule();
const emailModule = new SMTPModule();

const accounts = new AccountService({
  user: userModule,
  email: emailModule
});

// 
// Call an action on a module
// 
// Here, we are..
// 
// 1. Calling an action named `create`..
// 2. On the `user` module..
// 3. With the parameter `{ email: "john@doe.com" }`.
// 
const newUser = await accounts.call(

    //
    // This is a type-safe accessor to the user module's "create" method using
    // string interpolation. If you misspell the module or the method name,
    // the call will fail to compile in TypeScript and you will get an error
    // at runtime in JavaScript.
    //
    // You can use a string, symbol or an enum as the accessor as long as it
    // is unique and contains the module and method name separated by a dot.
    // 

    "user.create",

    //
    // This is a type-safe parameter. It will also be checked at compile-time
    // in TypeScript but *not* at runtime in JavaScript unless you build your
    // own runtime type-checker into your implementation.
    //
    // You can pass any sort and number of parameters, not just objects.
    //
    // The type of the return value will be inferred from the "create" method
    // in the UserModule class.
    //

    {
        email: "john@doe.com"
    }
);

// newUser is correctly typed:
console.log(newUser.email); // "john@doe.com"
//          ^^^^^^^--------    { id: number, email: string }

Module dependencies

You can declare dependencies between modules in a type-safe way using abstract classes. This allows you to access other modules and their actions with confidence that the correct dependencies are being used, leading to fewer runtime errors and a more maintainable codebase.

Module injection

You can inject dependencies into a module in two ways; through the Core or directly into the module. For small projects, you generally don't need to inject dependencies directly into modules, but it can be useful for larger projects.

Injection into Core

const emailModule = new EmailModule();
const userModule = new UserModule();

const core = new Core({ user: userModule, email: emailModule });

Injection into a module

const emailModule = new EmailModule();
const otherEmailModule = new SomeOtherEmailModule();
const userModule = new UserModule({}, { email: someOtherEmailModule });

const core = new Core({ user: userModule, email: emailModule });

Now, let's dive into an example:

Module providing dependency

Here's an email module that provides an action to send an email. The module is defined as an abstract class extending the Module class. The second type parameter of the Module class is used to declare the dependencies of the module. In this case, the email module has no dependencies, so we pass an empty object.

The abstract class pattern allows the module to be extended by a concrete class that implements the module's actions. This enables the module to be mocked in tests and extended by other modules providing access to different email providers, SMTP, and so on.

// email.ts

import { Module, Modules } from "pnpmod";

export interface Email {
  to: string;
  message: string;
}

export abstract class AbstractEmailModule extends Module<Modules> {
  abstract actions: {
    send: (to: string, message: string) => Promise<void>;
  };
}

export class EmailModule extends AbstractEmailModule {
  actions = {
    send: async (to: string, message: string): Promise<void> => {
      console.log(`Sending email to ${to}: ${message}`);
    },
  };
}

Module using dependency

In this example, we have a user module that uses the email module above. The user module is also defined as an abstract class that extends the Module class. The second type parameter of the Module class is used to declare the dependencies of the module- in this case, the user module depends on the email module.

As you see, the AbstractUserModule specifies a dependency on an AbstractEmailModule through the second type parameter of the Module class. This guarantees that the UserModule has access to the email module with the correct type.

// user.ts

import { AbstractEmailModule } from "./email";
import { Module, Modules } from "pnpmod";

interface User {
  id: number;
  email: string;
}

export abstract class AbstractUserModule extends Module<
  // Dependency to email module declared here:
  Modules, { email: AbstractEmailModule } 
> {
  abstract actions: {
    create: ({ email }: { email: string }) => Promise<User>;
    update: (id: number, email: string) => Promise<User>;
  };
}

export class UserModule extends AbstractUserModule {
  actions = {
    create: async ({ email }: { email: string }): Promise<User> => {
      const user = { id: Date.now(), email };

      // You can simply call the action using the string accessor.
      this.call("email.send", { to: user.email, message: "Welcome!" });

      //
      // You also have access to the following:
      //

      // Type-safe access to email module.
      // Will throw if the module is not found in runtime.
      const emailModule = this.getDependency("email");
      emailModule.yourCustomMethod(); // not visible as an action

      // Type-safe access to email module's "send" action.
      // Will also throw if it is not found in runtime.
      const emailSendAction = this.getAction("email.send");
      await emailSendAction(user.email, "Welcome!");

      return user;
    },
    update: async (id: number, email: string): Promise<User> => {
      return { id, email };
    },
  };
}

By declaring type-safe dependencies through the abstract classes, we ensure better developer experience and reduce the chance of typing errors during implementation.

API

Core

core.call<T>(accessor: string, ...params: any[]): Promise<T>

Call an action with an accessor.

(property) core.modules: Modules

List of modules in the core.

Module

module.call<T>(accessor: string, ...params: any[]): Promise<T>

Call an action with an accessor.

module.getDependency<T>(name: string): T

Get a module dependency by name.

module.getAction<T>(accessor: string): (...params: any[]) => Promise<T>

Get an action with an accessor.

(property) module.dependencies: Modules

List of module dependencies.

(property) module.actions: Actions

List of actions.

Contributing

Contributions are welcome! Please open an issue or a pull request.

Copyright

(c) 2023 Auth70

License

MIT

1.0.2

2 years ago

1.0.1

2 years ago

1.0.0

2 years ago