Mantle JS
A full-stack, tech-agnostic JavaScript/TypeScript framework optimized for building real-time applications and scalable web APIs. Mantle is the application layer between your UI and your data — analogous to the geological mantle beneath the crust. Your domain sits at the core and defines the interfaces it needs; database adapters implement them from the outside.
Mantle is designed to be architecture-first: it enforces a clean separation of concerns inspired by Clean Architecture and Onion Architecture, rather than coupling business logic to data access or transport concerns.
Mantle is built for three audiences: indie developers who want structure without ceremony, startup teams who need conventions that scale, and AI coding agents (Claude, Gemini, GPT-4o) that scaffold or generate backend code on behalf of a developer. Consistent naming, explicit interfaces, and clear layer boundaries make Mantle code correct to generate — an AI agent can produce a valid, runnable service with no human correction.
Background & Motivation
FeathersJS is the closest analog to Mantle. It is a lightweight, real-time capable service framework with a strong following. However, it carries a structural limitation: services couple business logic directly with data access logic. A Feathers service is simultaneously a use case, a repository, and a controller. This works well for small applications but becomes a liability at scale, during testing, or when swapping infrastructure.
Other frameworks (NestJS, AdonisJS, Hapi) either impose too much framework opinion on domain code, require significant boilerplate, or are not optimized for real-time delivery.
Mantle takes direct inspiration from FeathersJS's ergonomics — the plugin model, adapters, hooks-style middleware — but reimagines the core around a layered architecture where:
- Domain logic is independent of infrastructure
- Services define contracts, not implementations
- Adapters and transports are swappable without touching business logic
- The framework is predictable enough to be scaffolded and consumed by AI agents
Mantle vs. FeathersJS
| Concern | FeathersJS | Mantle JS |
|---|
| Service definition | Class implementing data + logic | Interface (contract) only |
| Data access | Inside the service via adapter | Repository in Infrastructure layer |
| Business logic | Mixed into service methods | Isolated in Application / Domain |
| Hooks | Transport-aware | Transport-agnostic |
| Swapping databases | Requires service changes | Swap adapter only |
| Testability | Requires mocking transport | Domain testable with no mocks |
| AI scaffoldability | Moderate | High (clear boundaries) |
Architecture
Transport Layer Express adapter — routes HTTP to services
Application Layer Service<T> contracts, hook pipeline (a.k.a. THE MANTLE)
Domain Layer (Core) Entities, Repository<T> interfaces
Infrastructure KnexRepository — implements Domain interfaces
Dependencies always point inward. Nothing in Domain or Application layers knows about HTTP or databases.
Packages
| Package | Description |
|---|
@mantlejs/mantle | Framework kernel — Service, Repository, hooks, errors. Zero external deps. |
@mantlejs/express | Express HTTP transport adapter |
@mantlejs/koa | Koa HTTP transport adapter |
@mantlejs/http | Zero-dependency HTTP transport adapter (Node.js handler + Fetch API / Edge Functions) |
@mantlejs/knex | SQL adapter via Knex.js (PostgreSQL, MySQL/MariaDB, SQLite3, MSSQL) |
@mantlejs/dynamodb | Amazon DynamoDB adapter — DynamoDbRepository with full QueryParams support |
@mantlejs/supabase | Supabase adapter — SupabaseRepository backed by the Supabase JS client |
@mantlejs/pinecone | Pinecone vector database adapter — PineconeRepository with embedding support |
@mantlejs/qdrant | Qdrant vector database adapter — QdrantRepository with embedding support |
@mantlejs/neo4j | Neo4j graph database adapter — Neo4jRepository with full QueryParams support |
@mantlejs/mongodb | MongoDB adapter — MongoRepository + Atlas Vector Search via MongoVectorRepository |
@mantlejs/auth | JWT engine + strategy runner |
@mantlejs/auth-local | Local email+password strategy (Argon2id) |
@mantlejs/auth-oauth | Shared OAuth 2.0 base — state, PKCE, find-or-create, route registration |
@mantlejs/auth-google | Google Sign-In strategy (authorization code + PKCE, no Passport.js) |
@mantlejs/auth-github | GitHub Sign-In strategy (authorization code flow, no Passport.js) |
@mantlejs/auth-facebook | Facebook Sign-In strategy (authorization code flow, no Passport.js) |
@mantlejs/auth-apple | Sign in with Apple strategy (form_post callback, ES256 client-secret JWT, no Passport.js) |
Experimental packages publish under the experimental npm dist-tag (npm install @mantlejs/mcp-code@experimental).
The Mantle UI registry — React Aria blocks installed with shadcn add @mantle/<block> — and the
mantlejs.com docs site aren't npm packages; they ship by deploy.
Quick Start
import { mantle } from "@mantlejs/mantle";
import { express } from "@mantlejs/express";
import { knex } from "@mantlejs/knex";
import { auth, authenticate, sanitizeUser } from "@mantlejs/auth";
import { localStrategy, hashPassword } from "@mantlejs/auth-local";
import { logger, createLogger, logRequest, logError } from "@mantlejs/logger";
const app = mantle()
.configure(express())
.configure(knex({ client: "pg", connection: process.env.DATABASE_URL }))
.configure(auth({ secret: process.env.JWT_SECRET! }))
.configure(localStrategy())
.configure(logger(await createLogger({ gcp: process.env.NODE_ENV === "production" })));
app.use("/users", new UserService(new UserRepository(app)), {
methods: ["find", "get", "create", "update", "patch", "remove"],
});
const requestLogger = logRequest();
app.service("users").hooks({
before: {
create: [hashPassword()],
all: [authenticate("jwt"), requestLogger],
},
after: { all: [sanitizeUser(), requestLogger] },
error: { all: [requestLogger, logError()] },
});
app.listen(3030);
For the smallest possible runnable app — one file, @mantlejs/http + @mantlejs/memory, no
external infra — see examples/todo-minimal. Two other runnable
examples live under examples/: realtime-chat (Socket.IO + local auth) and
knowledge-base (the canonical example — nearly every package wired into one app).
Services with multiple repositories
RepositoryService<T> is the framework-owned bridge from HTTP query strings to a single
Repository<T> — reach for it whenever a service reads and writes exactly one collection.
When a service legitimately needs more than one (writing an activity-log entry alongside the
record it just created, upserting an embedding after a text write, …), compose the repositories
directly behind a hand-written Service<T> instead of trying to force a second repository through
RepositoryService:
import type { Id, Repository, Service, ServiceParams } from "@mantlejs/mantle";
class ArticleService implements Service<Article> {
constructor(
private readonly articles: Repository<Article>,
private readonly activity: Repository<ActivityEntry>,
) {}
async find(params?: ServiceParams) {
return this.articles.findAll({ where: params?.query });
}
async get(id: Id) {
return this.articles.findById(id);
}
async create(data: Partial<Article>) {
const article = await this.articles.save(data);
await this.activity.save({ articleId: article.id, action: "created" });
return article;
}
// ...update/patch/remove delegate to `this.articles`
}
app.use("/articles", new ArticleService(new ArticleRepository(app), new ActivityRepository(app)));
Registered this way, a multi-repository service runs through app.use() exactly like a
RepositoryService — the full hook pipeline (before/after/error) and service:event
emission apply identically; hooks and transports cannot tell the difference.
Cross-repository writes are not atomic. If the second write fails, the first has already
committed and is not rolled back — the same non-atomicity as app.batch() (see the Phase 4
batch-atomicity notes). Whether that matters depends on the repositories involved:
- Same Knex instance: repositories sharing one
KnexRepository-derived instance can share a
transaction — pass a withTransaction-scoped repository into the service (or construct the
service inside the transaction callback) so both writes commit or roll back together.
- Cross-adapter (e.g. Knex + Mongo, or any two different adapters): there is no distributed
transaction. Design for eventual consistency — make the secondary write idempotent/retryable, or
treat its failure as non-fatal and reconcile out of band — rather than assuming both writes
succeed or fail together.
The cross-adapter write-consistency pattern
Formalized once, so every cross-adapter secondary write follows the same shape instead of each
service inventing its own: an idempotent upsert keyed on the source record's id, safe to retry,
and non-fatal on failure (logged, never rolled back, never rethrown into the caller). Concretely:
- Idempotent, keyed on the source id. The secondary write is an upsert addressed by the
primary record's id, not an append — running it again for the same id replaces rather than
duplicates.
- Safe to retry. Because step 1 is an upsert, retrying after a transient failure (yours or the
secondary system's) is always safe — there's no "already ran once" bookkeeping to get wrong.
- Non-fatal on failure. A secondary-write failure is caught where it happens and never
propagates to fail the primary operation the caller is waiting on — the primary write already
committed, and a degraded secondary system must never turn that into an error response.
@mantlejs/embeddings's embed() hook is the reference
implementation: it upserts a vector into a VectorRepository<T> keyed on the just-written record's
id (property 1 — and idempotency here comes for free from upsertVector's own upsert contract, not
from anything the hook does), and catches an embedding-provider or vector-store failure internally
rather than rethrowing it (property 3). Reach for the same shape for any other cross-adapter
secondary write (an activity log in a different store, a search-index sync, a cache invalidation) —
it doesn't need to be embeddings-specific.
Development
This monorepo is managed with Nx.
# Build
npx nx build mantle # build one package
npx nx run-many -t build # build all packages
# Test
npx nx test mantle # test one package
npx nx run-many -t test # test all packages
# Lint
npx nx run-many -t lint
# Visualise the dependency graph
npx nx graph
Add a new package
NX_DAEMON=false npx nx g @nx/js:library \
--name=<name> \
--directory=packages/<name> \
--bundler=tsc \
--unitTestRunner=vitest \
--linter=eslint \
--publishable \
--importPath=@mantlejs/<name>
Tech Choices
| Decision | Choice | Reason |
|---|
| Monorepo | Nx (TS preset, npm) | Task pipeline, module boundary enforcement |
| SQL adapter (current) | @mantlejs/knex via Knex.js | Query builder not ORM — keeps infra layer thin; additional adapters (e.g. Prisma) can be added |
| Password hashing | @node-rs/argon2 (Argon2id) | OWASP recommended; no 72-char bcrypt limit |
| Testing | Vitest | Faster than Jest, native ESM, Jest-compatible API |
| HTTP transport (P1) | @mantlejs/express via Express | Phase 1 adapter; transport layer is pluggable |
| Bundler | tsc | Emits .d.ts natively — critical for TS-first libraries |