The NetScript architecture
NetScript is a set of Deno-native framework packages, published on JSR and composed into a running backend by first-party plugins, orchestrated locally by Aspire. This essay answers one question: when you build an app on NetScript, what are the layers you actually work with, what runs where, and which way do the dependencies point?
Read this for orientation, not step-by-step work. The [tutorials]( Tutorials ) and [how-to guides]( How-to guides ) are the practical paths; the glossary defines every term of art; and each layer below routes you onward to the capability hub or reference unit that elaborates it.
The thesis: the published surface is the product
NetScript treats publishing as the whole point. A package's mod.ts is not an
implementation detail that happens to be exported — it is the contract the
framework makes with you, the caller. The shape you see through deno doc, the
JSR doc score, and the type checker is the surface everything else serves.
Two consequences explain most of the design decisions you will meet:
- Types are designed before implementation. The public types and the README
come first; the code that satisfies them comes second. If
deno doc <package>does not read end-to-end as a manual, the surface is considered broken, regardless of how the internals work. - Simple is preferred over easy at every boundary. Approachability comes from a small, predictable surface rather than hidden magic. The common 80% case is one chained call; advanced cases unfold one method deeper rather than appearing as required configuration on the entry point.
The deeper rationale for this contract-first stance is the subject of [Contracts & type flow]( Contracts & type flow ).
Four layers, one direction of dependency
The single most useful thing to hold in your head is the direction of the arrows. Every layer below depends only on the layers beneath it, and nothing ever edits another boundary's code. Build that picture once and the rest of the framework stops surprising you.
1. Typed contracts — the base, with no dependencies
At the bottom sits the contract layer: schemas and types that describe what a
service accepts and returns, with essentially no runtime and no dependencies of
their own. A contract is a plain object built from @orpc/contract and zod
schemas; the same object is imported by the service that implements it and by
the client that calls it, so the two can never drift. Because contracts depend
on nothing, everything above can depend on them freely.
This is why "change the shape" is a type error you see at compile time rather
than a 500 you discover in production. The contract machinery —
@orpc/contract, zod, and implement() — is the subject of
[Contracts & type flow](
Contracts & type flow
); the
generated reference is [@netscript/contracts](
contracts
).
2. Packages and plugins — composing the substrate
Above the contracts sit two kinds of building block:
- Platform packages (the
@netscript/*kernel) know nothing about any one capability. They are the shared substrate: [config]( config ), [service]( service ), [database]( database ), [queue]( queue ), [kv]( kv ), [telemetry]( telemetry ), and the rest. A package wraps one concern behind a small surface and never reaches across into another package's internals. - Plugins turn that substrate into a capability — workers, sagas, triggers, auth, streams. A plugin composes packages; it does not redefine them. It declares its capability by registering named contributions (a service, a background processor, a Prisma schema fragment, a stream topic) and leaves the wiring to the host.
The rule that keeps this clean: a plugin depends on packages, packages depend on
contracts, and the arrow never reverses. A workers plugin imports the contract
types from its sibling [@netscript/plugin-workers-core](
workers
)
package rather than inventing its own; an auth plugin composes the auth seam
rather than embedding a provider. The mechanics — public plugin versus core
package, manifests, contributions, and the generated registry — live in
[The plugin system](
The plugin system
).
(You may see the word archetype in the doctrine and tooling. That is an internal classification the framework uses for its own quality gates — as an app author you meet it only indirectly, through the shape of a scaffolded plugin. See [The plugin system]( The plugin system ) if you are curious; it is not something you configure.)
3. The host — assembling plugins through generated registries
A workspace is more than a pile of plugins; something has to assemble them. That
is the host's job. When you run the scaffold's generate step, NetScript reads the
plugin list from netscript.config.ts, validates each plugin's contributions
against fixed extension axes, and writes generated registry files that wire
the contributions together. The host then loads those registries at startup.
The discipline here is the same as everywhere else: the host reads plugin manifests and generates wiring; it never asks you to hand-edit one plugin to make room for another. Two enabled plugins coexist because the host merged their registrations, not because either was patched. This is why enabling a capability is a config line plus a regenerate, and why the generated files are reproducible output you do not author by hand.
4. The runtime — executing services and background processors
At the top, the host's assembled workspace runs. NetScript is not one process: it is a small constellation of single-purpose services, each owned by a plugin and each speaking a contract, brought up together by Aspire. Two kinds of thing execute here:
- Services answer requests. Most are
[oRPC services](
Services & contracts
) mounted at
/api/rpc/*; trigger ingress is the exception, exposing raw Hono routes because webhooks are webhook-shaped, not contract-shaped. - Background processors do work outside the request path: the worker job
runtime, the durable saga runtime, schedulers, and file watchers. They own
state and lifecycle, persist through a store, and shut down cleanly through a
{ stop() }handle.
The topology below is the canonical shape of a fully-loaded workspace, with every first-party plugin enabled.
┌───────────────────────────────────┐
│ Aspire AppHost │
│ dashboard http://localhost:18888│
│ provisions Postgres + Redis (KV) │
└───────────────┬───────────────────┘
│ wires env, ports, resources
┌─────────────────────────────────┼─────────────────────────────────┐
│ │ │
┌────────▼────────┐ ┌────────▼─────────────────────────┐
│ example service │ │ PLUGIN SERVICES (oRPC / Hono) │
│ users :3001 │ ├──────────────────────────────────┤
│ /api/rpc/* │ │ workers :8091 (jobs/tasks) │
│ oRPC contract │ │ sagas :8092 (durable flows)│
└────────┬─────────┘ │ triggers :8093 (raw Hono in) │
│ │ auth :8094 (oRPC /api/rpc)│
│ contracts └────────┬─────────────────────────┘
│ (@orpc/contract + zod) │
│ ┌────────▼─────────┐
┌────────▼──────────────────────────┴──────────────────┴──────────────────────┐
│ PACKAGES (@netscript/* platform substrate) │
│ config · runtime-config · service · contracts · sdk · plugin · database │
│ queue · kv · cron · logger · telemetry · aspire │
└────────┬──────────────────────────────────────────────────────────┬──────────┘
│ produces durable change-data events │
┌────────▼─────────┐ ┌──────────▼─────────┐
│ streams :4437 │ durable-stream service │ Postgres / Redis │
│ HTTP / SSE │ workers · auth · sagas mirror here │ (relational + KV) │
└───────────────────┘ └────────────────────┘
Read the picture in three bands. Aspire sits on top as the local
orchestrator: cd aspire && aspire start brings up Postgres and Redis (the default cache backend; garnet or deno-kv are selectable via --cache-backend) and starts
every service before any netscript db command runs, with traces, logs, and
health landing in the dashboard at http://localhost:18888. Plugin services
sit in the middle, each owning exactly one service on a fixed port; your own
scaffolded users service sits alongside them, speaking the same contract
machinery. The packages sit underneath as the shared substrate every plugin
composes against. See
[Orchestration with Aspire](
Orchestration with Aspire
) for the
control-plane detail.
| Surface | Port | Protocol | Owner |
|---|---|---|---|
| Aspire dashboard | :18888 | HTTP (UI) | Aspire AppHost |
| Example service (users) | :3001 | oRPC over /api/rpc/* |
your service |
| Workers | :8091 | oRPC | @netscript/plugin-workers |
| Sagas | :8092 | oRPC | @netscript/plugin-sagas |
| Triggers | :8093 | raw Hono routes | @netscript/plugin-triggers |
| Auth | :8094 | oRPC over /api/rpc/v1/auth/* |
@netscript/plugin-auth |
| Streams | :4437 | durable-stream HTTP / SSE | @netscript/plugin-streams |
A subtlety worth fixing early: triggers expose raw Hono routes, not oRPC,
because trigger ingress is webhook-shaped rather than contract-shaped. Every other
plugin service is an oRPC service, and service RPC is mounted at /api/rpc/* (not
/rpc). Those two facts trip people up more than any other detail here.
The pure-backend seam: a port with interchangeable adapters
There is one recurring pattern that, once you see it, you will recognize across the whole framework: the pure-backend seam. It is how NetScript keeps a capability open to several implementations without leaking any of them into the contract.
The shape is always the same:
- A package defines a port — a pure interface, no IO — that says what the capability must be able to do.
- Adapter code implements that port against a concrete technology. Adapters are pure backends: they depend on the port, never the reverse, and add no public surface of their own beyond the factory you call.
- A plugin composes exactly one active adapter, selected by configuration, and exposes it as a running service.
Auth is the most visible instance: a core seam defines an AuthBackendPort, three
pure adapters implement it (a default interactive KV-OAuth backend, plus WorkOS
and Better-Auth), and the auth plugin composes the one backend named by
NETSCRIPT_AUTH_BACKEND. A capability a given backend cannot honor fails loud
through a typed AuthBackendOperationUnsupportedError rather than silently doing
nothing. The full treatment — the port, the three backends, and the per-backend
capability matrix — lives in
[The auth model](
Auth model
); the
capability hub is [Authentication](
Authentication
).
This is why "swap the backend" is a configuration change in NetScript and not a rewrite: the contract is the port, and adapters are interchangeable behind it.
Configuration records intent before runtime wiring
NetScript keeps authored intent separate from the concrete runtime wiring that
makes a workspace run. The project-level intent lives in netscript.config.ts,
authored with [defineConfig](
config
) or
defineConfigAsync. That object says what the project is — its name, paths,
enabled plugins, services, apps, database declarations, saga groups, trigger
groups, and runtime-config output paths. At startup, loadConfig and initConfig
resolve that authored form into a validated NetScriptConfig, and getConfig is
the synchronous read path after initialization.
The split matters because the host needs both halves: netscript.config.ts
carries workspace intent such as paths and the plugin list
(./plugins/workers/mod.ts, ./plugins/sagas/mod.ts, and the rest), while the
operational database and resource details are carried by appsettings.json and
the Aspire AppHost. Plugin packages can contribute partial config fragments
through @netscript/config/merge, but those fragments merge into the
already-validated project shape rather than replacing project identity. Runtime
overrides are a third layer exposed by
[@netscript/runtime-config](
runtime-config
) for
topics such as jobs, sagas, triggers, features, and tasks.
Think of it as three nested layers: netscript.config.ts declares project intent,
plugin config contributions extend that intent, and
appsettings/Aspire/runtime-config materialize the environment-specific wiring.
This is also where the capability switches live — the saga durable store
(NETSCRIPT_SAGA_STORE=kv|prisma), the queue provider, and the active auth
backend (NETSCRIPT_AUTH_BACKEND) are all selected here rather than in code. It
is also why the database docs note a scaffold reality: databases.config can be
empty in netscript.config.ts while the chosen engine — Postgres by default, or
mysql / mssql / sqlite selected with --db at scaffold time — is still
provisioned by Aspire (sqlite is file-backed, so it has no Aspire container
resource) and described in appsettings.json. See
[Runtime configuration](
Runtime configuration
).
Naming is part of the contract
Entry points follow fixed verbs, so you can predict behavior from the name alone:
defineX(...)returns a frozen definition and does no runtime work.createX(...)constructs a runtime object that owns state and IO.startX(...)constructs and starts a runtime, returning a{ stop() }handle.useX(...)is a hook-style accessor inside a request, render, or handler scope.withX(...)is a builder method that returns a new builder and never mutates.
When a mod.ts would re-export more than roughly twenty symbols, the surface
splits into subpath exports (for example @netscript/logger/middleware and
@netscript/logger/orpc) to keep bundles lean and isolate adapter-specific code
from production exports.
Durable behavior is modeled as state machines
For the background processors that own durable flows, NetScript models them as explicit state machines rather than generic event-handler ladders. [Durable sagas]( Durable sagas ) are the clearest example: a saga is named phases with explicit correlation, persistence, and compensation, declared through a builder.
// plugins/sagas/user-registration.saga.ts
import { defineSaga } from "@netscript/plugin-sagas-core";
type State = Readonly<{ status: string }>;
const saga = defineSaga("user-registration")
.state<State>({ status: "started" })
.on("UserRegistered", (s, _message, _context) => {
s.state = { status: "welcoming" };
})
.on("WelcomeEmailSent", (s, _message, _context) => {
s.state = { status: "complete" };
})
.build();
Failure handling is just as explicit. Handlers throw rich errors; a supervisor —
not a scatter of defensive try/catch inside the handler — decides whether to
restart or escalate, and owns the telemetry for that decision. The durable runtime
persists through a selectable kv or prisma store. The full flow is covered in
[The durability model](
The durability model
).
Observability is built into the substrate
Because every plugin composes the same packages, observability is wired once and inherited everywhere. aspire starts an OTLP collector, and the worker job path — dispatch, execution, scheduler, and subprocess continuation — emits real OpenTelemetry spans automatically, so job traces appear in the Aspire dashboard without any handler code.
The full picture is in [Observability]( Observability ).
The publish gate is the architecture gate
Tests are treated as fitness functions, and the publish gate is where the
architecture is enforced rather than merely described. deno publish --dry-run, deno doc, the workspace type checks, the semantic tests, and an
export/docs audit are not optional steps — they are the mechanism by which every
boundary rule above survives contact with real code. A surface that does not pass
them is broken by definition, which is precisely why the published surface can be
trusted as the product.
Where to go next
- The contract machinery: how types flow from a contract to a client in [Contracts & type flow]( Contracts & type flow ).
- The plugin mechanics: public-versus-core split, manifests, and registries in [The plugin system]( The plugin system ).
- The auth seam in depth: the port, the three backends, and the capability matrix in [The auth model]( Auth model ).
- Durable behavior: how sagas and workers persist and recover in [The durability model]( The durability model ).
- The local control plane: how Aspire provisions everything in [Orchestration with Aspire]( Orchestration with Aspire ).
- Exact symbols: open any package's reference unit.
- Hands-on: the [tutorials]( Tutorials ) and [how-to guides]( How-to guides ).