Core Concepts
Understand the ports & adapters architecture, the two consumption modes, dependency injection, and the preserved wire contract.
Ports & adapters architecture
@teardown/server is a hexagonal (ports & adapters) runtime. The SDK ships the domain services and the HTTP edge; you supply the database through a storage port. Each layer talks only to the layer below it, through interfaces.
HTTP adapter (fetch / elysia) ← shipped
▼
HTTP router + handlers (identify/events) ← shipped (reproduces the ingest wire contract)
▼
Domain services (identify/events/...) ← shipped (no DB, no framework, no singletons)
▼
TeardownStorage port (*Repository) ← you implement (or use a shipped adapter)- HTTP adapters lower a framework-native request (a Web
Request, an ElysiaContext) into a normalizedTdRequest, run the router, and serialize theTdResponse. - The router + handlers match method + path, run header extraction, API-key auth, and body validation, then call a domain service and map its result to an HTTP status.
- Domain services hold all the business logic (find-or-create, session reuse, version-status severity, the build cascade). They perform all I/O through ports.
- The storage port (
TeardownStorage) is the database boundary. The runtime never imports a database directly — it callsstorage.users.findById(...)and friends.
The two consumption modes
The runtime supports two integration styles, and you can mix them.
Which one?
Start by mounting the router — it's the drop-in self-hosted ingest endpoint and what Getting Started sets up. Reach for calling services directly only when you need your own routes/auth or you're building the version & build management (write) side.
Mount the router
Let the SDK own the HTTP edge. td.router() reproduces the ingest contract, and an adapter serves it. This is the fastest path to a drop-in self-hosted ingest endpoint.
import { toFetchHandler } from "@teardown/server/http/fetch";
Bun.serve({ port: 4501, fetch: toFetchHandler(td.router()) });Call services directly
Call td.services.* from your own routes, with your own auth and validation. This is how the Teardown dashboard backend drives version & build management — it never mounts the ingest router, it calls td.services.versions / td.services.builds.
const result = await td.services.identify.identify(headers, body);
if (result.success) {
// result.data: { session_id, device_id, user_id, token, version_info }
}
const versions = await td.services.versions.searchVersionsByProject({
projectId, page: 1, limit: 20, sortBy: "created_at", sortOrder: "desc",
});The available services are identify, events, forceUpdate, session, environment, pushTokens, versions, and builds. See the API Reference for every method signature.
Dependency injection, no singletons
There are no module-level db / cache / logger globals. createTeardown builds one shared dependency container and hands it to each service constructor. This means:
- You can run multiple independent runtimes in one process (e.g. one per tenant, or one per test) with different storage and config.
- Every cross-cutting concern — cache, logging, metrics, clock, token signing — is an injectable port with a no-op / system default, so the SDK's required dependency surface is just
storage. - Services are trivially testable against the in-memory storage adapter with a fake clock.
const td = createTeardown({
storage: createMemoryStorage(),
config: { sessionSecret: "test-secret" },
clock: { now: () => new Date("2026-01-01T00:00:00Z"), uuid: () => "fixed-uuid" },
});The preserved wire contract
The mounted router speaks the exact same HTTP contract as Teardown's hosted ingest API, so the React Native SDK works against your server with only an ingestUrl change. The routes are:
| Method | Path | Purpose |
|---|---|---|
POST | /v1/identify | Identify a device/user, return a session + version_info |
POST | /v1/events | Ingest a batch of events |
GET | /health | Liveness probe ({ status: "ok", timestamp, build_id?, service_id? }) |
GET | / | Root ({ message, version }) |
The request/response DTOs come from @teardown/schemas, the single source of truth shared with the React Native SDK's generated client. See Mounting for the response envelopes and error codes.
API-key authentication (hosted / multi-tenant)
In the default hosted/multi-tenant mode, both POST /v1/identify and POST /v1/events are guarded by an API-key authenticator that reproduces the hosted ingest behavior:
- Read
td-api-key(a leadingBearerprefix is stripped) andtd-project-idfrom the request headers. - A missing key or missing project id is a
403. - Resolve the publishable key to its
{ key_id, project_id, org_id }viastorage.apiKeys.findPublishableContext(key). An unknown key is a403"Invalid API key". - Security: require the request's
td-project-idto equal the key's resolvedproject_id. A mismatch is a403"API key does not belong to the specified project". The project and org are always taken from the key, never from the header.
The authenticator depends only on your storage's apiKeys repository, so it works with any database.
Single-tenant self-hosting. Pass tenant: { orgId, projectId } to createTeardown and API-key auth is turned off: td-api-key, td-org-id, and td-project-id are no longer required (only td-environment-slug and td-device-id are), the org/project come from the tenant, and no apiKeys repository is needed. You can still supply a custom ApiKeyAuthenticator to the router for your own scheme (see Mounting).
Error handling convention
Domain services return a discriminated AsyncResult rather than throwing on expected failures:
type AsyncResult<T> =
| { success: true; data: T }
| { success: false; error: string };The version/build management services return a typed error union instead (e.g. { code: "VERSION_NOT_FOUND"; message }). Storage repositories follow a different convention — they return Entity | null and throw a StorageError on infrastructure failure. See Storage for why the boundaries differ.