Skip to content

Composing an App

A Slingshot app is composed from a small set of pieces. Read the rest of this section once in order and you’ll know how the framework fits together.

PieceWhat it isDeclared with
AppThe root. Lists the packages and plugins it composes.defineApp(...)
PackageA self-contained feature module — your blog, your notifications, your saas modules. Owns its entities, routes, middleware.definePackage(...)
PluginA framework-level plugin — auth, postgres, bullmq, search. Provides infrastructure your packages depend on.SlingshotPlugin
EntityA typed data model. Generates CRUD routes, DTOs, OpenAPI.defineEntity(...)
Domain + RouteA typed custom route inside a package, for things that don’t map to CRUD.domain(...), route.*()
Package ContractA package’s typed public surface — the capabilities and entity refs it publishes for other packages to consume.definePackageContract(...)
EventA typed message published by one piece, subscribed by another.defineEvent(...) + the event bus

Slingshot has two first-class authoring tiers. They are not the same thing and one is not the “escape hatch” version of the other:

  • definePackage(...) — for app-side feature composition. You write packages to model your app’s features (a blog, a notifications module, a saas billing surface). Packages are declarative: they describe entities, domain routes, middleware bindings, and the capabilities they publish or consume. Most application code lives here.

  • SlingshotPlugin — for framework-level plugin authoring. Plugins implement infrastructure: auth, persistence, queues, search, mail. They use lifecycle hooks (setupMiddleware, setupRoutes, setupPost, teardown) to do async bootstrap, register framework boundary contracts (route auth, actor resolver, rate limiter), and mount conditional middleware. Almost every plugin you import (slingshot-auth, slingshot-postgres, slingshot-bullmq, …) is one of these.

You compose both at the app root: defineApp({ packages: [...], plugins: [...] }). Packages typically depend on plugins (dependencies: ['slingshot-auth']); plugins do not depend on packages. See When to use which below.

defineApp({ packages, plugins })
├── package "blog"
│ ├── entity Post (defineEntity) → /posts CRUD
│ ├── domain "extras" (domain + route.*) → /extras/preview
│ └── public surface (definePackageContract)
│ ├── publishes capability (Mailer)
│ └── publishes entity ref (Post, readonly(['list', 'getById']))
├── package "notifications"
│ ├── declares dependency ← contract: blog
│ ├── requires capability (Mailer) ← from blog's contract
│ ├── reads via published ref (BlogRefs.Post) ← from blog's contract
│ └── subscribes 'blog:post.published' ← via event bus
└── plugin "slingshot-auth" (framework-level plugin: lifecycle, infra)

The app reads like a table of contents. Each package owns its slice. Cross-package wiring happens through Package Contracts (capabilities + narrowed entity refs) and events (decoupled side effects) — never through direct imports between packages.

There are three authoring tiers, not two. Each owns a different concern, and middleware can live at any of them.

TierWhat it isMiddleware story
defineAppRoot config: lists plugins and packages, configures framework subsystems (auth, db, security, etc.).middleware: MiddlewareHandler[]static global, runs after plugin middleware and before route matching. No lifecycle hooks needed.
definePackageApp-side feature composition: declarative entities, domain routes, named middleware, capabilities, mount path.middleware: Record<string, MiddlewareHandler>named registry, attached to specific package routes by reference (e.g. route.post({ middleware: ['rateLimit'] })). Scoped to the package.
SlingshotPluginFramework-side plugin authoring: lifecycle hooks, async bootstrap, registrar registrations.Imperative app.use('*', ...) from inside setupMiddleware. Required when middleware needs dynamic, conditional, or async setup.

Under the hood, packages compile into framework plugins — there is one runtime concept (plugins) and two authoring surfaces (definePackage declarative, SlingshotPlugin imperative), with defineApp as the assembly root. Pick by what you’re modeling:

If you are building…Use
App-level static cross-cutting middleware (tracing, request-id, custom logging)defineApp({ middleware: [...] })
A feature with its own routes/entities (blog, billing, chat)definePackage(...)
Per-package middleware that some of the package’s routes opt intodefinePackage.middleware (named registry)
Auth, persistence, queues, search, mail (anything needing async bootstrap)SlingshotPlugin
Anything that registers with the framework registrar (route auth, request actor resolver, rate limit adapter)SlingshotPlugin
Conditional mounting based on resolved configSlingshotPlugin

Reach for SlingshotPlugin only when you need lifecycle hooks. If your middleware is a plain function and doesn’t need bootstrap, defineApp.middleware (global) or definePackage.middleware (scoped) is enough. Plugins are for the things that need to do something at boot — registering framework boundaries, opening connections, building runtimes, mounting middleware that depends on resolved config — not just attaching behavior.

  • Async bootstrap with handoff between lifecycle phases. Auth opens DB connections, validates JWT secrets, builds adapters in setupMiddleware, then registers boundary contracts in setupPost based on the bootstrap result. There is no way to express a bootstrap-completion handoff from a static module description.
  • Registrar boundary registrations. registrar.setRouteAuth(...), setRequestActorResolver(...), setRateLimitAdapter(...), addCacheAdapter(...), addEmailTemplates(...) — framework-level singleton contracts.
  • Imperative app.use('*', ...) middleware that runs before package middleware. CSRF protection, MFA enforcement, identify middleware. Plugin setupMiddleware runs before setupRoutes; package middleware mounts during route registration.
  • Conditional mounting based on resolved config. Auth conditionally mounts ten different routers (MFA, passkey, magic-link, password-reset, SAML, …) based on whether each feature is enabled in config.
  • Lifecycle hooks beyond bootstrap. seed() for initial data, teardown() for cleanup, setupPost(...) for post-startup health checks or OpenAPI inspection.
LayerMounted byScopeUse for
Framework defaultsThe framework, from defineApp configAll routes (CORS, secure headers, rate limiting baseline)Things every app needs that are config-driven, not code
App-level static middlewaredefineApp({ middleware: [...] })All routes, after plugin middleware, before route matchingTracing, request-id, custom logging — anything static you’d otherwise have to wrap in a plugin
Plugin imperative middlewareSlingshotPlugin.setupMiddlewareAll routes (or a path prefix), runs earliestAuth, CSRF, identify, MFA enforcement — anything needing async/conditional setup
Package-named middlewaredefinePackage({ middleware: { ... } })Only the package’s routes that reference the namePer-feature middleware: ownership checks, package-scoped rate limits
Per-route middlewareroute.*({ middleware: ['name', ...] })One routeEndpoint-specific constraints
Entity hooksdefineEntity afterHooks, etc.Generated routes for one entityCross-cutting on an entity’s lifecycle (audit, denormalization, downstream events)

For “cross-cutting metrics” specifically — pick by scope:

  • Every request including framework routes, no async setup → defineApp.middleware.
  • Every request, requires registering a metrics adapter at boot → SlingshotPlugin.setupMiddleware.
  • Only a specific package’s routes → definePackage.middleware referenced from each route.
  • Only one entity’s generated routes → an entity hook.

Picking the right layer minimizes blast radius — package-scoped middleware doesn’t run on unrelated routes, and an entity hook doesn’t run on unrelated entities.

  • “I want to add a feature with its own routes and data” → definePackage.
  • “I want my package’s routes to use auth” → definePackage with dependencies: ['slingshot-auth'].
  • “I want a static piece of middleware on every request” → defineApp({ middleware: [...] }).
  • “I want async setup that other things wait for” → SlingshotPlugin.
  • “I want to provide a framework boundary contract (route auth, actor resolver)” → SlingshotPlugin.
  • “I want middleware that mounts based on config” → SlingshotPlugin.
  • “I want to teach the entity system a new operation pattern” → entity executor override, not a plugin (see Lower-level Seams).

If you find yourself reaching for SlingshotPlugin to model a feature module, the package shape probably fits better. If you find yourself trying to express async bootstrap or conditional middleware in definePackage, you want a plugin instead.

The pages in this section build on each other. If this is your first time, read them in order:

  1. App Config — what defineApp(...) accepts and how the runtime discovers app.config.ts.
  2. Packages — declare a feature module with its own entities, routes, middleware, and dependencies.
  3. Domains and Routes — typed custom routes inside a package, for endpoints that don’t map to entity CRUD.
  4. Package Contracts — declare a package’s typed public surface so other packages can consume its capabilities and entities through a provider-owned, boot-validated contract.
  5. Events and the Event Bus — the shared transport for cross-package side effects, SSE, and webhook delivery.
  6. Plugin Interface — the SlingshotPlugin contract for framework-level plugin authoring (auth, infra, custom middleware).

Once you have an app composed, follow the journey into per-route concerns (Authoring Routes), data deep dives, security, realtime, operations, and production. Two reference pages live at the end of the journey for when you need to step beneath the primary path:

  • Capabilities and entityRef — the underlying mechanism that Package Contracts wrap. Reach for it directly only when a contract is overkill or unavailable.
  • Lower-level Seams — overrides and manual wiring inside the package model when generated behavior needs tweaking.

This section answers “how do I compose an app.” From here, the canonical journey continues through the rest of the sidebar in order:

  1. Authoring Routes — every concern that applies to a single route: validation, DTO mapping, auth/permissions, idempotency, OpenAPI, exception handling.
  2. Working with Data — the entity system in depth: defineEntity, operations, storage adapters, generated routes.
  3. Security — actor identity, sessions, then permissions on top.
  4. Realtime — Server-Sent Events and WebSockets.
  5. Operations — durable background work and multi-step workflows.
  6. Production — deployment, runtime selection, secrets, scaling.

Walk the sidebar in order and you’ll have seen every layer of the framework.