From 474d61def9ffd8f349155acedecd022058d2bcb7 Mon Sep 17 00:00:00 2001 From: teunmooij Date: Sun, 23 Jun 2024 16:09:38 +0200 Subject: [PATCH 1/2] basic readme --- README.md | 448 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 443 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c29d5fa..915ee81 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,457 @@ # systemic-ts -A minimal type-safe dependency injection library, based on and compatible with [systemic](https://github.com/onebeyond/systemic#readme). +A minimal type-safe dependency injection library, based on and compatible with [systemic](https://www.npmjs.com/package/systemic). ## Installation ```shell $ npm install systemic-ts ``` +## tl;dr -## Usage +### Define the system +```typescript +import { systemic } from 'systemic-ts'; +import initConfig from './components/config'; +import initLogger from './components/logger'; +import initMongo from './components/mongo'; +export const initSystem = () => systemic() + .add('config', initConfig(), { scoped: true }) + .add('logger', initLogger()).dependsOn('config') + .add('mongo.primary', initMongo()).dependsOn('config', 'logger') + .add('mongo.secondary', initMongo()).dependsOn('config', 'logger'); +``` + +### Run the system + +```typescript +import { initSystem } from './system'; + +const events = { SIGTERM: 0, SIGINT: 0, unhandledRejection: 1, error: 1 }; + +async function start() { + const system = initSystem(); + const { config, mongo, logger } = await system.start(); + + console.log('System has started. Press CTRL+C to stop'); + + for (const name of Object.keys(events)) { + process.on(name, async () => { + await system.stop(); + console.log('System has stopped'); + process.exit(events[name]); + }); + } +} + +start(); +``` + +## Why Use Dependency Injection With Node.js? + +Node.js applications tend to be small and have few layers than applications developed in other languages such as Java. This reduces the benefit of dependency injection, which encouraged [the Single Responsibility Principle](https://en.wikipedia.org/wiki/Single_responsibility_principle), discouraged [God Objects](https://en.wikipedia.org/wiki/God_object) and facilitated unit testing through [test doubles](https://en.wikipedia.org/wiki/Test_double). + +However when writing microservices the life cycle of an application and its dependencies is a nuisance to manage over and over again. We want a way to consistently express that our service should establish database connections before listening for http requests, and shutdown those connections only after it had stopped listening. We find that before doing anything we need to load config from remote sources, and configure loggers. This is why one uses DI. + +The journey that led to [systemic-ts](https://www.npmjs.com/package/systemic-ts) started with a dependency injection framework called [electrician](https://www.npmjs.com/package/electrician) by our friends at Tes. It served it's purpose well, but the API had a couple of limitations that they wanted to fix. This would have required a backwards incompatible change, so instead a new DI library was written - [systemic](https://www.npmjs.com/package/systemic), which was written in javascript. In late 2021 an attempt was made to add typescript definitions, but the types where incomplete and difficult to debug. This is why we decided to completely re-write the library in typescript, mostly compatible with it's predecessor, but fully type safe - [systemic-ts](https://www.npmjs.com/package/systemic-ts). + + +## Concepts + +Systemic-ts has 4 main concepts + +1. Systems +1. Runners +1. Components +1. Dependencies + +### Systems + +You add components and their dependencies to a system. When you start the system, systemic-ts iterates through all the components, starting them in the order derived from the dependency graph. When you stop the system, systemic-ts iterates through all the components stopping them in the reverse order. + +```typescript +import {systemic} from 'systemic-ts'; +import initConfig from './components/config'; +import initLogger from './components/logger'; +import initMongo from './components/mongo'; + +const events = { SIGTERM: 0, SIGINT: 0, unhandledRejection: 1, error: 1 }; + +async function init() { + const system = systemic() + .add('config', initConfig(), { scoped: true }) + .add('logger', initLogger()).dependsOn('config') + .add('mongo.primary', initMongo()).dependsOn('config', 'logger') + .add('mongo.secondary', initMongo()).dependsOn('config', 'logger'); + + const { config, mongo, logger } = await system.start(); + + console.log('System has started. Press CTRL+C to stop'); + + for (const name of Object.keys(events)) { + process.on(name, async () => { + await system.stop(); + console.log('System has stopped'); + process.exit(events[name]); + }); + } +} + +init(); +``` + +### Runners + +While not shown in the above examples we usually separate the system definition from system start. This is important for testing since you often want to make changes to the system definition (e.g. replacing components with stubs), before starting the system. By wrapping the system definition in a function you create a new system in each of your tests. + +```typescript +// system.ts +export const system = () => systemic() + .add('config', initConfig()) + .add('logger', initLogger()).dependsOn('config') + .add('mongo', initMongo()).dependsOn('config', 'logger'); +``` + +```typescript +// index.ts +import { system } from './system'; + +const events = { SIGTERM: 0, SIGINT: 0, unhandledRejection: 1, error: 1 }; + +async function start() { + const system = system(); + const { config, mongo, logger } = await system.start(); + + console.log('System has started. Press CTRL+C to stop'); -## Version history + for (const name of Object.keys(events)) { + process.on(name, async () => { + await system.stop(); + console.log('System has stopped'); + process.exit(events[name]); + }); + } +} + +start(); +``` + +There are some out of the box runners that can be used in your applications or as a reference for your own custom runner + +1. [Service Runner](https://github.com/onebeyond/systemic-service-runner) +1. [Domain Runner](https://github.com/onebeyond/systemic-domain-runner) + +As these runners have been written for `systemic` and expect a callback based system, we'll need to use the migration helper. + +```typescript +import { asCallbackSystem } from 'systemic-ts/migrate'; +import runner from 'systemic-service-runner'; + +import { system } from './system'; + +runner(asCallbackSystem(system())).start((err, components) => { + if (err) throw err; + console.log('Started'); +}); +``` + +### Components + +A component is or wraps the underlying resource that makes up the system. It has optional start and stop functions. The start function should return or yield the underlying resource after it has been started. e.g. + +```typescript +type Dependencies = { + config: { url: string }; +}; + +export function initMongo() { + let db; + + async function start({ config }: Dependencies) { + db = await MongoClient.connect(config.url); + return db; + } + + async function stop() { + return db.close(); + } + + return { + start, + stop, + }; +}; + +const system = systemic().add('mongo', initMongo()); +``` + +The components stop function is useful for when you want to disconnect from an external service or release some other kind of resource. + +`systemic-ts` supports multiple types of components: + +#### (A)synchronous components + +(A)synchronous components look like the `initMongo` component in the example above. They have a start function that returns the underlying resource and an optional stop function. Both start and stop function can be either synchronous or asynchronous. + +#### Plain object components + +Plain object components do not have a start function and are added to the system as-is. They will not be started or stopped, but can be injected into other component as any other component. + +```typescript +const logger = { + info(message: string) { + console.log(message); + } +} + +const system = systemic().add('logger', logger); +``` + +#### Function components + +Function components are similar to the `start` function of the (a)synchronous component. The function is called on system start and the returned resource is added to the system. + +```typescript +import type { BookService } from './book-service'; + +type Dependencies = { + bookService: BookService +} + +function booksDomain({ bookService }: Dependencies) { + return { + async getBooks() { + return bookService.getBooks() + } + } +} + +const system = systemic().add('booksDomain', booksDomain) +``` + +#### Callback components + +Support for callback components has been dropped in `systemic-ts` in favor of synchronous components. To maintain backwards compatibility with existing components written for legacy `systemic`, `systemic-ts` includes a migration helper to convert them into asynchronous components. + +```typescript +import initRabbit from 'systemic-rabbitmq'; +import { promisifyComponent } from 'systemic-ts/migrate'; + +const system = systemic().add('rabbit', promisifyComponent(initRabbit())); +``` + +### Dependencies + +A component's dependencies must be registered with the system + +```typescript +import { systemic } from 'systemic-ts'; +import initConfig from './components/config'; +import initLogger from './components/logger'; +import initMongo from './components/mongo'; + +const system = systemic() + .add('config', initConfig()) + .add('logger', initLogger()).dependsOn('config') + .add('mongo', initMongo()).dependsOn('config', 'logger'); +``` + +The components dependencies are injected via it's start function + +```typescript +async function start({ config }) { + db = await MongoClient.connect(config.url); + return db; +} +``` + +#### Mapping dependencies + +You can rename dependencies passed to a components start function by specifying a mapping object instead of a simple string + +```typescript +const system = systemic() + .add('config', initConfig()) + .add('mongo', initMongo()) + .dependsOn({ component: 'config', destination: 'options' } as const); +``` + +If you want to inject a property or subdocument of the dependency thing you can also express this with a dependency mapping + +```typescript +const system = systemic() + .add('config', initConfig()) + .add('mongo', initMongo()) + .dependsOn({ component: 'config', source: 'mongo' } as const); +``` + +Now `config.mongo` will be injected as `config` instead of the entire configuration object. +Because of the way typescript narrowing of object properties works, mappings need to be added as constants. Otherwise `systemic-ts` is not able to validate the dependency. + +#### Scoped Dependencies + +Injecting a sub document from a json configuration file is such a common use case, you can enable this behaviour automatically by 'scoping' the component. The following code is equivalent to that above + +```typescript +const system = systemic() + .add('config', initConfig(), { scoped: true }) + .add('mongo', initMongo()) + .dependsOn('config'); +``` + +#### Optional Dependencies + +By default an error is thrown if a dependency is not available on system start. Sometimes a component might have an optional dependency on a component that may or may not be available in the system, typically when working with subsystems. In this situation a dependency can be marked as optional. + +```typescript +const system = systemic() + .add('app', app()) + .add('server', server()) + .dependsOn('app', { component: 'routes', optional: true }); +``` + +### Overriding Components + +Attempting to add the same component twice will result in an error, but sometimes you need to replace existing components with test doubles. Under such circumstances use `set` instead of `add` + +```typescript +import system from '../src/system'; +import stub from './stubs/store'; + +const testSystem = system().set('store', stub); +``` + +### Removing Components + +Removing components during tests can decrease startup time. + +```typescript +import system from '../src/system'; + +const testSystem = system().remove('server'); +``` + +`systemic-ts` does not allow you to delete components that other components depend on. + +### Including components from another system + +You can simplify large systems by breaking them up into smaller ones, then including their component definitions into the main system. + +```typescript +// db-system.ts +import { systemic } from 'systemic-ts'; +import initMongo from './components/mongo'; + +type DependenciesFromMaster = { + logger: Logger; + config: { component: Config, scoped: true } +}; + +export function initDbSystem() { + return systemic() + .add('mongo', initMongo()) + .dependsOn('config', 'logger'); +}; +``` + +```typescript +// system.ts +import { systemic } from 'systemic-ts'; +import initUtilSystem from './util-system'; +import initWebSystem from './web-system'; +import initDbSystem from './db-system'; + +import initConfig from './config'; +import initLogger from './logger'; + +const system = systemic() + .add('config', initConfig(), { scoped: true}) + .add('logger', initLogger()).dependsOn('config') + .include(initUtilSystem()) + .include(initWebSystem()) + .include(initDbSystem()); +``` + +### Grouping components + +Sometimes it's convenient to depend on a group of components. e.g. + +```typescript +const system = systemic() + .add('app', app()) + .add('routes.admin', adminRoutes()) + .dependsOn('app') + .add('routes.api', apiRoutes()) + .dependsOn('app') + .add('routes') + .dependsOn('routes.admin', 'routes.api') + .add('server') + .dependsOn('app', 'routes'); +``` + +The above example will create a component 'routes', which will depend on routes.admin and routes.api and be injected as + +```typescript + { + routes: { + admin: { ... }, + adpi: { ... } + } + } +``` + +### Debugging + +You can debug systemic by setting the DEBUG environment variable to `systemic:*`. Naming your systems will make reading the debug output easier when you have more than one. + +```typescript +// system.ts +import { systemic } from 'systemic-ts'; +import initRoutes from './routes'; + +const system = systemic({ name: 'server' }) + .include(initRoutes()); +``` + +```typescript +// routes/index.ts +import { systemic } from 'systemic-ts'; +import adminRoutes from './admin-routes'; +import apiRoutes from './api-routes'; + +export default () => systemic({ name: 'routes' }) + .add('routes.admin', adminRoutes()) + .add('routes.api', apiRoutes()) + .add('routes') + .dependsOn('routes.admin', 'routes.api'); +``` + +``` +DEBUG='systemic:*' node system +systemic:index Adding component routes.admin to system routes +0ms +systemic:index Adding component routes.api to system auth +2ms +systemic:index Adding component routes to system auth +1ms +systemic:index Including definitions from sub system routes into system server +0ms +systemic:index Starting system server +0ms +systemic:index Inspecting component routes.admin +0ms +systemic:index Starting component routes.admin +0ms +systemic:index Component routes.admin started +15ms +systemic:index Inspecting component routes.api +0ms +systemic:index Starting component routes.api +0ms +systemic:index Component routes.api started +15ms +systemic:index Inspecting component routes +0ms +systemic:index Injecting dependency routes.admin as routes.admin into routes +0ms +systemic:index Injecting dependency routes.api as routes.api into routes +0ms +systemic:index Starting component routes +0ms +systemic:index Component routes started +15ms +systemic:index Injecting dependency routes as routes into server +1ms +systemic:index System server started +15ms +``` -### v1.0 +### Migration from Systemic -- Initial version \ No newline at end of file +-- compatibility +-- callbacks +-- migration helpers From 5ea4cb53b986ef464a3d91edb0114485e1a0a410 Mon Sep 17 00:00:00 2001 From: Teun Mooij Date: Sun, 23 Jun 2024 17:06:26 +0200 Subject: [PATCH 2/2] migration documentation --- README.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 915ee81..25247b7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Node.js applications tend to be small and have few layers than applications deve However when writing microservices the life cycle of an application and its dependencies is a nuisance to manage over and over again. We want a way to consistently express that our service should establish database connections before listening for http requests, and shutdown those connections only after it had stopped listening. We find that before doing anything we need to load config from remote sources, and configure loggers. This is why one uses DI. -The journey that led to [systemic-ts](https://www.npmjs.com/package/systemic-ts) started with a dependency injection framework called [electrician](https://www.npmjs.com/package/electrician) by our friends at Tes. It served it's purpose well, but the API had a couple of limitations that they wanted to fix. This would have required a backwards incompatible change, so instead a new DI library was written - [systemic](https://www.npmjs.com/package/systemic), which was written in javascript. In late 2021 an attempt was made to add typescript definitions, but the types where incomplete and difficult to debug. This is why we decided to completely re-write the library in typescript, mostly compatible with it's predecessor, but fully type safe - [systemic-ts](https://www.npmjs.com/package/systemic-ts). +The journey that led to [systemic-ts](https://www.npmjs.com/package/systemic-ts) started with a dependency injection framework called [electrician](https://www.npmjs.com/package/electrician) by our friends at Tes. It served its purpose well, but the API had a couple of limitations that they wanted to fix. This would have required a backwards incompatible change, so instead a new DI library was written - [systemic](https://www.npmjs.com/package/systemic). In late 2021 an attempt was made to add typescript definitions, but the types where incomplete and difficult to debug. This is why we decided to completely re-write the library in typescript, mostly compatible with it's predecessor, but fully type safe - [systemic-ts](https://www.npmjs.com/package/systemic-ts). ## Concepts @@ -72,7 +72,7 @@ Systemic-ts has 4 main concepts You add components and their dependencies to a system. When you start the system, systemic-ts iterates through all the components, starting them in the order derived from the dependency graph. When you stop the system, systemic-ts iterates through all the components stopping them in the reverse order. ```typescript -import {systemic} from 'systemic-ts'; +import { systemic } from 'systemic-ts'; import initConfig from './components/config'; import initLogger from './components/logger'; import initMongo from './components/mongo'; @@ -108,7 +108,7 @@ While not shown in the above examples we usually separate the system definition ```typescript // system.ts -export const system = () => systemic() +export const initSystem = () => systemic() .add('config', initConfig()) .add('logger', initLogger()).dependsOn('config') .add('mongo', initMongo()).dependsOn('config', 'logger'); @@ -116,12 +116,12 @@ export const system = () => systemic() ```typescript // index.ts -import { system } from './system'; +import { initSystem } from './system'; const events = { SIGTERM: 0, SIGINT: 0, unhandledRejection: 1, error: 1 }; async function start() { - const system = system(); + const system = initSystem(); const { config, mongo, logger } = await system.start(); console.log('System has started. Press CTRL+C to stop'); @@ -149,9 +149,9 @@ As these runners have been written for `systemic` and expect a callback based sy import { asCallbackSystem } from 'systemic-ts/migrate'; import runner from 'systemic-service-runner'; -import { system } from './system'; +import { initSystem } from './system'; -runner(asCallbackSystem(system())).start((err, components) => { +runner(asCallbackSystem(initSystem())).start((err, components) => { if (err) throw err; console.log('Started'); }); @@ -197,7 +197,7 @@ The components stop function is useful for when you want to disconnect from an e #### Plain object components -Plain object components do not have a start function and are added to the system as-is. They will not be started or stopped, but can be injected into other component as any other component. +Plain object components do not have a start function and are added to the system as-is. They will not be started or stopped, but can be injected into other component like any other component. ```typescript const logger = { @@ -450,8 +450,63 @@ systemic:index Injecting dependency routes as routes into server +1ms systemic:index System server started +15ms ``` -### Migration from Systemic +## Migration from Systemic to Systemic-ts + +Since `systemic-ts` is mostly compatible with `systemic`, you can migrate your existing `systemic` service to `systemic-ts` with minimal effort. + +### Compatibility + +`systemic-ts` is mostly compatible with `systemic`. The differences are: +- the main `systemic` export is now a named export, for better esm vs commonjs compatibility +- the `bootstrap` function has been removed, since it was not type safe +- `systemic-ts` does not support callback components, but includes a migration helper to convert them to asynchronous components +- the `start` and `stop` functions of the system now return a promise, instead of taking a callback. To maintain compatibility with existing runners, a migration helper is included to convert the system to a callback based system. +- `systemic` subsystems need to be converted to `systemic-ts` systems with the included migration helper, before they can be included in a `systemic-ts` system. + +### Available migration helpers + +#### Promisify component + +When using a callback component, it's best to convert them to an asynchronous component. However, if you're importing a component from a library, you might not be able to change the source code. In this case, you can use the `promisifyComponent` helper to convert the component to an asynchronous component. + +```typescript +import initRabbit from 'systemic-rabbitmq'; +import { promisifyComponent } from 'systemic-ts/migrate'; + +const system = systemic().add('rabbit', promisifyComponent(initRabbit())); +``` + +#### Use a legacy runner + +If you're using a runner that expects a callback based system, you can use the `asCallbackSystem` helper to convert the system to a callback based system. + +```typescript +import { asCallbackSystem } from 'systemic-ts/migrate'; +import runner from 'systemic-service-runner'; + +import { initSystem } from './system'; + +runner(asCallbackSystem(initSystem())).start((err, components) => { + if (err) throw err; + console.log('Started'); +}); +``` + +#### Upgrade a (sub)system + +If you have a `systemic` subsystem that you want to include in a `systemic-ts` system, you can use the `upgradeSystem` helper to convert the subsystem to a `systemic-ts` system. + +```typescript +import { upgradeSystem } from 'systemic-ts/migrate'; +import initSubSystem from 'my-legacy-subsystem'; + +const system = upgradeSystem(initSubSystem()); +``` + +### Migration steps --- compatibility --- callbacks --- migration helpers +1. Replace all `systemic` imports with `systemic-ts` +1. Change all callback components to asynchronous components, either by changing the source code or using the `promisifyComponent` helper +1. If the system includes subsystems that you cannot convert, use the `upgradeSystem` helper to convert them to `systemic-ts` systems. +1. If subsystems are included using the `bootstrap` functions, use the `include` function instead to add them to the main system. +1. If you're using a runner that expects a callback based system, choose a different runner or use the `asCallbackSystem` helper to convert the system to a callback based system. \ No newline at end of file