Skip to content

Architecture

Mantle is a layered framework in the spirit of Clean and Onion Architecture. The name is the metaphor: the application layer is the geological mantle — between the database at the core and the UI at the crust.

CRUST (UI) React, Vue, Angular, iOS, Android
Transport layer Express / Koa / HTTP / Socket.IO / MCP — route calls to services
Application layer Service<T> contracts, the hook pipeline ← the mantle
Domain layer (core) Entities, Repository<T> interfaces
Infrastructure KnexRepository, MongoRepository, … — implement the Domain interfaces

Dependencies always point inward:

  • Infrastructure implements interfaces the Domain defines — never the reverse. A service depends on Repository<T>, not on KnexRepository.
  • Nothing in the Domain or Application layers knows about HTTP or databases. A hook that needs to tell callers apart reads context.provider ("rest" for Express, "koa", "http", "socket.io", "mcp", or undefined for an internal call), never a request object.
  • Transports are thin: they translate a request into a service call and run it through the same hook pipeline. REST, Socket.IO, and MCP calls to one service get identical authentication, validation, and auditing — that equivalence is covered by specs, not left to convention.

The closest relative of Mantle is FeathersJS, and the central difference is this: in Feathers a service is its data access — one class is use case, repository, and controller at once. In Mantle, Service<T> is only a contract:

interface Service<T, D = Partial<T>> {
find(params?: ServiceParams): Promise<T[] | Paginated<T>>;
get(id: Id, params?: ServiceParams): Promise<T>;
create(data: D, params?: ServiceParams): Promise<T>;
update(id: Id, data: D, params?: ServiceParams): Promise<T>;
patch(id: Id, data: D, params?: ServiceParams): Promise<T>;
remove(id: Id, params?: ServiceParams): Promise<T>;
}

Data access lives behind Repository<T> (findAll, findById, save, updateById, patchById, deleteById, count, …), implemented by an adapter in the Infrastructure layer. RepositoryService bridges the two for plain CRUD; anything richer — a service that writes to two stores, or one with custom methods — is a hand-written class that implements Service<T> and composes repositories it receives through its constructor.

Concern FeathersJS Mantle JS
Service definition Class implementing data + logic Interface (contract) only
Data access Inside the service via adapter Repository in the Infrastructure layer
Business logic Mixed into service methods Isolated in the Application / Domain layers
Hooks Transport-aware Transport-agnostic
Swapping databases Requires service changes Swap the adapter only
Testability Requires mocking the transport Domain testable with no mocks

Hooks are plain functions — (context: HookContext) => HookContext | Promise<HookContext>, never classes — registered per service and per method in three phases:

app.service("users").hooks({
before: { all: [authenticate("jwt")], create: [hashPassword()] },
after: { all: [sanitizeUser()] },
error: { all: [logError()] },
});

A before hook that sets context.result skips the service call; an error hook sees context.error. Every cross-cutting package — @mantlejs/auth, @mantlejs/schema, @mantlejs/logger, @mantlejs/audit, @mantlejs/storage, @mantlejs/embeddings — plugs in as hooks rather than middleware, which is what keeps them transport-agnostic.

Code never throws a plain Error: every failure is a MantleError subclass — BadRequest (400), NotAuthenticated (401), Forbidden (403), NotFound (404), Conflict (409), Unprocessable (422), GeneralError (500), and a few more. Each serializes to the same JSON over every transport and can carry a hint: actionable guidance on how to make the request succeed, written for whoever reads it — a developer, a client SDK, or an AI agent.

The monorepo holds one package per concern. Which package may import which is part of the architecture, not a convention: every project is tagged in Nx, the root ESLint config turns each row below into an @nx/enforce-module-boundaries constraint, and a CI check fails if this table and the configuration drift apart.

PackageMay depend on
@mantlejs/mantlenothing
@mantlejs/express@mantlejs/mantle
@mantlejs/koa@mantlejs/mantle
@mantlejs/http@mantlejs/mantle
@mantlejs/knex@mantlejs/mantle
@mantlejs/dynamodb@mantlejs/mantle
@mantlejs/supabase@mantlejs/mantle
@mantlejs/pinecone@mantlejs/mantle
@mantlejs/qdrant@mantlejs/mantle
@mantlejs/neo4j@mantlejs/mantle
@mantlejs/mongodb@mantlejs/mantle
@mantlejs/auth@mantlejs/mantle
@mantlejs/auth-local@mantlejs/mantle, @mantlejs/auth
@mantlejs/auth-oauth@mantlejs/mantle, @mantlejs/auth
@mantlejs/auth-google@mantlejs/mantle, @mantlejs/auth-oauth
@mantlejs/auth-github@mantlejs/mantle, @mantlejs/auth-oauth
@mantlejs/auth-facebook@mantlejs/mantle, @mantlejs/auth-oauth
@mantlejs/auth-apple@mantlejs/mantle, @mantlejs/auth-oauth
@mantlejs/auth-microsoft@mantlejs/mantle, @mantlejs/auth-oauth
@mantlejs/auth-linkedin@mantlejs/mantle, @mantlejs/auth-oauth
@mantlejs/auth-twitter@mantlejs/mantle, @mantlejs/auth-oauth
@mantlejs/auth-redis@mantlejs/mantle, @mantlejs/auth, @mantlejs/auth-oauth
@mantlejs/storage@mantlejs/mantle
@mantlejs/storage-s3@mantlejs/mantle, @mantlejs/storage
@mantlejs/storage-gcs@mantlejs/mantle, @mantlejs/storage
@mantlejs/logger@mantlejs/mantle
@mantlejs/audit@mantlejs/mantle (test-only: @mantlejs/memory, @mantlejs/knex)
@mantlejs/embeddings@mantlejs/mantle (test-only: @mantlejs/memory)
@mantlejs/schema@mantlejs/mantle
@mantlejs/memory@mantlejs/mantle
@mantlejs/config@mantlejs/mantle
@mantlejs/socketio@mantlejs/mantle
@mantlejs/openapi@mantlejs/mantle
@mantlejs/mcp@mantlejs/mantle (+ @modelcontextprotocol/sdk; test-only: @mantlejs/http, @mantlejs/memory, @mantlejs/auth)
@mantlejs/mcp-code@mantlejs/mantle, @mantlejs/mcp (+ quickjs-emscripten-core, @jitl/quickjs-wasmfile-release-sync; test-only: @mantlejs/http, @mantlejs/memory, @mantlejs/auth, @mantlejs/audit)
@mantlejs/sync@mantlejs/mantle
@mantlejs/clientnothing (optional peer: socket.io-client; test-only: @mantlejs/mantle for conformance specs)
@mantlejs/react@mantlejs/client (peers: react, @tanstack/react-query)
@mantlejs/clinothing (standalone code generator)
create-mantlejs@mantlejs/cli
ui-registry@mantlejs/client, @mantlejs/react (registry/, unpublished; peers: react, react-aria-components, @tanstack/react-query)
examples/*anything (apps, not libraries — exempt from the boundary rules above, never depended on)
websiteanything (the mantlejs.com docs site — an app like examples/*, never depended on)

Rendered at build time from CLAUDE.md, so it can't drift from the repository.

Each package has a page under Packages (its README) and a generated API reference.