Kiến trúc · Cơ chế liên kết dữ liệu · 15/7/2026

Cơ chế liên kết dữ liệu giữa installed module

Trả lời câu hỏi: installed module trên WS muốn dùng dữ liệu module khác thì platform phải cung cấp cơ chế gì? Đi từ khảo sát Odoo · Salesforce · MS Dataverse · Frappe/ERPNext · Lark, không đi từ hiện trạng. Khác với quan-he-module (chọn pattern P1–P6 theo loại action), trang này thiết kế tầng platform thực thi các pattern đó. Bản chi tiết: markdown.

0

Hiện trạng đọc từ code — vì sao phải có cơ chế mới

Sự thật trong code (ws_suns)Vị tríHệ quả
Installed module nhận restricted Sequelize proxy: cấm raw SQL, ép prefix <key>_, cấm đụng core tablesruntime-core:57-95Module store cô lập tuyệt đối — muốn đọc chéo là không có đường hợp lệ nào
Built-in (hrm, org, crm…) nhận sequelize đầy đủ, shared model registry + associate() chéo tự domodels/index.jsHai chế độ cực đoan ở hai đầu: hoặc cấm sạch, hoặc thả tự do
Manifest v2 đã có depends · scopes · providesmanifest-schema.jsonscopes mới chỉ in ra lúc cài — chưa cấp quyền gì
Mỗi WS = 1 process Node + 1 DB Postgres chung schemaapp.js · provisioner.jsJOIN chéo & in-process call chi phí ~0 — vấn đề chỉ là kiểm soát
Event dispatcher nằm riêng trong module workflow, handler tĩnhworkflow/events/Chưa phải năng lực platform; lỗi handler bị swallow
Gap: các điểm hook đều đã tồn tại (manifest có scopes, mount có buildModuleCtx, restricted proxy là chỗ duy nhất module đi qua) — chỉ thiếu tầng cơ chế nối chúng lại.
1

Bốn archetype trong ngành

ArchetypeĐại diệnCơ chế liên kếtĐược gìMất gì
AShared ORM registryOdoo, WordPressMọi module vào 1 registry, join/extend tự do (_inherit, bridge module)Tích hợp sâu nhất, 1 query enrich mọi thứĐòi code trusted + nâng version đồng bộ; module bên thứ 3 vỡ ngầm
BPlatform-owned canonical entitiesSalesforce, Shopify, DataversePlatform sở hữu entity lõi; app extend & tham chiếu entity lõi, hiếm khi nối app↔appHệ sinh thái ISV lớn nhất thế giới; gỡ app A không chết app BPlatform phải "đoán đúng" entity lõi; link app↔app vẫn cần cơ chế phụ
CMetadata-declared linksFrappe (Link field, fetch_from), DataverseQuan hệ là metadata platform đọc được → tự render lookup, enrich, chặn xoá, permissionLiên kết = năng lực platform (lookup UI, integrity, degrade)Phải xây tầng metadata; kém linh hoạt cho query lạ
DAPI + events, không chung DBLark, Slack appsScoped API + event subscription, eventual consistencyCô lập tuyệt đốiN+1, chậm — vô lý khi chung 1 DB

Analog 1 — Dataverse managed solutions ≈ .ogapp

Package có publisher prefix (= prefix bảng), dependency tracking khi cài/gỡ, không cho xoá table mà solution khác tham chiếu, upgrade theo version. Mô hình "gói cài được + chung DB + prefix" đã validate ở quy mô lớn — lời giải của họ cho liên kết là B + C, không phải A.

Analog 2 — AppExchange extension package ≈ bridge module

App package muốn nối app package khác → extension package declare dependency cả hai — đúng bridge module của Odoo. Hai hệ độc lập cùng hội tụ một pattern → tín hiệu mạnh cho quan hệ optional × optional.

Kết luận: OpenGate = chung DB per-WS (kinh tế của A) + vòng đời module rời từ store (ràng buộc của D) → chọn B + C, thực thi bằng capability injection: module chủ publish entity/service trong manifest · module dùng declare scope · runtime inject capability lúc mount. Restricted proxy giữ nguyên — capability cấp từ ngoài vào, không đục lỗ cho raw SQL.
2

Ba tầng cơ chế

Tầng 1 — Entity contract ĐỌC · ~80%

  • Module chủ: view <key>_v_* + khai entities trong manifest
  • Consumer: khai scopes → nhận model read-only qua ctx.entities.get()
  • Associate với model mình → JOIN 1 query thật (kinh tế Odoo)
  • Nguồn vắng mặt → null → degrade (ẩn cột/tab), check lúc mount
  • Đổi/bỏ cột → publish _v2, view cũ giữ nguyên

Tầng 2 — Service registry GHI

  • Ghi vào đất module khác chỉ qua service module chủ đăng ký (ctx.services.provide/call) — học SAP BAPI / Frappe controller
  • In-process call (cùng Node process), không HTTP
  • Rule nghiệp vụ + lịch sử nằm một chỗ ở module chủ
  • Scope dạng course:enroll:invoke
  • Callback bất đồng bộ thất bại → outbox retry + job đối soát

Tầng 3 — Event outbox THÔNG BÁO

  • Generalize dispatcher của workflow thành của platform
  • Bảng outbox + worker retry trong host
  • Fire-and-forget: cảnh báo → notify, trigger BI, webhook
  • Module vắng mặt → event nằm chờ hoặc rơi theo policy

Quy ước đi kèm (rẻ, học thẳng từ ngành)

Hub entitiesorg / hrm / catalog / iam là canonical (bài học Salesforce) — module store link vào hub trước, link ngang hàng chỉ khi thật cần → giảm mạnh số cạnh đồ thị phụ thuộc
Bridge moduleQuan hệ optional × optional đặt ở module thứ 3 depends cả hai (Odoo + Salesforce cùng hội tụ)
Copy-tại-thời-điểmDữ liệu gắn sự kiện quá khứ: copy lúc ghi (fetch_from của Frappe), không join lại; cột copy đặt tên rõ là snapshot
3

Ví dụ xuyên suốt (Vĩnh An): student thêm học viên vào khóa học

Màn hình Hồ sơ học viên (module student) → nút "Thêm vào khóa" → dropdown chọn khóa (dữ liệu của course) → Lưu → ghi danh. Gồm 2 luồng khác bản chất:

LuồngBản chấtĐường đi
Dropdown danh sách khóaĐỌC dữ liệu courseTầng 1 — entity course.khoa_hoc
Lưu ghi danh (bảng course_enrollment thuộc course)GHI vào đất courseTầng 2 — service course.enroll
Phía course (bên publish — làm 1 lần)
-- 1. Migration tạo contract view — db/migrations/003_views.sql CREATE OR REPLACE VIEW course_v_khoa_hoc AS SELECT ma_kh, ten, hang_gplx, ngay_khai_giang, trang_thai, si_so_toi_da FROM course_khoa_hoc;
// 2. Manifest — khai những gì cho người khác dùng "entities": [{ "name": "khoa_hoc", "view": "course_v_khoa_hoc", "key": "ma_kh" }], "services": [{ "name": "enroll", "desc": "Ghi danh học viên vào khóa" }]
// 3. Đăng ký service — backend/index.js mountRoutes(app, ctx) { ctx.services.provide('course.enroll', async ({ ma_kh, ma_hv }, actor) => { // rule nghiệp vụ MỘT chỗ duy nhất: khóa mở? còn chỗ (si_so_toi_da)? trùng hạng? // → ghi course_enrollment + ghi lịch sử }); }
Phía student (bên dùng)
// 4. Manifest — khai nhu cầu "scopes": ["course:khoa_hoc:readonly", "course:enroll:invoke"]
// 5. API dropdown const KhoaHoc = ctx.entities.get('course.khoa_hoc'); // model read-only | null app.get('/api/student/khoa-hoc-mo', ctx.authenticate, async (req, res) => { if (!KhoaHoc) return res.json({ available: false, items: [] }); // course chưa cài → FE ẩn nút res.json({ available: true, items: await KhoaHoc.findAll({ where: { trang_thai: 'mo' } }) }); });
// 6. Nút Lưu — KHÔNG BAO GIỜ INSERT vào bảng của course app.post('/api/student/:ma_hv/ghi-danh', ctx.authenticate, ctx.authz.authorize('student.hoso.edit'), async (req, res) => { const result = await ctx.services.call('course.enroll', { ma_kh: req.body.ma_kh, ma_hv: req.params.ma_hv }, req.user); res.json(result); // lỗi nghiệp vụ (hết chỗ…) trả về từ course });
// 7. Enrich ngược — list học viên hiện tên khóa: JOIN 1 query như model của mình models.StudentHoSo.belongsTo(KhoaHoc, { foreignKey: 'ma_kh', targetKey: 'ma_kh', constraints: false });

Platform tự lo (dev module KHÔNG phải viết)

ViệcAi lo
Đối chiếu scope khai ↔ entity/service tồn tại; inject ctx.entities / ctx.services lúc mountruntime-core
course chưa cài/disabled → entities.get() trả null, services.call báo lỗi rõ ràngruntime-core
Chặn student sờ trực tiếp bảng course_* (giữ nguyên restricted proxy)runtime-core
Hiển thị lúc cài: "student cần đọc Khóa học và gọi Ghi danh của course" cho admin duyệtinstaller
3 câu dev module cần nhớ: ① cho người khác đọc → publish view + khai entities (bảng gốc không ai đụng) · ② đọc của ai → khai scope, nhận model qua ctx.entities, chịu được null · ③ ghi vào đất của ai → gọi service của người đó, rule nằm ở module chủ.
4

Map với 6 pattern P1–P6

quan-he-module chọn pattern theo loại action; cơ chế ở đây là tầng platform thực thi các pattern đó:

PatternCơ chế thực thiGhi chú
P1 Contract viewTầng 1entities + ctx.entitiesNâng từ "quy ước tên view + tự JOIN" lên capability có kiểm soát scope
P2 Copy tại thời điểmQuy ước (không cần cơ chế)Đọc qua tầng 1 lúc ghi, copy vào bảng mình
P3 Service + callbackTầng 2services + ctx.servicesIn-process thay vì HTTP; retry qua tầng 3
P4 Event một chiềuTầng 3 — outbox platformGeneralize dispatcher của workflow
P5 Bridge moduledepends (đã có) + tầng 1/2Không cần cơ chế mới
P6 Snapshot / MVQuy ước + tầng 1Query view qua entity, snapshot vào file/MV
5

Hệ quả kiến trúc — vì sao contract dạng metadata đáng giá

① Lookup UI component chung

FE gọi GET /api/entities/course.khoa_hoc?q=… — mọi module có dropdown chọn khóa học / nhân viên / xe mà không cần biết module kia. (Cách Frappe render Link field, Dataverse render lookup.)

② Integrity khi uninstall

uninstallModule hiện chỉ chặn theo depends; có entity metadata thì chặn/cảnh báo cả theo dữ liệu đang tham chiếu (Dataverse làm đúng thế này).

③ Version hoá contract như API

Đổi/bỏ field → publish khoa_hoc_v2, view cũ giữ nguyên; SDK lint breaking change lúc opengate-module build vì contract nằm trong manifest.

④ Built-in & installed hội tụ

Built-in dần publish entity như mọi module (hrm.employee, org.department…) — xoá tình trạng hai chế độ cực đoan. Đường Salesforce đi: standard & custom object chung một metadata engine. Hub entities chính là các built-in này.

6

Việc platform cần làm & điểm cần chốt

Checklist thực thi (theo thứ tự)

1manifest-schema: thêm block entities (+ services); validator lint tên/scope tương ứng
2runtime-core: registry entity/service khi mount; đối chiếu scopes → inject ctx.entities, ctx.services; degrade khi nguồn vắng mặt
3Enforce scopes (hiện chỉ validate hình dạng): dùng entity/service không khai scope → lỗi lúc mount (fail fast)
4Outbox platform: bảng outbox + worker retry trong host; migrate dispatcher của workflow sang
5Generic entity API + lookup component cho FE (đợt 2, sau khi tầng 1 chạy)
6Built-in publish entity đợt đầu: hrm.employee · org.department · catalog.* — module store Vĩnh An (student, course, dat…) tiêu thụ

3 điểm cần chốt

#Câu hỏiĐề xuất
1Điểm enforcement: capability trong Node hay Postgres role + GRANT per-module?Capability trong Node — rẻ, đủ, khớp kiến trúc 1 process/WS. Postgres role chỉ đáng khi chạy code thật sự untrusted (đắt: pool per role, SET ROLE mỗi query)
2Phạm vi tầng 1 đợt đầu: chỉ ctx.entities backend hay kèm lookup API cho FE?Backend trước — nhỏ, tự chứa, test độc lập; lookup API đợt 2
3Built-in có bị ép theo contract không?Grandfather built-in (giữ shared models nội bộ), chỉ yêu cầu publish entity cho module store dùng — hội tụ dần, không refactor big-bang