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, AndroidTransport layer Express / Koa / HTTP / Socket.IO / MCP — route calls to servicesApplication layer Service<T> contracts, the hook pipeline ← the mantleDomain layer (core) Entities, Repository<T> interfacesInfrastructure KnexRepository, MongoRepository, … — implement the Domain interfacesThe dependency rule
Section titled “The dependency rule”Dependencies always point inward:
- Infrastructure implements interfaces the Domain defines — never the reverse. A service depends on
Repository<T>, not onKnexRepository. - 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", orundefinedfor 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.
Services are contracts
Section titled “Services are contracts”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.
Typed errors
Section titled “Typed errors”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.
Packages and their boundaries
Section titled “Packages and their boundaries”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.
| Package | May depend on |
|---|---|
| @mantlejs/mantle | nothing |
| @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/client | nothing (optional peer: socket.io-client; test-only: @mantlejs/mantle for conformance specs) |
| @mantlejs/react | @mantlejs/client (peers: react, @tanstack/react-query) |
| @mantlejs/cli | nothing (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) |
| website | anything (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.