Skip to content

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)

  1. Core (qcobjects npm 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.
  2. 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.
  3. CLI / Runtime (qcobjects-cli) — scaffolding (templates/apps, templates/pwa), dev servers (HTTP, HTTPS, HTTP/2, GAE variants), qcobjects-server / qcobjects-collab / qcobjects-shell entry points, build commands (esbuild, TypeScript), publish-static. Orchestrates; does not implement UI widgets.
  4. 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.
  5. Apps (qcobjects-new-app and 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 against location, shadow roots). Component.ts carries isBrowser guards for DOM-only behaviors (effects, fullscreen, shadow attachment).
  • SSR (opt-in): FileDispatcher serves .html/.tpl.html files by instantiating a real Component server-side in Node — New(Component, {name:"static_source", template: source, tplsource:"inline", data:{…}, done({component}){ body = component.parsedAssignmentText }}) — and sending parsedAssignmentText as 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 set useTemplate:true in config.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:static remains 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 Microservice class extending BackendMicroservice is REQUIRED. The HTTP/2 server calls post() (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, defaults body to null, runs cors(), and wires dispatch. stream/request/route/headers stay available as instance fields for the whole call. NOTE: cors() runs unconditionally and dereferences this.routeroute is NOT a constructor param, so the harness MUST set it (at minimum responseHeaders, plus cors for browser routes) or construction throws TypeError.
  • Verb dispatch: stream "data" events route to post(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 call done().
  • Answering: set this.body (object, e.g. a JSON-RPC 2.0 envelope {jsonrpc:"2.0", result, id}) then call this.done(). Never write the raw stream unless implementing a custom transport.
  • cors() semantics (driven by route.cors): allow_origins ("*" or list; mismatch empties the body and finishes — fail-closed); allow_credentials (default "true"); allow_methods (default GET, OPTIONS, POST); allow_headers (default *). With no route.cors at all, validation is skipped (log only) — routes that need browsers MUST declare cors.
  • The com.qcobjects.backend.microservice.static route name serves redirect_to file targets — use it for static routes instead of custom code. (It is a route RECORD written by the CLI's defaultsettings.ts, not a class in core — no such package exists in core src/.)

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 Service subclass (ProxyOpenAIService) POSTs to a same-origin route (/api/openai, external:false, cached:false), carrying only the model payload (model, messages, temperature) — NO key, NO Authorization header.
  • Server side: a BackendMicroservice.post() builds the vendor client service (key from CONFIG.get("OPENAI_API_KEY"), i.e. $ENV(...) resolved only in Node), executes it via serviceLoaderNode, sets this.body to the vendor response, and done()s. Errors become this.body + done() (fail closed with a body, not a stream write).
  • Rules: vendor keys MUST live only in server-side $ENV settings; browser code MUST NOT contain, import, or receive keys; proxy routes SHOULD keep cached: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() instantiates PrintfulService (a Service subclass whose _new_ sets Authorization: Basic <process.env KEY>process.env exists only in Node, so the class is backend-only by construction), serviceLoader(New(PrintfulService,{data:null})), then microservice.body = JSON.parse(service.template) + done().
  • Rules: composing services MUST be Service subclasses (never raw https calls — keeps headers, done/fail, and both transport legs); secrets MUST come from process.env/$ENV (never literals, never client reachable); each upstream failure MUST map to a body + done() (or a deliberate non-200), never an unhandled rejection; aggregation of N upstreams SHOULD Promise.all them 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" via fs.writeFile, reference: puzzle-game saveplayer). 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 via done().

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) with broadcaster/watcher/offer/answer relay 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.io npm 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/JSONService subclass consumed in the browser — fetches data over HTTP (XHR leg of serviceLoader), binds the response into templates via {{}}, and MUST NOT hold secrets (proxy instead).
  • Back-end service: a Microservice extends BackendMicroservice dispatched from backend.routes by HTTP verb — runs in Node, answers with this.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 Service subclass is isomorphic — the same code runs in the browser or in Node (serviceLoader picks the transport leg internally by service.kind + runtime: XHR, Node http/https/http2, mockup, or local). 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 at https://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, SignupClientService hitting Service.basePath + route). Same-route pairs SHOULD share the leaf name (signupsignup) 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), custom service.headers applied in a loop (function values skipped), withCredentials honored, status 200done({request: xhr, service}), anything else → fail({request: xhr, service}) when defined, else reject.
  • kind:"rest" + Node → built-in Node leg: http/https per URL protocol, http2 client when service.useHTTP2 (with :method/:path pseudo-headers merged from service.options + service.headers), chunk accumulation into service.template, resolution with {http2Client, request, service, responseHeaders}. Standalone serviceLoaderNode helpers (e.g. the OpenAI package's native-https one) predate/parallel this leg and MUST keep its shape.
  • kind:"mockup" → calls service.mockup(response) (or done) with {request: null, service, responseHeaders} — no network. The test-double path: tests MUST use mockup services, never stub URLs.
  • kind:"local" → calls service.local(response) (or done) 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.env presence 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.
  • path is matched as a regex string (e.g. "^/demo-tests/QCObjects-SDK.js$").
  • Unmatched paths fall back to static-file serving from documentRoot if the file exists — so the server handles static AND dynamic from one table.
  • Route → class resolution (ImportMicroservice, three tiers): the microservice value resolves as (1) npm package (findPackageNodePath — bare names like qcobjects-handler-hello-world work), else (2) app-local <absolutePath>/backend/<value>, else (3) dynamic import(value). The resolved package MUST register <value>.Microservice, instantiated with {domain, basePath, projectPath, route, routeParams, server, stream, request} (routeParams from {param} groups; route set by the harness — satisfying the constructor requirement in § BackendMicroservice base API).
  • Server-side config.json MAY 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-createcert generates 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 ls in any app shows exactly one qcobjects + one qcobjects-sdk copy.
  • Deleting a handler package degrades only its routes; the server still boots.
  • schemas/examples/backend-config.json validates against schemas/config.schema.json.