Sub-apps
Isolated JustScale() scopes composed into a parent app with scope-switched bridges
A sub-app is a separate JustScale() compilation unit with its own DI container, its own controllers, and its own AbstractContainer reflection. You compose it into a parent by adding it with .add(). The parent provides the services the sub-app declared it needs via .requires(); the framework bridges those services into the sub-app at compile time, preserving async scope so calls through them still execute in the parent's container.
Sub-app vs Feature
Features flatten into the parent scope — everything a feature provides lands directly in the parent's DI container. Sub-apps don't. A sub-app keeps its internals invisible to the parent; only what it expressly exposes through its own route handlers or (future) .exposes() reaches out.
- Feature — flat merge, shared scope. Use when everything the bundle provides is meant to be callable from anywhere in the parent app. Classic example:
AuthFeature. - Sub-app — isolated scope, explicit requires, scope-switched bridges. Use when internals genuinely don't belong in the parent's DI graph. Classic examples: admin subsurface, plugin packs, tenant-scoped sub-trees, docs-serving subsystems.
Declaring a sub-app
A sub-app is just a JustScale() that called .requires() at least once. Calling .requires(T) marks the builder as a sub-app and makes it un-compilable on its own — the type system gates .compile() with a branded error until the sub-app is composed into a parent that provides T.
import JustScale, { Config } from '@justscale/core';
import { HttpConfig } from '@justscale/http';
import { OpenApiConfig, OpenApiFeature } from '@justscale/feature-openapi';
import { CatalogService } from '../domains/catalog/catalog.service.js';
import { InventoryService } from '../domains/catalog/inventory.service.js';
import { StockController } from './stock.controller.js';
// Sub-app: separate scope, mounts under /admin/*
// .requires() lines are the explicit surface the parent must cover.
// Parent's services are bridged in at compose time and the docs
// routes merge into the shop's HTTP server via delegation.
export const DocsSubApp = JustScale()
.requires(CatalogService)
.requires(InventoryService)
.requires(Config.of(HttpConfig))
.add(StockController)
.add(OpenApiFeature)
.build();Composing into a parent
The parent just .add()s the built sub-app. At build time the framework checks that the parent's TProvided covers every token the sub-app listed — missing requires are a compile error, pointing at the exact .add(SubApp) call.
import JustScale from '@justscale/core';
import { CatalogFeature } from './domains/catalog/catalog.feature';
import { DocsSubApp } from './docs/docs.sub-app';
JustScale()
.add(CatalogFeature) // provides CatalogService, InventoryService
.add(defaultHttpConfig) // provides Config.of(HttpConfig)
.add(DocsSubApp); // TypeScript verifies the three requires are coveredCompile-time gate: if CatalogFeature wasn't added before DocsSubApp, TypeScript emits a MissingSubAppRequiresError naming the uncovered token — no runtime surprise.
Runtime composition
Three framework mechanisms connect sub-app and parent at runtime:
- Scope-switched bridges. Each token in the sub-app's
.requires()is resolved against the parent's container, wrapped in aProxy, and registered in the sub-app's container. Method calls on the bridged service run insiderunWithContainer(parentContainer, ...)— sogetContainer()inside a parent service reads the parent's scope, not the sub-app's. - Route delegation. The parent app's
match(method, path)tries its own controllers first, then falls through to each sub-app'smatch()recursively. Parent routes win on collision. The matched route carries its owning container asowningContainer;app.execute()uses that container forrunInFullRequestScope, so sub-app handlers read the right scope. - Build-context inheritance. When a sub-app compiles, its adapter installs (e.g. the HTTP adapter triggered by a
Get()route factory) forward to the parent's kernel. One HTTP server serves the whole composition tree — no per-sub-app listener.
Per-scope reflection via AbstractContainer
Every compiled scope binds its own AbstractContainer. Services in a sub-app that inject AbstractContainer see only that sub-app's controllers; parent'sAbstractContainer sees only parent's. This is how per-scope OpenAPI works — an OpenApiFeature added in the sub-app generates a spec for just that surface, with a different title and path than the root's spec.
// Root scope
JustScale()
.add(ShopFeature)
.add(createConfig({
provides: [OpenApiConfig],
factory: () => ({
[OpenApiConfig.key]: { info: { title: 'Shop API', version: '1.0.0' } },
}),
}))
.add(OpenApiFeature) // serves /openapi.json covering shop
.add(DocsSubApp); // sub-app with its own OpenApiFeature...
// Docs sub-app
JustScale()
.requires(CatalogService)
.add(StockController)
.add(createConfig({
provides: [OpenApiConfig],
factory: () => ({
[OpenApiConfig.key]: {
info: { title: 'Admin API', version: '1.0.0' },
specPath: '/admin/openapi.json',
},
}),
}))
.add(OpenApiFeature) // ...serves /admin/openapi.json covering just admin
.build();Two OpenAPI specs, one HTTP server, fully disjoint content — each reflects only its own scope's AbstractContainer.
Multi-level nesting
Sub-apps compose recursively: a sub-app's builder can itself .add(otherSubApp). Routes delegate through every level, the build context bubbles to the root, and TRequires check at each boundary — grandchild's requires must be covered by the child, which must be covered by the root.
const Inner = JustScale()
.requires(SharedService)
.add(InnerController)
.build();
const Middle = JustScale()
.requires(SharedService) // middle must cover Inner's require
.add(Inner)
.build();
const root = JustScale()
.add(SharedService) // root covers middle's require
.add(Middle)
.build();
// Routes from Inner reachable via root.match()When to reach for a sub-app
- Admin subsurface. Admin-only services, guards, rate limits that you want invisible to the customer-facing shop scope.
- Plugin packs. Each plugin ships as a sub-app with its own internals; only the
.requires()surface touches the host. - Tenant scopes. Per-tenant container with tenant-specific config bridged in from a tenant-aware parent.
- Documentation / OpenAPI surfaces. A separate scope emits its own spec reflecting just its routes, without leaking the rest of the app.
What sub-apps are not
- Not a separate process. Sub-apps run in the same Node process as their parent. For process isolation, look at the cluster transport (distributed roadmap).
- Not a replacement for Features. If everything a bundle provides is meant to be available to the rest of the app, it's a Feature, not a sub-app. Sub-apps are about keeping internals private.
- Not free. Each bridged service method call pays a
Proxyhop and an async-context switch. Fine for RPC-style boundaries; worth measuring before putting something hot behind a bridge.