AI-native TypeScript SDK skeleton — a reusable, domain-agnostic project structure and tooling baseline designed for autonomous AI development.
Frame contains no real business logic. It's a reference implementation with a placeholder domain (Cats) that demonstrates the full pattern end-to-end. Fork it, replace the placeholder domain with your own, and start building.
- Node.js ≥ 20
- pnpm (any recent version)
- Docker (for Postgres — used by integration tests, examples, and the codegen drift check)
git clone https://github.com/FranciscoMateusVG/frame.git
cd frame
pnpm install
pnpm check # runs lint, depcruise, typecheck, codegen drift, tests, examples, hook verificationThat's it. pnpm check is fully self-contained — it spins up Postgres via Testcontainers, runs migrations, and tears everything down. No manual Docker Compose setup required.
For interactive development, you can run a persistent Postgres:
pnpm db:up # start Postgres via Docker Compose (port 54320)
pnpm db:migrate # run migrations
pnpm db:codegen # regenerate Kysely types from live schema
pnpm db:down # stop Postgres
pnpm db:reset # drop volume, restart, re-run migrations| Script | What it does |
|---|---|
pnpm lint |
Biome lint + format check |
pnpm lint:fix |
Auto-fix lint issues |
pnpm lint:structure |
Enforce folder/file layout via eslint-plugin-project-structure |
pnpm typecheck |
TypeScript strict type check |
pnpm test |
Run all tests (Vitest) |
pnpm test:watch |
Run tests in watch mode |
pnpm test:coverage |
Run tests with coverage thresholds |
pnpm depcruise |
Check architectural rules |
pnpm check:codegen-drift |
Verify generated types match live schema |
pnpm verify-hooks |
Verify git hooks are installed |
pnpm build |
Build ESM + CJS with types (tsup) |
pnpm check |
Run all checks — the Definition of Done |
Hexagonal-ish, by hand. No framework, no DI container, no decorators.
src/
├── domain/ # Types, entities, value objects. No I/O.
├── use-cases/ # One file per use case. Pure functions taking deps as args.
├── adapters/ # Infrastructure: interfaces + implementations.
├── errors/ # Typed error classes.
├── observability/ # Logger interface, implementations, tracer re-exports.
├── testing/ # Exported test helpers (frame/testing subpath).
└── index.ts # Public API surface.
Frame ships structured logging and distributed tracing via OpenTelemetry as first-class concerns. The design is no-op by default: without an OTel SDK registered, all tracing and logging operations silently do nothing. Zero overhead, zero crashes.
- Use cases receive an
Observabilityobject (Logger + Tracer) via deps. Each use case wraps in a span and logs meaningful business events. - Adapters use
trace.getTracer('frame')at module level. Span nesting (e.g.,createCat→db.cats.save) happens automatically via OTel's AsyncLocalStorage-backed context propagation. - Logger has three implementations:
ConsoleLogger(dev/examples),NoopLogger(tests), andOtelLogger(production — forwards to OTel Logs API with automatic trace correlation).
Frame deliberately does NOT provide a setupObservability() helper. Consumers own SDK configuration — sampling, exporter choice, and resource attributes are your decisions, not Frame's.
Install the OTel SDK packages (listed as optional peer dependencies):
pnpm add @opentelemetry/sdk-trace-base @opentelemetry/sdk-trace-nodeSee examples/create-cat.with-otel.ts for the complete, copy-pasteable setup:
import { trace } from '@opentelemetry/api';
import { ConsoleSpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
const provider = new NodeTracerProvider({
spanProcessors: [new SimpleSpanProcessor(new ConsoleSpanExporter())],
});
provider.register(); // Sets global provider + enables AsyncLocalStorage context propagation
// Now all Frame spans are live — createCat, db.cats.save, etc.For production, replace ConsoleSpanExporter with your backend's exporter:
- OTLP (Jaeger, Grafana Tempo):
@opentelemetry/exporter-trace-otlp-http - Honeycomb:
@honeycombio/opentelemetry-node - Datadog:
dd-tracewith OTel compatibility
Consumers may additionally install @opentelemetry/instrumentation-pg for automatic query-level Postgres tracing. Frame's manual spans remain valuable for use-case-level and repository-level visibility.
Frame exports createTestObservability() under the frame/testing subpath for consumers to assert on span emission:
import { createTestObservability } from 'frame/testing';
const { observability, getSpans, reset, shutdown } = createTestObservability();
// ... run your use case ...
const spans = getSpans();
expect(spans.find(s => s.name === 'myUseCase')).toBeDefined();- Instrument: use case entry points, adapter I/O methods (DB, HTTP, external services).
- Do NOT instrument: Zod validation, domain pure functions, value object construction.
- PII discipline: span attributes capture shapes (
cat.name.length), not raw values. - Adapters emit spans only — they do not log. Logs come from use cases for meaningful business events.
domain/cannot import from anywhere except otherdomain/files. The domain layer is pure — no infrastructure, no I/O.use-cases/can import fromdomain/and adapter interfaces, but never from concrete adapter implementations.- Nothing internal imports from
index.ts. The barrel is for consumers only. - No OTel SDK imports in production code.
src/(exceptsrc/testing/) only uses the OTel API. The SDK is for tests, examples, and consumer setup. - No circular dependencies, anywhere.
Violations are caught by pnpm depcruise and blocked by the pre-push hook.
The import-graph rules above are paired with a structural gate on file and folder layout. ESLint is wired in solely to host eslint-plugin-project-structure — Biome remains the lint + format authority. Run pnpm lint:structure to check, or pnpm check to run it as part of the full quality gate.
The rules live in folder-structure.mjs and enforce:
src/domain/,src/use-cases/,src/observability/,src/testing/— flat folders of kebab-case*.tsfiles. No nested subdirectories.src/adapters/—<port>.ts(the interface) and<port>.<impl>.ts(concrete adapters, e.g.cat-repository.postgres.ts).src/errors/—<entity>-<thing>.error.tsplus theindex.tsbarrel.tests/{unit,integration}/—*.test.tsand*.<flavor>.test.ts(e.g.cat-repository.memory.test.ts).tests/helpers/— flat kebab-case*.ts, optionally with a single dotted qualifier (cat-repository.conformance.ts).examples/— three accepted forms:<use-case>.ts,<use-case>.with-<integration>.ts,<use-case>.<flavor>.ts(e.g.create-cat.hono.ts).migrations/—<YYYYMMDD>_<NNN>_<snake_name>.ts.scripts/— flat kebab-case*.ts/*.js.
To allow a new file shape, extend the structure tree in folder-structure.mjs. To make a one-off exception, add it to ignorePatterns at the bottom of the same file.
The examples/ directory holds runnable demonstrations of Frame's patterns. Each example is fully self-contained — Testcontainers spins up Postgres on demand — and is executed as part of pnpm check.
| File | What it shows |
|---|---|
examples/create-cat.ts |
Bare SDK usage — wire up CatRepositoryPostgres, call createCat, fetch + delete |
examples/create-cat.with-otel.ts |
Same flow with the full OTel SDK registered. Spans printed via ConsoleSpanExporter |
examples/create-cat.hono.ts |
Use case exposed as an HTTP API via Hono. Demonstrates how a transport adapter stays a thin shell — parse → invoke use case → translate domain errors to HTTP status codes (201 / 200 / 409 / 400) |
The Hono example is the template for any transport layer (Hono, Express, Fastify, tRPC). Frame stays transport-agnostic: the use case takes (deps, input), returns a domain entity, and throws typed domain errors. The route handler is the only place HTTP exists.
Step-by-step recipe:
Create or extend files in src/domain/:
// src/domain/dog.ts
import { z } from 'zod/v4';
export const DogNameSchema = z.string().trim().min(1).max(100);
export type DogName = string;
export interface Dog {
readonly id: string;
readonly name: DogName;
readonly breed: string;
readonly createdAt: Date;
}// src/adapters/dog-repository.ts
import type { Dog } from '../domain/dog.js';
export interface DogRepository {
save(dog: Dog): Promise<void>;
findById(id: string): Promise<Dog | undefined>;
}src/adapters/dog-repository.memory.ts— for testssrc/adapters/dog-repository.postgres.ts— for production
// src/use-cases/create-dog.ts
import type { Dog } from '../domain/dog.js';
import type { DogRepository } from '../adapters/dog-repository.js';
export interface CreateDogDeps {
readonly dogRepository: DogRepository;
readonly clock: () => Date;
readonly observability: Observability;
}
export async function createDog(deps: CreateDogDeps, input: { id: string; name: string; breed: string }): Promise<Dog> {
const { dogRepository, clock, observability } = deps;
return observability.tracer.startActiveSpan('createDog', async (span) => {
// validate, create, persist — see createCat for the full pattern
});
}// src/errors/dog-already-exists.error.ts
export class DogAlreadyExistsError extends Error {
public readonly code = 'DOG_ALREADY_EXISTS' as const;
constructor(public readonly name: string) {
super(`A dog named "${name}" already exists.`);
}
}Add types, use case, and adapter interface to src/index.ts. Do not export concrete adapters from here.
Create a new migration in migrations/, run pnpm db:codegen, and commit the updated generated types.
- Unit tests in
tests/unit/ - Integration tests in
tests/integration/ - Property-based tests using fast-check
pnpm check # must be green- Fork or clone this repo
- Rename
frame→ your project name inpackage.json - Delete everything in
src/domain/,src/use-cases/,src/adapters/(exceptdatabase.tsand the generated types),src/errors/, andtests/ - Delete
migrations/contents and create your own - Update
src/index.tsto export your domain - Update
tsup.config.tsentry points - Run
pnpm db:codegenafter creating your first migration - Replace the Cat examples with your own in
examples/ - Run
pnpm checkto verify everything is clean
| Concern | Tool |
|---|---|
| Language | TypeScript (strict) |
| Database | PostgreSQL 16 |
| DB access | Kysely + kysely-codegen |
| Migrations | Kysely built-in Migrator |
| Validation | Zod (external boundaries only) |
| Tracing | OpenTelemetry API (SDK in tests/examples only) |
| Logging | OTel Logs API (ConsoleLogger for dev) |
| Testing | Vitest + fast-check |
| Lint/format | Biome |
| Arch rules (imports) | dependency-cruiser |
| Arch rules (layout) | ESLint + eslint-plugin-project-structure |
| Git hooks | Husky + lint-staged |
| Build | tsup (ESM + CJS) |
| Package manager | pnpm |
MIT