02 — Architecture¶
Purpose¶
Pin the layered architecture shared by every QCObjects repo and app, including the N-Tier/micro-service doctrine and the backend routing contract.
Sources: QCObjects README §§ N-Tier, Micro-services, backend settings/routing,
microservice class (v2.5.142); qcobjects-new-app config.json (v2.4.40-ts).
Definitions below are authoritative.
Scope¶
Layers, their responsibilities, and the contracts between them. Component-level detail lives in 03–05.
The five layers (normative, read bottom-up)¶
- Core (
qcobjectsnpm package) — class system (Class,InheritClass,ClassFactory,New), MVC primitives (Component,Controller,View,VO,DDO), packaging (Package,Import/Export), routing (routings.ts), and loaders (component, service, SDK). No transport, no CLI. - SDK (
qcobjects-sdk) — elementary Controllers, Views, Components (grid, list, slider, splashscreen, notifications, modal, i18n, effects, cloud-auth session). Depends on core; MUST NOT depend on the CLI. - CLI / Runtime (
qcobjects-cli) — scaffolding (templates/apps,templates/pwa), dev servers (HTTP, HTTPS, HTTP/2, GAE variants),qcobjects-server/qcobjects-collab/qcobjects-shellentry points, build commands (esbuild, TypeScript), publish-static. Orchestrates; does not implement UI widgets. - Handlers / Microservices — backend route targets addressed by name in
config.json(e.g.com.qcobjects.backend.microservice.static), plus language bridges (PHP handler today; Wasm / FastAPI per v3.2+). MUST be independently installable npm packages, auto-discovered by keyword. - Apps (
qcobjects-new-appand derivatives) — PWA shell (index.html,manifest.json,sw.js),src/js/{config,init,packages}, static assets. Apps consume layers 1–3; MUST NOT fork them.
Cross-cutting rules:
- Layer N MAY depend on layers below it, MUST NOT depend on layers above it.
- All runtime behavior MUST resolve through
CONFIG+config.json(relativeImportPath,componentsBasePath,documentRoot,backend.routes). - Browser, ESM, and CJS distributions MUST all be published
(
public/browser,public/esm,public/cjs+public/types).
Rendering model: CSR + opt-in SSR (normative)¶
QCObjects renders on the client by default AND supports server-side rendering
through the CLI server — same component pipeline both sides. Sources:
qcobjects-cli src/main-file.ts (FileDispatcher), src/defaultsettings.ts.
- CSR (default): components build in the live browser DOM (template XHR,
{{}}binding, routing againstlocation, shadow roots).Component.tscarriesisBrowserguards for DOM-only behaviors (effects, fullscreen, shadow attachment). - SSR (opt-in):
FileDispatcherserves.html/.tpl.htmlfiles by instantiating a realComponentserver-side in Node —New(Component, {name:"static_source", template: source, tplsource:"inline", data:{…}, done({component}){ body = component.parsedAssignmentText }})— and sendingparsedAssignmentTextas the response body. Template binding,$…()processors, and the template handler all run server-side exactly as in the browser; no browser DOM is needed for this inline path. - Gate: SSR applies only when
CONFIG.get("useTemplate")is true AND the extension is.html/.tpl.html(CLI default:useTemplate=false). Otherwise the file streams unrendered with its mime type +cacheControl. Apps that want SSR MUST setuseTemplate:trueinconfig.json. - Scope note: SSR covers template+data rendering (the
parseTemplate/ handler path). Browser-only behaviors (shadow DOM attachment, effects, client routing, XHR-loaded external templates) still execute client-side on hydration.publish:staticremains a file copier, not a prerenderer. - Apps SHOULD still ship a crawlable static shell (meta/OG tags,
404.html, sitemap) for crawlers that don't execute JS when SSR is off.
N-Tier doctrine (from the core README)¶
QCObjects targets professional Multitier/N-Tier environments for scalability and reliability. Reference background (study only): Multitier Architecture (Wikipedia), 3-Tier Architecture (tonymarston.net), Multi-Tier Application (techopedia), N-Tier concepts (guru99).
- Presentation (components/views), logic (controllers/services), data (services/microservices) MUST stay separable; a component MUST be usable with a different service, and a service with a different component.
- Routing (
hash|pathname|search, see spec 03) belongs to presentation; persistence and auth belong to services/microservices.
Micro-service doctrine¶
A microservice compacts a backend fragment callable remotely, so a high-level service splits into small completable tasks (reference: microservices.io, Wikipedia Microservices).
- Definition rule: inside a microservice package, a
Microserviceclass extendingBackendMicroserviceis REQUIRED. The HTTP/2 server callspost()(or the verb method) only on POST requests to the configured path, answering e.g. JSON-RPC 2.0 envelopes. - Canonical example (signup saver, from core README
v2.5.142):
'use strict';
const fs = require('fs');
Package('cl.quickcorp.backend.signup',[
Class('Microservice',BackendMicroservice,{
body:{ "jsonrpc": "2.0", "result": "", "id": 1 },
saveToFile: function (filename,data){
logger.debug('Writing file: '+filename);
fs.writeFile(filename, data, (err) => {
if (err) throw err;
console.log('The file has been saved!');
});
},
post:function (data){
let submittedDataPath = CONFIG.get('dataPath'); // filled out from qcobjects-server
let filename = submittedDataPath+'signup/signup'+Date.now().toString()+'.json';
this.saveToFile(filename,data);
}
})
]);
(Note: Date.now().toString() in source; shape above.)
BackendMicroservice base API (normative)¶
Source: src/BackendMicroservice.ts, pinned at
https://github.com/QCObjects/QCObjects/blob/v2.5.142/src/BackendMicroservice.ts.
- Construction:
New(MicroserviceClass, {domain, basePath, body, stream, request}); the constructor stores those five, defaultsbodytonull, runscors(), and wires dispatch.stream/request/route/headersstay available as instance fields for the whole call. NOTE:cors()runs unconditionally and dereferencesthis.route—routeis NOT a constructor param, so the harness MUST set it (at minimumresponseHeaders, pluscorsfor browser routes) or construction throwsTypeError. - Verb dispatch: stream
"data"events route topost(data); all other request methods dispatch to same-named methods —get,head,put,delete,connect,options,trace,patch. Override exactly the verbs the route serves; default verb methods log and calldone(). - Answering: set
this.body(object, e.g. a JSON-RPC 2.0 envelope{jsonrpc:"2.0", result, id}) then callthis.done(). Never write the raw stream unless implementing a custom transport. cors()semantics (driven byroute.cors):allow_origins("*"or list; mismatch empties the body and finishes — fail-closed);allow_credentials(default"true");allow_methods(defaultGET, OPTIONS, POST);allow_headers(default*). With noroute.corsat all, validation is skipped (log only) — routes that need browsers MUST declarecors.- The
com.qcobjects.backend.microservice.staticroute name servesredirect_tofile targets — use it for static routes instead of custom code. (It is a route RECORD written by the CLI'sdefaultsettings.ts, not a class in core — no such package exists in coresrc/.)
Secret-hiding proxy pattern (normative, reference: qcobjects-openai-api)¶
Third-party APIs with secret keys MUST be integrated browser → same-origin proxy → vendor, never browser → vendor. The OpenAI/Azure packages prove the shape:
- Browser side: a
Servicesubclass (ProxyOpenAIService) POSTs to a same-origin route (/api/openai,external:false,cached:false), carrying only the model payload (model,messages,temperature) — NO key, NOAuthorizationheader. - Server side: a
BackendMicroservice.post()builds the vendor client service (key fromCONFIG.get("OPENAI_API_KEY"), i.e.$ENV(...)resolved only in Node), executes it viaserviceLoaderNode, setsthis.bodyto the vendor response, anddone()s. Errors becomethis.body+done()(fail closed with a body, not a stream write). - Rules: vendor keys MUST live only in server-side
$ENVsettings; browser code MUST NOT contain, import, or receive keys; proxy routes SHOULD keepcached:false; streaming/SSE responses are NOT covered by this pattern (request/response JSON only — verify before promising streams).
Service composition — BFF aggregation (normative, reference: store app)¶
Beyond proxying, a microservice verb method MAY run one or more client Service
subclasses through serviceLoader and reshape their responses into body
(backend-for-frontend aggregation). Production proof (Printful catalog):
Microservice.get()instantiatesPrintfulService(aServicesubclass whose_new_setsAuthorization: Basic <process.env KEY>—process.envexists only in Node, so the class is backend-only by construction),serviceLoader(New(PrintfulService,{data:null})), thenmicroservice.body = JSON.parse(service.template)+done().- Rules: composing services MUST be
Servicesubclasses (never rawhttpscalls — keeps headers,done/fail, and both transport legs); secrets MUST come fromprocess.env/$ENV(never literals, never client reachable); each upstream failure MUST map to abody+done()(or a deliberate non-200), never an unhandled rejection; aggregation of N upstreams SHOULDPromise.allthem and merge, not chain sequentially. - File-sink microservices: persistence without a database —
post(data)appends timestamped JSON under a records dir (projectPath + "/records/record" + Date.now() + ".json"viafs.writeFile, reference: puzzle-gamesaveplayer). File sinks MUST scope writes to a dedicated records dir (never the document root), MUST derive filenames from timestamp + validated fields (never raw client input — path traversal), and SHOULD answer a JSON-RPC envelope viadone().
Realtime signaling coexistence (normative, reference: video-streaming app)¶
WebSocket/socket.io realtime runs ALONGSIDE the verb dispatch, not through it:
- A microservice method attaches the socket layer to
microservice.server(production proof:require("socket.io")(microservice.server)withbroadcaster/watcher/offer/answerrelay events for WebRTC signaling). Verb stubs on the same class MAY no-op (done()immediately) — their job is route presence; media flows peer-to-peer, the server relays signals only. - Signaling state MAY use
global(global.set("broadcaster", socket.id)), but MUST be treated as ephemeral (no persistence, no cross-instance assumptions — sticky sessions or external store required past one process). - Socket dependencies (
socket.ionpm package) belong to the app/handler package, never to core; the HTTP server MUST NOT depend on socket code paths.
Front-end vs back-end services (normative)¶
- Front-end service: a
Service/JSONServicesubclass consumed in the browser — fetches data over HTTP (XHR leg ofserviceLoader), binds the response into templates via{{}}, and MUST NOT hold secrets (proxy instead). - Back-end service: a
Microservice extends BackendMicroservicedispatched frombackend.routesby HTTP verb — runs in Node, answers withthis.body+done(), and MAY hold keys via$ENV/process.env. Think edge/cloud function, but encapsulated in a class (verbs,cors(),done()protocol). - Same classes, either side: a
Servicesubclass is isomorphic — the same code runs in the browser or in Node (serviceLoaderpicks the transport leg internally byservice.kind+ runtime: XHR, Node http/https/http2,mockup, orlocal). Classify by WHERE it runs, not by what it extends. - The two meet ONLY at HTTP route boundaries (proxy + BFF patterns above).
Source:
src/serviceLoader.ts, pinned athttps://github.com/QCObjects/QCObjects/blob/v2.5.142/src/serviceLoader.ts. - Namespace convention (reference: hacktoberfest app): keep the sides
visibly apart — backend microservices under
<org>.backend.*(org.quickcorp.backend.projectlist,…signup), browser client services under<org>.frontend.services(ProjectListClientService,SignupClientServicehittingService.basePath + route). Same-route pairs SHOULD share the leaf name (signup↔signup) so routes, services, and templates trace to each other by inspection.
serviceLoader dispatch detail (normative)¶
The single entry point serviceLoader(service) dispatches internally —
callers never choose a transport. Source as above.
kind:"rest"+ browser → XHR leg: async forced (sync XHR is deprecated), customservice.headersapplied in a loop (function values skipped),withCredentialshonored, status200→done({request: xhr, service}), anything else →fail({request: xhr, service})when defined, else reject.kind:"rest"+ Node → built-in Node leg:http/httpsper URL protocol,http2client whenservice.useHTTP2(with:method/:pathpseudo-headers merged fromservice.options+service.headers), chunk accumulation intoservice.template, resolution with{http2Client, request, service, responseHeaders}. StandaloneserviceLoaderNodehelpers (e.g. the OpenAI package's native-https one) predate/parallel this leg and MUST keep its shape.kind:"mockup"→ callsservice.mockup(response)(ordone) with{request: null, service, responseHeaders}— no network. The test-double path: tests MUST usemockupservices, never stub URLs.kind:"local"→ callsservice.local(response)(ordone) with the same null-request shape — the embedded-data path.- Unknown kind → resolved no-op + debug line (never throws).
- Rules: a service class holding secrets MUST run server-side only (gate on
process.envpresence or keep it out of browser bundles); microservice classes MUST NOT import browser globals (document,window,location); new loaders MUST preserve the{request, service}shape; shared DTO shapes SHOULD be documented once (in the route's spec entry) and referenced from both sides.
Backend routing contract (config.json)¶
- Every route REQUIRES
path+microservice(package string as indexing point). Optional:name,description,redirect_to,responseHeaders,cors. pathis matched as a regex string (e.g."^/demo-tests/QCObjects-SDK.js$").- Unmatched paths fall back to static-file serving from
documentRootif the file exists — so the server handles static AND dynamic from one table. - Route → class resolution (
ImportMicroservice, three tiers): themicroservicevalue resolves as (1) npm package (findPackageNodePath— bare names likeqcobjects-handler-hello-worldwork), else (2) app-local<absolutePath>/backend/<value>, else (3) dynamicimport(value). The resolved package MUST register<value>.Microservice, instantiated with{domain, basePath, projectPath, route, routeParams, server, stream, request}(routeParamsfrom{param}groups;routeset by the harness — satisfying the constructor requirement in §BackendMicroservicebase API). - Server-side
config.jsonMAY carry:documentRoot,basePath,projectPath,domain,dataPath, TLS material (private-key-pem,private-cert-pem), ports. Full field catalogue: 12-schemas.
{
"documentRoot": "/home/qcobjects/projects/mynewapp/",
"relativeImportPath": "js/packages/",
"basePath": "/home/qcobjects/projects/mynewapp/",
"projectPath": "/home/qcobjects/projects/mynewapp/",
"domain": "mynewapp.qcobjects.com",
"dataPath": "/etc/qcobjects/data/",
"private-cert-pem": "/etc/letsencrypt/live/mynewapp.qcobjects.com/fullchain.pem",
"private-key-pem": "/etc/letsencrypt/live/mynewapp.qcobjects.com/privkey.pem",
"backend": { "routes": [
{ "path": "/createaccount", "microservice": "org.quickcorp.backend.signup", "responseHeaders": {} }
]}
}
Certificates¶
qcobjects-createcertgenerates self-signed local TLS material (dev only).- Production MUST use externally provisioned certificates (e.g. LetsEncrypt +
Certbot paths wired via
private-key-pem/private-cert-pem).
Verification¶
npm lsin any app shows exactly oneqcobjects+ oneqcobjects-sdkcopy.- Deleting a handler package degrades only its routes; the server still boots.
schemas/examples/backend-config.jsonvalidates againstschemas/config.schema.json.