pnpmod v1.0.2
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 simpleactions
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