@prun/prun
v0.1.3
Published
`prun` is a least opinionated modular ecosystem of modules for hexagonal architecture. This package is the shell entrypoint for prun prorgrams. It acts as the thin minimal basis for the core services
Maintainers
Readme
prun
Pronounced prun, or P-Run, or Pro-run - however you like, all are correct.
P stands for Project, Program, Process, Professional, Power - whatever you like.
In the best spirit of JavaScript - it is opinionated about being unopinionated. It aspires to let you do whatever you want, but still funnel you into healthy practices by trying to make them the path of least resistence for you.
However, we aspire to let you take ownership and responsibility and go the hard way whenever you feel you must.
Architecture & Philosophy
The current trend in frameworks is to obfuscate the lower levels from the programmer, focusing her on the high-level business logic.
But: How do I expect programmers to guard and protect architectural boundaries if I do everything I can to hide it from them?
prun takes the opposite approach. prun wants the developers to be exposed to the layers the program is made of, and acknowledge the borders between them. prun wants to make the discourse about tidy code, SOLID principals and architecture to be an elementary part of working on a prun project.
Principals
IoC. Not DI.
prun choses to keep SOLID's Invertion of Control principal (IoC), but stop one step before the complex voodoo of a full DI.
The thing about DI is that users of advanced DI systems become completely oblivious to the initiation process. This could be both a boon and a bane.
Common code smells observed with projects that use DI systems are:
- lacking understanding of architectural boundaries
- circular dependencies
- unpredictable order of operations in initiation
- complacency or helplessness about the startup sequence
prun wants more than making the initiation logic obvious and transparent, it wants its users to have a mental map of what belongs where, and why. It wants users to be able to own this structure and be a part of the architectural considerations it embodies.
Using Layers to Separate Execution Context and Business Logic
When the Business Logic gets a message to process - it should not be aware if it's a request that came through a web-interface, a remote storage input for a step-function, a message that came from a queue, or through arguments of a batch-job CLI.
In the same manner, the business logic should be concerned with business entities and logical operations applied to them, while the underlying implications of these logical operations and their implementation details are kept abstract.
For this, a prun application is built of layers.
You can have as many layers as you like. These layers are meant to support and encourage what won the name of hexagonal architecture (TBD: link). If you flatten and simplify a hexagonal architecture to a list of layers, you end up with:
[downstream ports (DP)]
|
|------------- [DTOs]
\|/
[business-Logic (BL)] -> [Domain Entities]
|
|--------------[DTOs]
\|/
[upstream ports (UP, aka DAL)] - Downstream Ports - are all the interfaces through which the application can accept a message: HTTP, Queue, TCP, CLI.
- Business Logic - the layer that encapsulataes and facilitates the Domain entities.
- Upstream Ports - are all the backend interfaces through which applicaiton can ask to retrieve or modify information.
Over this basis, you can add as many layers as you like.
An example: Caching Logic
Ports that manage cache are basically another form of Upstream ports. However, given a cache DAL component next to your DB compopnent - it's useful to place a cache controller in a caching layer between BL and UP, separating the concern of caching logic from the BL. So:
- Downstream Ports
- Business Logic - the layer that encapsulataes and facilitates the Domain entities.
- Caching Logic - the layer that encapsulates caching logic and faciliates it to the BL
- Upstream Ports - are all the backend interfaces through which applicaiton can ask to retrieve or modify information (i.e - from DB or from cache).
Another example: Context-depednent Validation Logic
While BL should be devoid of execution context and focus on business rules and domain entities, sometimes there are validations that are applicable to specific ports. However, loading these validation logic on the layer of the ports would mix concerns of protocol implementation details with control logic.
- Downstream Ports - are all the interfaces through which the application can accept a message: HTTP, Queue, TCP, CLI.
- Context-Validations - proxies the BL to the Ports layer, but adds logic of filtering invalid runtime requests
- Business Logic - the layer that encapsulataes and facilitates the Domain entities.
- Upstream Ports
Another example: View Logic
In this example, unlike the Context-Validation, this layer will not hide the BL from the interfaces, but will provide the interfaces mapping and formatting logic.
Mapping and formatting logic tend to be synchronous, and manage only logic of what piece of information goes where in the view, rathern than where it is obtained from.
If you need to enrich the information - consider consolidating enrichment logic in it's own components, which will probably be placed in a deeper layer of it's own.
Another example: Business Entities
Business entities are expected to be pure entities, not only devode of context, but also ignorant to the use-cases. If you use facilitators for Business entities - it is advised to place them in the deepest layer. This is true also for auxilliary, like custom error classes that are meant to be reused accross layers. e.g:
- Downstream Ports
- Business Logic
- Upstream Ports
- Business Entities
Generic Components Architecture
For this, each component module is expected to export a factory method. This factory is called with the configuration for this component, and the IoC bag of all components that were initiated in previous layers.
Both bags are meant to be destructured, but you don't have to.
MJS:
//load time
export default async (options, ioc) => {
//initiation time
return {
//... runtime API
};
};CJS:
//load time
module.exports = async (options, ioc) => {
//initiation code
return {
//... runtime API
};
};Note the factory does not have to be async, but prun awaits for it.
prun encourages you to avoid load-time code. Its core is designed to handle the load/init/run sequence for you with maximum predictability and transparency. For this, you are encouraged to relay on dependencies passed in the ioc rather than modules you import directly.
This separation will also help you with test.
Fail Fast
In our context, the Fail Fast principal sais that configuration, network and topography errors must be discovered before the application starts to process workload. Components should do everything they can do in validation/initiation time to assert their ability to handle workload.
More over - ideally, prun wishes to know that the configured layers should work before committing any resources like TCP connections or local cache building, rather than successfully initiate the lower layers and obtain DB connection for them, just to find out that the configuration is broken for the upper layers.
For this, prun encourages the use of early validation steps through which each component can assert that it has all it needs to be able to function. If any of these load-time validation fails - the process will fail and refuse to join the workload.
The first validations are core-level validations. They validate that the generic structure of the configuration, and that all the modules named are found. prun will throw very detailed errors for any rejections from these stages. Then it proceeds to load all your components.
Once the component modules are loaded and before invoking any of their factories - the core will run any configuration validation hooks you provide.
The API for configuration validation handler is: validateConfig(options, IoC) throws PrunConfigError.
The validation is synchronous.
Mind that in this stage, none of the components are initiated, so the IoC contains initiated instances only for the core components, and the rest hold just metadata. (The errors component is a core component, the rest of the core is explained later).
MJS
export const validateConfig = ({ positive }, { errors: { configError } }) => {
if (positive < 0) {
throw configError('options.positive is expected to be a positive number');
}
};CJS
module.exports = (options, ioc) => { /* ... */ };
module.exports.validateConfig = ({ positive }, { errors: { configError } }) => {
if (positive < 0) {
throw configError('options.positive is expected to be a positive number');
}
};Note that this handler is provided with a factory faclitator: configError(..). Using it is equivalent to throw new PrunConfigError(..)
Upon running all config validation handlers - the core proceeds to initiation, calling your factories.
The contract is: If the factory returns/resolves successfully - your component should have everything it needs to do it's work.
If you have validations to make after components are loaded - you need to validate data obtained through async operations - you may fall back to doing validations in initiation time.
There are also load-time events you can use - more on that later.
Distinct, Clear and Descriptive Errors
prun looks at errors like a fully-fledged interface messages.
True, in many cases these errors are not meant for end-users, however, prun counts developers are users (albeit more privileged ones).
For this, prun does few things:
- It utilizes an errors registry that helps developers invest in their errors.
- The facilitation of errors puts emphasize on how they look - i.e - it treats errors as views, even when they are thrown to the CLI, or represented as JSON for log visualizations.
- All the errors
prunthrows are very descriptive, and bring with them every piece of context that is safe to float up. These errors serve as an example for users to follow.
A component may register custom errors to errors registry by exporting a custom-errors factory as follows:
MJS:
export const errors = (options, { PrunError }, ioc) => {
//initiation code
return [
class PrunFutureError extends PrunError {
constructor(meta) {
super('This error came from the future', meta);
}
},
];
};module.exports = (options, { PrunError }, ioc) => {
//initiation code
return [
class PrunFutureError extends PrunError {
constructor(meta) {
super('This error came from the future', meta);
}
},
];
};The factory should return an array of subclasses of PrunError.
All custom errors should extend PrunError. This helps distinct violent unhandled errors from deliberated errors.
Note that while the name for the error type is an InitCapitalErrorType name, the registry facilitates for each error type an error factory that just saves you the new operator as errorTypeFactory.
So, assuming MyFutureError is declared, both these forms are equivalents:
export default (options, { errors: { MyFutureError, myFutureError } }) => {
return {
run() {
//...
throw new MyFutureError({ oupsy: 'dazy' });
//...
throw myFutureError({ oupsy: 'dazy' });
},
};
};No voodoo (Maximize User Control)
prun aspires to make as much heavy lifting as lightweight as possible for the least amount of envelope code. This refers to the heavy lifting of: configuration, initiation, and process management.
It aspires to do that without sacrificing user control.
To keep the control on the users side, it does few things:
Minimalist but robust host process
prun offers you an implementation of a host process. Just like a test-runner consumes your test suites and runs them for you - prun host process consumes your application modules and runs them for you.
It does so within a modular minimalist core.
The minimalist core that comes with prun is made of two rings:
Core components:
The core components:
bus- local in-memory event bus, commonly for theshutduwnevent, but can be used for custom events as well.config- config data-structure, validated by extensible load-time config validationserrors- extensible error types registry, which facilitates factories for all error types it learns.logger- there are two built-in minimalist loggers. The user can provide a custom logger if they wish.
The purpose of the core is to be able to fire the first log entry. Being able to emit log entries means it can be consisten and transparent about the loading sequence of your modules.
To be able to emit log entries, it has to load your configuation and obey any log-verbosity directives you provide. For this, the config is loaded before the log is initiated.
It loads all configurations from all sources (file, env, CLI switches) and consolidate them into a consistent configuration model.
User-Application life-cycle manager:
The life-cycle of a single component is: load -> validate -> initialize -> runtime.
Components are grouped in layers that are processed together. The overal lifecycle is:
- loads all layers from bottom to top, with their components in them. This includes:
- import the component module
- process directives pull values from custom env-vars to the config model
- register any custom errors
- validate all layers, from bottom to top, with their components, calling any exported configValidation handlers.
- initialize all layers, from bottom to top, with their components - by calling your factories, and accumulating the results on the IoC bag of the layer.
- activate the
runlayers - the interface layers.
you control the structure
Users determine the structure of the application and the initiation order-of-operations it implies.
The structure is described in a yaml file that by default is called .prunrc.
The main config keys of this file is the root keys layers, and run.
layersis a list of objects.- Each object represents a layer, where the keys are the
iocNames of components in this layer, and the values specify and configure these components. - All components in the same layer are initiated in parallel and should not consume each other.
- All components of all layers are accumulated on the same IoC context under the
iocNamethey are defined. - Each layer is provided the initiated context as the previous layer had left it. The value returned by the factories is accumulated on the IoC bag that will be passed to the next layer.
- The lowest layer is initiated first, and is exposed only to the minimalist core components. Thus, in a classical structure, interfaces can access BL and BL can access DAL. This also means a mid layer can shadow components of lower layers from upper layers if you so wish.
- Each object represents a layer, where the keys are the
runnames the components that also act as interfaces.- it specifies what method to call on them (if no method is specified - it defaults to a default method
run()). - the IoC bag that is passed to the runner is the IoC bag as the highest layer had left it. This means that a component that is an interface must not be shadowed by upper layers.
- it specifies what method to call on them (if no method is specified - it defaults to a default method
The structure is demonstrated with examples, test-fixtures, documented, and validated with very descriptive errors. (TBD: - add links to these different communicators).
Ecosystem of Modularity over the core
Any component can be a module that is local to the project, or a module that is shipped independently as a package.
prun aspires to facilitate the growth of a community ecosystem of SOLID modular components that can be shared and reused just by installing them from npm and naming them in your .prunrc.
Modularity of the core
prun aspires to let the user replace any core part with a part of their own.
Currently, the only core component that can be replaced using only configuration is the logger.
There is intent to support users placing their own replacement for any component (under the premise that you take responsibility on concerns of any components you replace).
Components you cannot replace now are transparent and verbose on their actions. It's done in two channels:
- using log entries with verbosity you can control
- by firing events you can hook on (see next section)
Last - it exports all its internals for your use if you ever need to rewrite any of it's parts, including the host process itself.
expose events of the load, validation and initiation lifecycle
These events are emitted on the local event-bus exposed on the IoC as bus.
Each event accepts a context payload. The lifecycle fires the following events:
module-loadedmodule-loaded:${iocName}layer-loadedlayer-loaded:${layerName}layer-validatedlayer-validated:${layerName}module-initiatedmodule-initiated:${iocName}layer-initiatedlayer-initiated:${layerName}
Mind the order of operations: It does not make sense to wait for an initiation event of a component in a deeper layer - all components of deeper layers have been initiated before all the components in your layer.
These events were born from an use-case that requires a circular dependency and a two way communication to be established.
The initiated events let components of lower layer hook on initiation events of upper layers, and obtain a reference to components from higher levels once they are ready to serve.
Modern logger with High Log Verbosity control
The logger is a core component.
The logger facilitates using different emission channels, each channel can be configured with its own warn level.
The logger is configured using the root key logger:
logger:
levels:
main: info
default: warnLogger instances follows the pino interface (i.e meta-first). (TBD: link)
Logger instances are augmented with an additional logger-broker method: of(name, meta): Logger, used like so:
export default (cfg, { logger }) => {
const log = logger.of('my-logging-channel');
// ...
};It is common to name logging channels after components, however it does not have to be so. You can use any naming scheme for your log channels and control the verbosity of each channel.
The .of(..) api should be used instead of .child(meta) whenever you pull a logger instance. This assures that the level of the logger is set according to level defined in the logger.levels[channelName] (TBD: link to code).
Currently, this method is a simple factory that returns a new instance whenever it is called. However, it may be refactored in the future, so that there will be a single logger instance per channel.
Configuration is a Model
An application may consume configuration from channels: files, env-variables, CLI switches. Components shoud not be aware of what channel the configuration was obtained. Any logic of precedence between the different channels should be kept predictable and consistent.
The configuration model is what consumes all these channels and consolidates then into a single model that is consistent and available synchronously.
The config logic is:
- it uses under the hood
rc(TBD: link) to locate and load your.prunrcfile as an overlay over the baked-in base defaults (TBD: link). As such, it supports the--config <filePath>switch. - if
cwdis defined (in the file or in--cwd) - it changes current dir to that path. - loads
nameandversionfrom thepackage.jsonin thecwd. - loads env vars from
.envusingdotenv(TBD: link) - it applies user directives to project env vars onto the config tree (TBD: explain, link)
- last - it exposes
config.isTTYandconfig.envThe later a step is in the logic - the more it may cascade previous steps.
