Zudojs
A 39-package modular TypeScript framework — DI, lifecycle, config, HTTP, events, CQRS, queues, tenancy and observability, each usable on its own, with the dependency direction enforced by a build-time check.
- 39
- Packages
- 198k
- Lines of TypeScript
- 5
- Enforced tiers
- 11
- Frontend adapters
- Role
- Author
- Year
- 2026
- Status
- Open source
- Type
- TypeScript application framework
01Overview
Zudojs is a modular TypeScript framework for backend services, APIs, distributed systems and fullstack platforms. It exists because I kept rewriting the same infrastructure — a DI container, layered config, a lifecycle with graceful shutdown, an event bus, a queue abstraction — in every project, slightly differently each time.
Rather than one opinionated runtime, it is 39 independent packages published under the @zudojs scope: 2,032 TypeScript source files, roughly 198,000 lines, 242 package test files. An application installs only the concerns it actually has.
Three beliefs sit under all of it. Explicit over implicit — dependencies, boundaries and lifecycles should be visible and enforceable. Composition over inheritance — behaviour is assembled, not inherited. Type safety end to end — types flow from request input to database output rather than stopping at the controller.
The design constraint was explicitness. Frameworks that resolve dependencies by reflection and register behaviour by side effect are pleasant for a week and opaque forever after. Everything in Zudojs is registered by an explicit token and ordered by a declared dependency graph.
02The problem
Node's ecosystem is excellent at HTTP and thin everywhere else. The moment an application needs background jobs, an event bus, multi-tenancy, feature flags, transactions and tracing to work together, you are assembling seven unrelated libraries with seven lifecycle models and no shared context.
The two usual answers are both bad. Adopt a batteries-included framework and inherit its entire runtime and deployment model. Or hand-roll the glue and rebuild it, differently, in the next project.
There is a third problem underneath both: in a framework, breaking changes propagate outward. If a foundation package depends on a transport package, every application that touches the foundation inherits the transport's instability. Most frameworks solve this with convention. Zudojs solves it with a check that fails the build.
Zudojs is the third answer: independent packages with consistent contracts, so composition is a choice rather than a condition of entry.
03Architecture
- 01
Five tiers, dependencies flowing inward
Every package sits at a tier: 0 leaf (errors, types), 1 foundation (container, config, logger, events, cache, queue, database, security, tenancy and the rest), 2 application (core, runtime, cqrs, auth, rpc, api, openapi), 3 transport (http, cli), 4 developer experience (testing). A package may depend only on its own tier or lower. The graph is a DAG with no upward arrows, which is what keeps @zudojs/errors stable enough for all 39 packages to import it.
- 02
The tier map is executable
scripts/package-tiers.js is the single source of truth, imported by both the architecture check and the Vitest boundary test. It also verifies that no cycles exist and that every internal dependency uses the workspace:* protocol, so pnpm resolves siblings from source rather than quietly from the registry.
- 03
Runtime as a state machine
Created → Initializing → Ready → Running → Draining → Stopped, with components moving through register → install → initialize → start → running → stop → dispose. Startup orders modules by declared dependencies; shutdown reverses it, drains in-flight requests to a deadline, then disposes. SIGINT and SIGTERM are handled by the runtime, not by application code.
- 04
Context without passing context
AsyncLocalStorage carries execution, tenant, transaction and logger context through async call chains, so a repository three layers down knows which tenant it is serving without that being threaded through every signature. Long-running operations take an AbortSignal, which is what makes graceful shutdown and timeouts actually cancel work rather than orphan it.
- 05
Controlled context, not the whole application
Plugins, modules and middleware receive a narrow context — container, config, logger, events — rather than the application object. A plugin cannot reach past its boundary, which is the difference between an extension point and a back door.
- 06
Errors as values
Every package roots its errors in @zudojs/errors. Each carries a machine-readable code, a cause, a severity, structured metadata, a transport status code, and an expose flag deciding whether it is safe to show a client. Internal failures are mapped at the transport boundary rather than leaking upward.
- 07
Infrastructure neutrality
@zudojs/adapters is the boundary between the framework and the outside world. Databases, queues, cloud providers and storage backends sit behind it, so an application is never coupled to a vendor by its framework.
- 08
Generation and frontend adapters
zudojs-cli scaffolds backend, frontend or fullstack workspaces across three backend architectures — monolith, modular monolith, microservice — and eleven frontend targets: React, Next, Vue, Nuxt, Angular, Svelte, SvelteKit, Astro, vanilla, Flutter and React Native. Fullstack projects get apps/web beside apps/backend or apps/gateway, a shared types package, and a dev proxy.
04Key decisions
Monorepo, independently published packages
One repository for coherent contracts, separate publishes so nobody installs 39 packages to get a DI container. Each package has its own build, types and version — 37 at 1.0.0, openapi and auth-oauth at 1.1.0.
Token-based DI instead of decorator reflection
Decorator-and-metadata DI needs a compiler flag, breaks under bundlers, and hides the dependency graph. Explicit tokens are more typing and dramatically more debuggable. The container supports singleton, scoped and transient lifetimes, and detects circular dependencies at resolution with a named error rather than a stack overflow.
One tier map, imported twice
The boundary rules were originally duplicated between the check script and the test. The copies drifted, and auth-oauth ended up passing the Vitest check while failing the script — the exact failure the rule exists to prevent, produced by the tooling meant to enforce it. Both now import the same module, and the comment above it says why.
Lifecycle as a real state machine
@zudojs/lifecycle orders startup by declared dependencies and tears down in reverse, so a queue consumer can never outlive the database connection it reads through. Graceful shutdown is a framework guarantee, not an application chore.
Small files, enforced
Maximum five files per folder excluding the barrel, maximum 150 lines per file, dot-notation names like pluginLifecycle.core.ts, and barrel index.ts files that re-export public API and contain no logic. Arbitrary limits, but they make a 2,000-file codebase navigable by path alone.
ESM-only, strict TypeScript, modern floor
No dual CJS/ESM build matrix. Node 24 and pnpm 11 are the floor, TypeScript is pinned through a workspace catalogue, and releases go through changesets. The framework targets modern Node and refuses to carry the compatibility surface that supporting everything would bring.
05Stack
- TypeScript
- Node.js
- pnpm workspaces
- Zod
- Vitest
- changesets
- ESM