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.

docs.sub-app.tsTypeScript
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.

app.tsTypeScript
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 covered

Compile-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 inside runWithContainer(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's match()recursively. Parent routes win on collision. The matched route carries its owning container as owningContainer; 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.

per-scope-openapi.tsTypeScript
// 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.

nesting.tsTypeScript
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 Proxy hop and an async-context switch. Fine for RPC-style boundaries; worth measuring before putting something hot behind a bridge.