<!-- Markdown mirror of https://justscale.sh/docs/fundamentals/sub-apps -->

# 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

```typescript
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

```typescript
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's`AbstractContainer` 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

```typescript
// 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

```typescript
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.

## Next Steps

- Features
- Controllers
- OpenAPI
