@vlynk-studios/nodulus-core
v1.8.1
Published
Nodulus architecture core framework, for Express with native ESM hooks support.
Maintainers
Readme
Nodulus
A lightweight structural layer for Express. Nodulus lets you organise your Node.js application into self-contained modules — handling discovery, route mounting, import aliases, and dependency validation at bootstrap time, with zero overhead at runtime.
Node.js ≥ 20.6 · Express 5.x · ESM Only · TypeScript included
Why Nodulus?
Express is minimal by design. Nodulus keeps it that way while adding just enough structure to scale:
- Module discovery — point it at a glob
src/modules/*and it finds, validates, and loads every module automatically. - Route mounting — controllers declare their prefix;
createApp()wires them to Express viaapp.use(). - Import aliases —
@modules/users,@config/database— no more../../..paths. - Dependency validation — declare what your module imports and exports; Nodulus catches mismatches before a single request is handled.
- No magic at runtime — after bootstrap, Nodulus is out of the way. Express handles requests exactly as normal.
Packages
Nodulus ships as two focused packages from the same repository:
| Package | Description | npm |
|---|---|---|
| @vlynk-studios/nodulus-core | Core framework — module discovery, routing, aliases, validation | |
|
@vlynk-studios/eslint-plugin-nodulus | ESLint plugin — static enforcement of Nodulus module boundaries in your editor and CI | |
Both packages are independent installs — use one or both depending on your setup. The ESLint plugin is a companion, not a dependency of the core.
Installation
npm install @vlynk-studios/nodulus-coreExpress 5 is a peer dependency:
npm install expressThe Pre-loader System (v1.5.0+)
By default, the Node.js ESM hook activates inside createApp(). This means aliases like @config/database are not available in static top-level imports in your server entry file:
// ❌ This fails if the pre-loader is not active
import { db } from '@config/database.js' // MODULE_NOT_FOUND
const app = express()
await createApp(app)The runtime pre-loader solves this by registering the alias resolution ESM hook before your code runs.
Automatic setup (create-nodulus-app)
If you generated your project using npx create-nodulus-app, the pre-loader is configured automatically. You don't need to do anything. Your npm run dev script automatically syncs the pre-loader before starting the server.
Manual setup (existing projects)
1. Generate the pre-loader file:
npx nodulus sync-preloadThis creates .nodulus/preload.js — commit it to version control.
2. Update your package.json scripts:
{
"scripts": {
"dev": "nodulus sync-preload --silent && nodulus dev src/server.ts",
"start": "node --import ./.nodulus/preload.js src/server.ts"
}
}The
--silentflag: When chained in yourdevscript,--silentsuppresses output if the pre-loader is already up to date, keeping your terminal clean on every startup. It only prints a message if it actually regenerated the file.
3. Aliases now work everywhere:
// ✅ Works with the pre-loader active
import { db } from '@config/database.js'
import { UserService } from '@modules/users'
const app = express()
const { runtime } = await createApp(app)
console.log(runtime.preloaderActive) // trueWhen to run sync-preload manually
Because npm run dev automatically chains sync-preload --silent, the pre-loader is usually kept in sync automatically. However, you need to run npx nodulus sync-preload manually when:
- You add, remove, or change aliases in
nodulus.config.tswhile the server is NOT running. - You move your project to a different directory on your local machine (the pre-loader embeds absolute paths to the runtime hook).
- In CI/CD pipelines to ensure the file is up to date (though committing
.nodulus/preload.jsis the recommended approach).
Legacy Mode (v1.4.0 compatibility)
If .nodulus/preload.js is not found, Nodulus falls back to Legacy Mode.
In this mode, aliases still work perfectly inside modules discovered by createApp() (exactly as they did in v1.4.0). However, top-level static imports in your entry file (server.ts) will fail to resolve aliases. Nodulus will emit a warning during bootstrap if it detects Legacy Mode, prompting you to run nodulus sync-preload.
Alias System
Define your aliases once in nodulus.config.ts:
import { defineConfig } from '@vlynk-studios/nodulus-core'
export default defineConfig({
aliases: {
'@config': './src/config',
'@middleware': './src/middleware',
'@shared': './src/shared',
}
})Nodulus automatically generates tsconfig.nodulus.json with the corresponding paths. Add this to your tsconfig.json once:
{ "extends": "./tsconfig.nodulus.json" }The @modules alias is always available — it points to the modules directory configured in modules.
Module boundaries
In Nodulus, @ always crosses into another module. ./ and ../ always stay within the current module.
import { X } from './local-file' // ✅ internal to the module
import { X } from '@modules/payments' // ✅ declared cross-module connection
import { X } from '../payments/service' // ❌ RELATIVE_BOUNDARY_VIOLATIONA relative path that escapes the module directory is always an error, regardless of strict mode. Fix it by declaring the import in imports[] and using the corresponding alias.
Quick start
import express from 'express'
import { createApp } from '@vlynk-studios/nodulus-core'
const app = express()
const nodulus = await createApp(app)
const server = app.listen(3000)
nodulus.listen(server)Note: Alias resolution runs through the Node.js ESM Hooks API, which activates inside
createApp()at bootstrap time. Aliases are available to any file that Nodulus imports dynamically during bootstrap (your modules). They are not available to static imports in your entry point file (app.ts,server.ts) beforecreateApp()is called. For bundler-based setups, see Alias resolution with bundlers.
Project structure
Nodulus expects modules in a consistent layout:
src/
├── modules/
│ └── users/
│ ├── index.ts ← required — calls Module('users', ...)
│ ├── users.routes.ts ← controller (discovered automatically)
│ ├── users.service.ts ← private business logic
│ └── users.types.ts ← excluded from controller scan
└── app.tsModule Identity
Each module in Nodulus has a persistent identity stored in a .nodulus file
at its root. This file is created automatically on first bootstrap and should
be committed to Git.
The identity file enables Nodulus to track modules across renames and moves, even when the module's internal code changes significantly.
You don't need to manage this file manually.
API
createApp(app, options?)
Bootstraps the entire application. Runs module discovery, alias resolution, controller mounting, and validation in a deterministic sequence. Throws a NodulusError before mounting any routes if anything is invalid — the app is never left in a partial state.
createApp(app: Application, options?: { logger?: LogHandler }): Promise<NodulusApp>| Option | Type | Default | Description |
|---|---|---|---|
| logger | LogHandler | built-in | Custom log handler |
Returns NodulusApp:
interface NodulusApp {
modules: RegisteredModule[]
routes: MountedRoute[]
registry: NodulusRegistry
runtime: { // v1.5.0+
preloaderActive: boolean // true when --import .nodulus/preload.js is active
preloaderVersion: string | null
aliasesAtBoot: Record<string, string>
}
// v1.5.0+ — registers the HTTP server with the graceful shutdown manager
listen(server: http.Server): () => Promise<void>
}Module(name, options?)
Declares a module and registers its metadata in the registry. Must be called from the module's index.ts (or index.js), and the name must match the containing folder name exactly — Nodulus enforces this as a structural rule.
// src/modules/orders/index.ts
import { Module } from '@vlynk-studios/nodulus-core'
Module('orders', {
description: 'Purchase order management',
imports: ['users', 'payments'],
exports: ['OrderService', 'createOrderSchema'],
})
export { OrderService } from './orders.service.js'
export { createOrderSchema } from './orders.schema.js'| Option | Type | Description |
|---|---|---|
| imports | string[] | Modules this module depends on |
| exports | string[] | Public API names — validated against real exports at bootstrap |
| description | string | Documentation / future tooling |
Rule: The name passed to
Module()must equal the directory name.Module('orders')insidesrc/modules/billing/will throwINVALID_MODULE_DECLARATION.
Controller(prefix, options?)
Declares a file as an Express controller. The controller name is derived automatically from the filename. The file must have a default export of an Express Router.
// src/modules/users/users.routes.ts
import { Controller } from '@vlynk-studios/nodulus-core'
import { Router } from 'express'
import { requireAuth } from '@middleware/auth.js'
import { UserService } from './users.service.js'
Controller('/users', {
middlewares: [requireAuth],
})
const router = Router()
router.get('/', async (req, res, next) => {
try {
res.json(await UserService.findAll())
} catch (err) {
next(err)
}
})
router.post('/', async (req, res, next) => {
try {
res.status(201).json(await UserService.create(req.body))
} catch (err) {
next(err)
}
})
export default router| Parameter | Type | Description |
|---|---|---|
| prefix | string | Route prefix for this controller (e.g. '/users') |
| options.middlewares | RequestHandler[] | Middlewares applied to all routes in this controller. Default: [] |
| options.enabled | boolean | If false, createApp() ignores this controller entirely. Default: true |
Nodulus mounts each controller as:
app.use(globalPrefix + controllerPrefix, ...middlewares, router)Domain Identifiers
Label your business logic with domain identifiers to register them in the Nodulus registry for tracing, tooling, and future framework features. Identifiers are entirely optional — a module with no identifiers is completely valid.
import { Service, Repository, Schema } from '@vlynk-studios/nodulus-core'
import { z } from 'zod'
Service('UserService')
Repository('UserRepository', { source: 'database' })
Schema('UserSchema', { library: 'zod' })Each identifier accepts an optional options object:
Service(name, options?)
| Option | Type | Description |
|---|---|---|
| module | string | Module this service belongs to. Inferred from parent folder if omitted |
| description | string | Documentation |
Repository(name, options?)
| Option | Type | Description |
|---|---|---|
| module | string | Module this repository belongs to. Inferred from parent folder if omitted |
| description | string | Documentation |
| source | 'database' \| 'api' \| 'cache' \| 'file' \| string | Data source type |
Schema(name, options?)
| Option | Type | Description |
|---|---|---|
| module | string | Module this schema belongs to. Inferred from parent folder if omitted |
| description | string | Documentation |
| library | 'zod' \| 'joi' \| 'yup' \| 'ajv' \| string | Validation library used |
Unlike Controller or Module, these identifiers do not alter runtime execution — they simply register presence and ownership into the NodulusRegistry, which is accessible after bootstrap via result.registry.
Note: Nodulus is validation-agnostic. While examples use Zod, you can use Joi, TypeBox, or any other library.
Import aliases
Nodulus registers two kinds of aliases:
- Module aliases — auto-generated for every discovered module:
@modules/<name> → src/modules/<name>/index.ts - Folder or file aliases — configured in
nodulus.config.ts(see Alias System):@config → src/config/ (directory — supports subpaths automatically) @db → src/config/db.ts (file — resolves exactly to that file)
Use them anywhere inside your modules:
import { UserService } from '@modules/users'
import { db } from '@config/database.js'[!IMPORTANT] Nodulus is an ESM-only framework. It requires
"type": "module"in yourpackage.json. Runtime alias resolution uses the Node.js ESM Hooks API and activates insidecreateApp(). Aliases are not available in your entry point beforecreateApp()is called.
Alias resolution with bundlers
For bundler-based projects (Vite, esbuild, etc.), you can disable the runtime hook and inject getAliases() directly into your config:
// vite.config.ts
import { getAliases } from '@vlynk-studios/nodulus-core'
const aliases = await getAliases()
export default {
resolve: { alias: aliases }
}// esbuild.config.ts
import { getAliases } from '@vlynk-studios/nodulus-core'
import * as esbuild from 'esbuild'
const aliases = await getAliases()
await esbuild.build({
entryPoints: ['src/index.ts'],
alias: aliases,
bundle: true,
outfile: 'dist/app.js'
})getAliases() accepts a GetAliasesOptions object:
| Option | Type | Default | Description |
|---|---|---|---|
| includeFolders | boolean | true | If false, config-defined folder aliases are excluded (returns only @modules/* aliases) |
| includeConfigAliases | boolean | true | Same as includeFolders, takes precedence when both are present |
| absolute | boolean | false | If true, returned paths are absolute |
nodulus.config.ts
Centralise configuration in the project root. Options passed directly to createApp() take priority over the file.
import { defineConfig } from '@vlynk-studios/nodulus-core'
export default defineConfig({
modules: 'src/modules/*',
prefix: '/api',
strict: true,
logLevel: 'info',
moduleLoadTimeoutMs: 30_000,
aliases: {
'@config': './src/config'
}
})|Campo|Tipo|Default|Descripción|
|---|---|---|---|
|modules|string|'src/modules/*'|Glob de directorios de módulos|
|prefix|string|''|Prefijo global de rutas HTTP|
|strict|boolean|true en dev, false en prod|Modo estricto|
|logLevel|'debug'\|'info'\|'warn'\|'error'|'info'|Nivel de logs|
|logFormat|'json'\|'pretty'\|'auto'|'auto'|Formato de logs|
|resolveAliases|boolean|true|Activar resolución de aliases en runtime|
|requirePreloader|boolean|false|Requerir el pre-loader activo|
|moduleLoadTimeoutMs|number|30000|Timeout de carga de módulos en ms|
|nits.enabled|boolean|true|Activar NITS identity tracking|
|nits.similarityThreshold|number|dinámico|Umbral de similitud Jaccard|
|aliases|AliasMap|{}|Mapa de aliases de usuario|
Config file loading order (first match wins):
nodulus.config.ts— development only (requires a TypeScript loader such astsx)nodulus.config.js— always
Note:
nodulus.config.tscannot be loaded in production unless your build step compiles it to.js. Usenodulus.config.jsfor production deployments, or build it as part of your pipeline.
CLI Tools
Nodulus provides a built-in CLI to enforce conventions and improve developer experience.
nodulus create-module <name>
Scaffolds a perfectly structured module conforming to the framework constraints.
npx nodulus create-module payments✔ Module 'payments' created successfully at src/modules/payments/
index.ts
payments.routes.ts
payments.service.ts
payments.repository.ts
payments.schema.ts| Option | Description |
|---|---|
| --path <path> | Sets a custom absolute or relative destination |
| --service | Generates a service file |
| --routes | Generates a controller/routes file |
| --repository | Generates a repository file |
| --schema | Generates a schema file |
| --full | Generates all of the above |
| --ts | Force TypeScript output (.ts files) |
| --js | Force JavaScript output (.js files) |
Language is auto-detected from the presence of
tsconfig.jsonin the project root when neither--tsnor--jsis specified.
nodulus sync-tsconfig
Syncs Nodulus aliases into tsconfig.json paths so IDEs and TypeScript recognise @modules/* and any folder aliases you've configured.
npx nodulus sync-tsconfig✔ tsconfig.json updated — 3 module(s), 2 folder alias(es)
Added paths:
@modules/users → ./src/modules/users/index.ts
@modules/auth → ./src/modules/auth/index.ts
@config/* → ./src/config/*Run this command initially, and whenever you create, rename, or drop modules. It behaves idempotently and automatically purges references to modules that no longer exist.
| Option | Description |
|---|---|
| --tsconfig <path> | Path to tsconfig.json. Default: tsconfig.json in the project root |
nodulus sync-preload (v1.5.0+)
Generates .nodulus/preload.js — a static ESM entry point that registers the alias resolution hook before your application code runs. This enables aliases in top-level imports of your server entry file.
npx nodulus sync-preload✔ Pre-loader sync complete.
To use the pre-loader, update your package.json scripts:
"dev": "nodulus sync-preload --silent && nodulus dev src/server.ts"
"start": "node --import ./.nodulus/preload.js src/server.ts"The generated file embeds your current alias config and is idempotent — running it again with the same config produces no changes. Re-run it whenever you add or rename aliases.
[!IMPORTANT] Commit
.nodulus/preload.jsto version control. CI/CD and production environments rely on it being present without needing to runsync-preloadat deploy time. It is safe to commit — it contains only resolved paths and no secrets.
nodulus dev (v1.5.0+)
Starts your application in development mode. Automatically injects --import ./.nodulus/preload.js if the file exists, ensuring aliases are available before any module loads.
The expected workflow is to chain this command with sync-preload to ensure the pre-loader is always up to date before the server starts:
npx nodulus sync-preload --silent && npx nodulus dev <entrypoint> [--watch] [--runtime <node|tsx>]| Option | Description |
|---|---|
| --watch | Run in watch mode using Chokidar (restarts on file changes) |
| --runtime tsx | Uses tsx instead of node for TypeScript without a build step |
If .nodulus/preload.js does not exist, nodulus dev falls back to legacy mode (v1.4.0 behaviour) with a warning. However, chaining sync-preload first guarantees it will be present.
nodulus check
Performs static architecture analysis by inspecting raw ASTs across your module structure without evaluating your application code.
npx nodulus checkNodulus Architecture Analysis
✔ orders — OK
✗ payments — 2 problem(s)
WARN Private import detected: module "payments" directly imports internal path from "@modules/users/users.repository.js". (payments.service.ts:3)
Suggestion: Import only the public index: "@modules/users".
✔ users — OK
2 problem(s) found.| Option | Description |
|---|---|
| --strict | Exit with code 1 if any violation is found. Ideal for CI gates |
| --module <name> | Narrow the analysis to a specific module |
| --format <json\|text> | Output format. Use json for external pipeline consumption |
| --no-circular | Disables cycle detection (A → B → A) |
Logging (v1.5.3+)
Nodulus uses a high-performance Pino logging engine internally, providing beautiful console output during development and highly structured JSON data for production observability.
Environment Variables
| Variable | Description |
|---|---|
| NODULUS_LOG_LEVEL | Minimum severity to output (debug, info, warn, error, fatal). Default: info. |
| NODULUS_LOG_FORMAT | Forces the output format. Allowed values: pretty | json | auto. Default is auto (uses json if NODE_ENV=production, otherwise pretty). |
Note: You can set
NODULUS_LOG_FORMAT=prettyto force human-readable output even in staging environments whereNODE_ENV=productionmight be set.
User-space Logger (useLogger)
You can create context-aware child loggers for your own modules. This keeps your application logs visually identical to the framework logs and inherits global settings automatically.
Development Output (logFormat: 'pretty'):
import { useLogger } from '@vlynk-studios/nodulus-core';
const log = useLogger('my-app');
log.info('Connecting to database...');
// 19:15:30.123 [my-app] INFO Connecting to database...Production Output (logFormat: 'json'):
{"level":"info","time":"2026-05-08T22:15:30.123Z","service":"my-app","msg":"Connecting to database..."}If you pass an Error object under the err or error key in the meta object, Nodulus automatically serializes the full stack trace in JSON mode:
log.error('Database connection failed', { err: new Error('Timeout') });Custom Transports (Loki, Datadog, etc.)
If you need to stream logs directly to external providers using Pino transports instead of stdout, you can wrap a custom Pino instance and pass it via nodulus.config.ts:
// nodulus.config.ts
import pino from 'pino';
// Define your external transport (e.g., Datadog, Loki, Axiom)
const externalPino = pino({
transport: {
target: 'pino-datadog-transport',
options: { ddClientConf: { authMethods: { apiKeyAuth: '...' } } }
}
});
export default {
// Redirect all Nodulus internal logs to your custom Pino instance
logger: (level, msg, meta) => externalPino[level](meta || {}, msg)
};ESLint Plugin
Available from v1.3.0 · Package:
@vlynk-studios/eslint-plugin-nodulus
nodulus check validates your architecture on demand or in CI. @vlynk-studios/eslint-plugin-nodulus brings the same rules into your editor — so you catch boundary violations the moment you write the import.
npm install --save-dev @vlynk-studios/eslint-plugin-nodulusSetup
// eslint.config.js
import nodulus from '@vlynk-studios/eslint-plugin-nodulus'
export default [nodulus.configs.recommended]To configure rules individually:
// eslint.config.js
import nodulus from '@vlynk-studios/eslint-plugin-nodulus'
export default [
{
plugins: { nodulus },
rules: {
'nodulus/no-private-imports': 'error',
'nodulus/no-undeclared-imports': 'warn',
}
}
]Rules
| Rule | Severity (recommended) | Description |
|---|---|---|
| nodulus/no-private-imports | error | Prevents importing internal files from another module directly. Only the public index (@modules/<name>) is a valid cross-module import target |
| nodulus/no-undeclared-imports | warn | Flags cross-module imports from modules not listed in the consuming module's imports array |
nodulus/no-private-imports
// ✗ error — accessing a private file directly
import { UserRepository } from '@modules/users/users.repository.js'
// ✓ correct — importing through the public index
import { UserService } from '@modules/users'nodulus/no-undeclared-imports
// src/modules/orders/index.ts
Module('orders', {
imports: ['users'], // 'payments' is not declared
})
// src/modules/orders/orders.service.ts
import { PaymentService } from '@modules/payments' // ✗ warn — undeclared import
import { UserService } from '@modules/users' // ✓ correctRelationship to nodulus check
| | nodulus check | eslint-plugin-nodulus |
|---|---|---|
| When it runs | On demand / CI step | On save / pre-commit / CI lint step |
| How it works | Full AST analysis across the whole project | Per-file ESLint rule evaluation |
| Circular dependency detection | ✓ | — |
| Editor integration (inline errors) | — | ✓ |
| CI gate | --strict flag | --max-warnings flag |
NITS Identity Tracking
Nodulus 1.2.5+ includes NITS (Nodulus Integrated Tracking System), which assigns a stable, unique mod_{hex} ID to every module. This allows the framework to track modules across renames, moves, and git branch switches — preventing identity loss during refactors.
NITS maintains a state file at .nodulus/registry.json in your project root. This file should be committed to version control.
How NITS assigns identities
NITS uses a three-step Verification Triangle algorithm:
- Match by path (maximum confidence) — same directory = same module.
- Match by semantic hash (high confidence, similarity ≥ 0.9) — same
Service,Repository, andSchemanames across locations = moved module. - Match by name (medium confidence) — a previously
stalemodule with the same name found at a new location = candidate for manual review.
Resolving merge conflicts
Because registry.json tracks project-level state, parallel branches may occasionally produce Git merge conflicts. To resolve them:
- Accept either side of the conflict to make the JSON valid again.
- Run
npx nodulus check. - NITS will automatically detect and heal the registry.
- Commit the updated
.nodulus/registry.json.
Logging
Nodulus emits structured, color-coded log events throughout the bootstrap pipeline.
Default behavior
Nodulus uses a high-performance Pino singleton internally. It automatically adapts to the environment:
- Development: Outputs human-readable, colorized logs via
pino-pretty. - Production: Outputs structured JSON logs (NDJSON).
| Environment Variable | Description |
|---|---|
| NODULUS_LOG_LEVEL | Minimum severity. E.g., debug, info, warn. (Or use NODE_DEBUG=nodulus) |
| NODULUS_LOG_FORMAT | Format output. pretty forces colorized text, json forces structured JSON, auto detects by environment. |
Semantic levels
| Level | When Nodulus uses it |
|---|---|
| debug | Internal bootstrap state, paths resolved, files scanned |
| info | Module loaded, route mounted, bootstrap complete |
| warn | Undeclared import (non-strict), unused import (non-strict), NITS fallback warning |
| error | Never — Nodulus uses throw NodulusError instead |
Using a custom logger (Pino)
import pino from 'pino'
const log = pino()
await createApp(app, {
logger: (level, message, meta) => {
log[level]({ ...meta, framework: 'nodulus' }, message)
}
})Total silence
await createApp(app, {
logger: () => {}
})Using the Nodulus Logger in your app (v1.5.0+)
You can use the same visual style for your own application logs by using useLogger. This creates a logger instance that respects your global log level settings and provides aligned, color-coded output.
import { useLogger } from '@vlynk-studios/nodulus-core'
const log = useLogger('my-app')
log.info('Application started')
// Output: [my-app] info Application startedUser-space logs
Nodulus does not intercept or wrap standard console.log calls from your application code.
Messages like Mounted N route(s) or Server running on http://localhost:3000 generated in your app.ts or server.ts are entirely your responsibility and will output normally without the [Nodulus] prefix.
Graceful Shutdown (v1.5.0+)
By default, when you press Ctrl+C or a process manager sends SIGTERM, Node.js exits immediately — leaving the port open and blocking the next restart (the classic "zombie process" problem).
Nodulus solves this with nodulus.listen(server). Call it once after app.listen() and Nodulus handles the rest:
import express from 'express'
import { createApp } from '@vlynk-studios/nodulus-core'
const app = express()
const nodulus = await createApp(app)
const server = app.listen(3000)
nodulus.listen(server, {
onShutdown: async () => {
await db.disconnect()
await redis.quit()
}
})What happens on shutdown
- SIGINT (Ctrl+C) or SIGTERM (kill / PM2 / Docker) fires.
- Nodulus calls
server.close()— no new connections are accepted, port is freed. - Your
onShutdown()hook runs (DB close, queue drain, etc.). - Process exits with code
0.
Manual shutdown
nodulus.listen() returns a shutdown() function you can call programmatically — useful for testing or custom signal logic:
const server = app.listen(3000)
const shutdown = nodulus.listen(server)
// Trigger shutdown from anywhere:
await shutdown()[!TIP] A double-invocation guard is built in — calling
shutdown()twice (or receiving both SIGINT and SIGTERM simultaneously) is safe and runs the sequence only once.
Error handling
All Nodulus errors are instances of NodulusError with a machine-readable code:
import { NodulusError } from '@vlynk-studios/nodulus-core'
try {
await createApp(app)
} catch (err) {
if (err instanceof NodulusError) {
console.error(err.code) // 'EXPORT_MISMATCH'
console.error(err.message) // human-readable description
console.error(err.details) // additional context (path, module name, etc.)
}
process.exit(1)
}| Code | When it's thrown |
|---|---|
| MODULE_NOT_FOUND | Discovered folder has no index.ts / index.js, or index.ts does not call Module() |
| INVALID_MODULE_DECLARATION | Module() name doesn't match folder name, or an identifier is declared incorrectly or outside a createApp() context |
| DUPLICATE_MODULE | Two modules share the same name or NITS ID |
| DUPLICATE_SERVICE | Two Service() calls share the same name |
| DUPLICATE_REPOSITORY | Two Repository() calls share the same name |
| DUPLICATE_SCHEMA | Two Schema() calls share the same name |
| MISSING_IMPORT | Module listed in imports does not exist in the registry |
| UNDECLARED_IMPORT | Module imports from another not listed in imports (strict only) |
| UNUSED_IMPORT | Module declares an import it never actually uses (strict only) |
| CIRCULAR_DEPENDENCY | A dependency cycle was detected (strict only) |
| EXPORT_MISMATCH | Name declared in exports is not an actual export of index.ts |
| INVALID_CONTROLLER | Controller file has no default export of an Express Router |
| ALIAS_NOT_FOUND | Configured alias points to a path that does not exist |
| ALIAS_INVALID | Wildcard alias (/*) points to a file instead of a directory (strict only) |
| DUPLICATE_ALIAS | Two aliases resolve to the same name but different paths |
| DUPLICATE_BOOTSTRAP | createApp() called more than once with the same Express instance |
| REGISTRY_MISSING_CONTEXT | A Nodulus API was called outside of a createApp() async context |
| INVALID_ESM_ENV | createApp() called in a non-ESM environment (missing "type": "module" in package.json) |
| CLI_ERROR | A CLI command failed with a validation or runtime error |
| PRELOADER_REQUIRED | (v1.5.0+) requirePreloader: true and the runtime pre-loader is not active |
| PRELOADER_VERSION_MISMATCH | (v1.5.0+) .nodulus/preload.js was generated by a different version of nodulus-core (warning only, not thrown) |
Advanced usage
getRegistry()
Returns the read-only registry bound to the current async execution context. Only callable within a createApp() scope.
[!CAUTION] @unstable API: Intended for advanced framework integrations and debugging. Structure may change without a major version bump.
import { getRegistry } from '@vlynk-studios/nodulus-core'
const registry = getRegistry()
const allModules = registry.getAllModules() // RegisteredModule[]
const alias = registry.resolveAlias('@modules/users')
const allAliases = registry.getAllAliases() // Record<string, string>NodulusRegistry interface (stable):
interface NodulusRegistry {
hasModule(name: string): boolean
getModule(name: string): RegisteredModule | undefined
getAllModules(): RegisteredModule[]
resolveAlias(alias: string): string | undefined
getAllAliases(): Record<string, string>
}NodulusRegistryAdvanced interface (@unstable):
interface NodulusRegistryAdvanced extends NodulusRegistry {
getDependencyGraph(): Map<string, string[]>
findCircularDependencies(): string[][]
}Use cases
Microservices
Isolate each domain into a module and share types through @modules/shared. Each service stays lean with zero cross-cutting concerns.
Monoliths
Enforce clean module boundaries at bootstrap, not code review. Nodulus catches circular dependencies and missing imports before your server starts.
Fast prototyping
Scaffold a new feature by creating a folder and an index.ts. Nodulus handles all the boilerplate of wiring routes and middlewares.
Requirements
| | Minimum |
|---|---|
| Node.js | 20.6.0 |
| Express | 5.x |
| TypeScript | 5.0+ (optional) |
| ESLint | 8.0+ (optional, for eslint-plugin-nodulus) |
Why 20.6? Nodulus uses the Node.js ESM Hooks API (
register) for runtime alias resolution. Native support without--experimental-loaderrequires Node 20.6+.
ESM Only
Nodulus is built as a pure ESM package. It does not support CommonJS (require()).
import { createApp, Module, Controller } from '@vlynk-studios/nodulus-core'Your project must have "type": "module" in package.json. Nodulus validates this at bootstrap and throws INVALID_ESM_ENV if it is missing.
TypeScript
Types are bundled — no @types/nodulus needed.
import type {
CreateAppOptions,
NodulusApp,
NodulusRegistry,
NodulusRegistryAdvanced,
NodulusConfig,
NitsConfig,
ModuleOptions,
ControllerOptions,
ServiceOptions,
RepositoryOptions,
SchemaOptions,
RegisteredModule,
MountedRoute,
GetAliasesOptions,
ModuleRegistration,
FeatureRegistration,
LogLevel,
LogHandler,
} from '@vlynk-studios/nodulus-core'
// v1.5.0+ pre-loader utilities
import type { PreloadConfig } from '@vlynk-studios/nodulus-core'
import { isPreloaderActive, getPreloadConfig } from '@vlynk-studios/nodulus-core'License
MIT
Developed and maintained by Vlynk Studios.
