Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Frame

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.

Prerequisites

  • Node.js ≥ 20
  • pnpm (any recent version)
  • Docker (for Postgres — used by integration tests, examples, and the codegen drift check)

Quick Start

git clone https://github.com/FranciscoMateusVG/frame.git
cd frame
pnpm install
pnpm check   # runs lint, depcruise, typecheck, codegen drift, tests, examples, hook verification

That'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.

Development Database (Optional)

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

Available Scripts

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

Architecture

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.

Observability

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.

How It Works

  • Use cases receive an Observability object (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., createCatdb.cats.save) happens automatically via OTel's AsyncLocalStorage-backed context propagation.
  • Logger has three implementations: ConsoleLogger (dev/examples), NoopLogger (tests), and OtelLogger (production — forwards to OTel Logs API with automatic trace correlation).

Wiring Up OTel (Consumer's Responsibility)

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-node

See 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-trace with 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.

Testing Spans

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();

Instrumentation Rules

  • 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.

Architectural Rules (enforced by dependency-cruiser)

  1. domain/ cannot import from anywhere except other domain/ files. The domain layer is pure — no infrastructure, no I/O.
  2. use-cases/ can import from domain/ and adapter interfaces, but never from concrete adapter implementations.
  3. Nothing internal imports from index.ts. The barrel is for consumers only.
  4. No OTel SDK imports in production code. src/ (except src/testing/) only uses the OTel API. The SDK is for tests, examples, and consumer setup.
  5. No circular dependencies, anywhere.

Violations are caught by pnpm depcruise and blocked by the pre-push hook.

Folder Layout Rules (enforced by eslint-plugin-project-structure)

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 *.ts files. 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.ts plus the index.ts barrel.
  • tests/{unit,integration}/*.test.ts and *.<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.

Examples

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.

How to Add a New Use Case

Step-by-step recipe:

1. Define the domain types

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;
}

2. Define the repository interface

// 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>;
}

3. Implement the adapters

  • src/adapters/dog-repository.memory.ts — for tests
  • src/adapters/dog-repository.postgres.ts — for production

4. Write the use case

// 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
  });
}

5. Add errors

// 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.`);
  }
}

6. Export from index.ts

Add types, use case, and adapter interface to src/index.ts. Do not export concrete adapters from here.

7. Add migration

Create a new migration in migrations/, run pnpm db:codegen, and commit the updated generated types.

8. Write tests

  • Unit tests in tests/unit/
  • Integration tests in tests/integration/
  • Property-based tests using fast-check

9. Verify

pnpm check  # must be green

How to Fork Frame for a New Project

  1. Fork or clone this repo
  2. Rename frame → your project name in package.json
  3. Delete everything in src/domain/, src/use-cases/, src/adapters/ (except database.ts and the generated types), src/errors/, and tests/
  4. Delete migrations/ contents and create your own
  5. Update src/index.ts to export your domain
  6. Update tsup.config.ts entry points
  7. Run pnpm db:codegen after creating your first migration
  8. Replace the Cat examples with your own in examples/
  9. Run pnpm check to verify everything is clean

Stack

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

License

MIT

About

AI-native TypeScript SDK skeleton — reference implementation with hexagonal architecture

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages