# Cơ chế liên kết dữ liệu giữa installed module — entity contract · service · event

> 15/7/2026 · Bản HTML: [lien-ket-du-lieu-module.html](lien-ket-du-lieu-module.html) · Liên quan: [Quan hệ module & 6 pattern P1–P6](quan-he-module-2026-07-07.md) · [rt-host](core/rt-host-2026-07-07.md) · [sdk](core/sdk-2026-07-07.md)
>
> Trả lời câu hỏi: **installed module trên WS muốn dùng dữ liệu của module khác thì platform phải cung cấp cơ chế gì?**
> Khác với [quan-he-module](quan-he-module-2026-07-07.md) (chọn *pattern* cho từng loại action), tài liệu này thiết kế **cơ chế platform thực thi** các pattern đó — đi từ khảo sát các hệ tương tự (Odoo, Salesforce, MS Dataverse, Frappe/ERPNext, Lark), không đi từ hiện trạng.

---

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

Khảo sát `packages/runtime-core`, `packages/module-sdk`, `apps/api` của WsRuntime2:

| Sự thật trong code | Vị trí | Hệ quả |
|---|---|---|
| Installed module nhận **restricted Sequelize proxy**: cấm `sequelize.query()` (raw SQL), `define()` bắt buộc prefix `<key>_`, cấm đụng core tables | `runtime-core/src/index.js:57-95` | Module store **cô lập dữ liệu tuyệt đối** — muốn đọc chéo là *không có đường hợp lệ nào* |
| Built-in module (hrm, org, crm, iam, workflow…) nhận sequelize **đầy đủ**, shared model registry + `associate()` chéo tự do | `apps/api/src/models/index.js`, `modules/hrm/index.js:51-53` | Hai chế độ **cực đoan ở hai đầu**: hoặc cấm sạch, hoặc thả tự do |
| Manifest v2 đã có `depends`, `scopes` (`module:feature:access`), `provides` | `module-sdk/src/manifest-schema.json` | `scopes` mới chỉ **in ra lúc cài** cho admin xem (`runtime-core:696-701`) — chưa cấp quyền gì |
| Mỗi WS = 1 process Node + 1 DB Postgres, mọi module chung schema `public` | `apps/api/src/app.js`, `provisioner.js` | JOIN chéo và in-process call **khả thi kỹ thuật với chi phí ~0** — vấn đề chỉ là kiểm soát |
| Event dispatcher tồn tại nhưng nằm **riêng trong module workflow**, handler tĩnh | `modules/workflow/events/dispatcher.js` | 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ện | Cơ chế liên kết | Được gì | Mất gì |
|---|---|---|---|---|---|
| **A** | **Shared ORM registry** | Odoo, WordPress/Drupal | Mọi module load 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 hỏi code **trusted** + nâng version đồng bộ cả cụm; module bên thứ 3 vỡ ngầm |
| **B** | **Platform-owned canonical entities** | Salesforce, Shopify, MS Dataverse | Platform sở hữu entity lõi (Account, Contact, Product…); app **extend và tham chiếu entity lõi**, hiếm khi nối trực tiếp app↔app | Hệ sinh thái ISV lớn nhất thế giới chạy kiểu này; gỡ app A không chết app B | Platform phải "đoán đúng" entity lõi; link app↔app vẫn cần cơ chế phụ |
| **C** | **Metadata-declared links** | Frappe/ERPNext (Link field, `fetch_from`), Dataverse relationships | Quan hệ là **metadata platform đọc được**, không phải SQL tự viết → framework tự render lookup, enrich, chặn xoá khi còn tham chiếu, permission theo metadata | Liên kết trở thành **năng lực platform** (generic lookup UI, integrity, degrade) | Phải xây tầng metadata; kém linh hoạt cho query lạ |
| **D** | **API + events, không chung DB** | Lark/Slack/microservices | Scoped API + event subscription, eventual consistency | Cô lập tuyệt đối, vòng đời độc lập | N+1, chậm — vô lý khi thực tế **chung 1 DB** |

**Hai analog đáng chú ý nhất với OpenGate:**

- **MS Dataverse managed solutions** giống `.ogapp` đến bất ngờ: package có publisher **prefix** (= prefix bảng), dependency tracking khi cài/gỡ, không cho xoá table mà solution khác đang tham chiếu, upgrade theo version. Mô hình "gói cài được + chung DB + prefix" đã được validate ở quy mô lớn — và lời giải của họ cho liên kết là **B + C**, không phải A.
- **Salesforce AppExchange**: app package muốn nối với app package khác → **extension package** declare dependency cả hai (= bridge module của Odoo). Hai hệ độc lập cùng hội tụ về một pattern → tín hiệu mạnh đây là lời giải đúng cho quan hệ optional × optional.

## 2. Kết luận chọn hướng

OpenGate = chung DB per-WS (kinh tế của A) + module vòng đời rời từ store (ràng buộc của D). Ngành đã hội tụ về câu trả lời cho đúng cấu hình này: **B + C, thực thi bằng capability injection**.

> Liên kết dữ liệu **không phải** là "cho phép module viết SQL sang bảng người khác", mà là **năng lực do platform cấp phát**: module chủ *publish entity/service* (contract trong manifest), module dùng *declare scope*, runtime *inject capability* lúc mount.

Khác biệt căn bản so với hiện trạng: hiện trạng chỉ có **quy ước** (hoặc cấm sạch với installed, hoặc thả tự do với built-in); phương án là **cơ chế** — restricted proxy giữ nguyên, capability được cấp từ ngoài vào chứ không đục lỗ cho raw SQL.

## 3. Ba tầng cơ chế

### Tầng 1 — Entity contract (ĐỌC — giải ~80% nhu cầu: enrich, lookup)

Manifest module chủ thêm block `entities`:

```jsonc
"entities": [{
  "name": "khoa_hoc",            // → scope: course:khoa_hoc:readonly
  "view": "course_v_khoa_hoc",   // view do migration của course tạo
  "key": "ma_kh",                // khóa nghiệp vụ
  "fields": ["ma_kh","ten","hang_gplx","ngay_khai_giang"]
}]
```

Consumer khai `scopes: ["course:khoa_hoc:readonly"]` (field **đã có sẵn** trong schema v2). Lúc mount, runtime-core đối chiếu scope ↔ entity đã publish rồi inject:

```js
const KhoaHoc = ctx.entities.get('course.khoa_hoc');
// → Sequelize model read-only bind vào view, hoặc null nếu course vắng mặt/disabled
```

- Vì là Sequelize model, consumer **associate được với model của mình** (`constraints:false`) → enrich list bằng **1 query JOIN thật** — giữ nguyên kinh tế của Odoo.
- `null` khi module nguồn chưa cài → degrade tự nhiên (ẩn cột/tab), check 1 lần lúc mount, không check mỗi request.
- View là public interface, version như API: thêm cột được; đổi/bỏ cột → publish entity `_v2`, view cũ giữ nguyên.

### Tầng 2 — Service registry (GHI — mọi thao tác ghi chéo)

Học SAP BAPI / Frappe controller: ghi vào đất module khác **chỉ** qua service module chủ đăng ký. In-process call (cùng Node process — đã xác nhận trong code), không cần HTTP:

```js
// Module chủ (course) — manifest: "services": [{ "name": "enroll", "desc": "Ghi danh học viên vào khóa" }]
ctx.services.provide('course.enroll', async (payload, actor) => {
  // rule nghiệp vụ nằm ở đây, MỘT chỗ duy nhất:
  // khóa còn mở? còn chỗ? học viên đã ở khóa khác cùng hạng?
  // → ghi course_enrollment + ghi lịch sử
});

// Consumer (student) — manifest: scopes: ["course:enroll:invoke"]
await ctx.services.call('course.enroll', { ma_kh, ma_hv }, req.user);
```

Lỗi mạng không tồn tại (in-process); lỗi nghiệp vụ trả về ngay. Với callback bất đồng bộ (tuition → student `da_thu_dot1`), service call thất bại → outbox retry (tầng 3) + job đối soát định kỳ.

### Tầng 3 — Event outbox (THÔNG BÁO — một chiều, fire-and-forget)

Generalize dispatcher đang nằm riêng trong workflow thành của platform: bảng outbox + worker retry trong host. Module publish event, module khác subscribe qua manifest; module vắng mặt thì event nằm chờ hoặc rơi theo policy. Dùng cho: cảnh báo → notify, trigger BI refresh, webhook.

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

1. **Hub entities**: org / 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 trong đồ thị phụ thuộc.
2. **Bridge module** cho quan hệ optional × optional (Odoo `sale_stock` + Salesforce extension package cùng hội tụ).
3. **Copy-tại-thời-điểm** (`fetch_from` của Frappe) cho dữ liệu gắn với sự kiện quá khứ — không join lại, cột copy đặt tên rõ là snapshot.

## 4. 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ồng | Bản chất | Đường đi |
|---|---|---|
| Dropdown danh sách khóa | ĐỌC dữ liệu course | Tầng 1 — entity `course.khoa_hoc` |
| Lưu ghi danh (bảng `course_enrollment` thuộc course) | GHI vào đất course | Tầ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`:

```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:

```jsonc
"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** trong `backend/index.js`:

```js
mountRoutes(app, ctx) {
  ctx.services.provide('course.enroll', async ({ ma_kh, ma_hv }, actor) => {
    // rule: khóa mở? còn chỗ (si_so_toi_da)? trùng hạng?
    // ghi course_enrollment + lịch sử
  });
}
```

### Phía `student` (bên dùng)

**4. Manifest khai nhu cầu:**

```jsonc
"scopes": ["course:khoa_hoc:readonly", "course:enroll:invoke"]
```

**5. API dropdown:**

```js
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:

```js
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: associate rồi JOIN 1 query như model của mình:

```js
models.StudentHoSo.belongsTo(KhoaHoc, { foreignKey: 'ma_kh', targetKey: 'ma_kh', constraints: false });
```

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

| Việc | Ai lo |
|---|---|
| Đối chiếu scope khai ↔ entity/service tồn tại; inject `ctx.entities` / `ctx.services` lúc mount | runtime-core |
| course chưa cài/disabled → `entities.get()` trả `null`, `services.call` báo lỗi rõ ràng | runtime-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ệt | installer (đã in scopes sẵn) |

**3 câu dev module cần nhớ:**

1. Muốn **cho người khác đọc** gì → publish view + khai `entities`. Bảng gốc không ai đụng được.
2. Muốn **đọc của ai** → khai scope, nhận model qua `ctx.entities` — dùng như model của mình nhưng read-only, phải chịu được `null`.
3. Muốn **ghi vào đất của ai** → gọi service của người đó; rule nghiệp vụ nằm ở module chủ, không bao giờ nằm ở bên gọi.

## 5. Map với 6 pattern P1–P6

Tài liệu [quan-he-module](quan-he-module-2026-07-07.md) chọn *pattern theo loại action*; cơ chế ở đây là *tầng platform thực thi* các pattern đó:

| Pattern | Cơ chế thực thi | Ghi chú |
|---|---|---|
| **P1** Contract view | **Tầng 1** — `entities` trong manifest + `ctx.entities` | Nâ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ểm | Quy ước (không cần cơ chế) | Đọc qua tầng 1 lúc ghi, copy vào bảng mình |
| **P3** Service API + callback | **Tầng 2** — `services` trong manifest + `ctx.services` | In-process thay vì HTTP; retry qua tầng 3 |
| **P4** Event một chiều | **Tầng 3** — outbox platform | Generalize dispatcher của workflow |
| **P5** Bridge module | `depends` (đã có) + tầng 1/2 | Không cần cơ chế mới |
| **P6** Snapshot / MV | Quy ước + tầng 1 | Query view qua entity, snapshot vào file/MV |

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

Khi entity là **metadata platform đọc được** (không phải SQL chôn trong code module), platform mở ra các năng lực generic không module nào phải tự viết:

1. **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. (Đúng cách Frappe render Link field, Dataverse render lookup.)
2. **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).
3. **Version hoá contract như API**: đổi/bỏ field → publish `khoa_hoc_v2`; SDK lint được breaking change lúc `opengate-module build` vì contract nằm trong manifest.
4. **Built-in và installed hội tụ về một cơ chế**: built-in dần publish entity như mọi module (hrm publish `hrm.employee`, org publish `org.department`…), xoá tình trạng hai chế độ cực đoan. Đây chính là con đường Salesforce: standard object và custom object chạy chung một metadata engine. Hub entities (mục 3) chính là các built-in này.

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

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

- [ ] **manifest-schema**: thêm block `entities` (+ `services`); validator lint tên/scope tương ứng.
- [ ] **runtime-core**: registry entity/service khi mount; đối chiếu `scopes` → inject `ctx.entities`, `ctx.services`; degrade khi nguồn vắng mặt.
- [ ] **Enforce scopes** (hiện chỉ validate hình dạng): consumer dùng entity/service không khai scope → lỗi lúc mount (fail fast, không phải runtime).
- [ ] **Outbox platform**: bảng outbox + worker retry trong host; migrate dispatcher của workflow sang.
- [ ] **Generic entity API + lookup component** cho FE (đợt 2, sau khi tầng 1 chạy).
- [ ] Built-in publish entity đợt đầu: `hrm.employee`, `org.department`, `catalog.*` — module store của 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 xuống tận 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 module thật sự untrusted (đắt: pool per role, `SET ROLE` mỗi query) |
| 2 | **Phạm vi tầng 1 đợt đầu**: chỉ `ctx.entities` backend, hay kèm luôn generic lookup API cho FE? | Backend trước — nhỏ, tự chứa, test độc lập; lookup API đợt 2 |
| 3 | **Built-in có bị ép theo contract không**? | Grandfather built-in (giữ shared models nội bộ), chỉ yêu cầu chúng **publish** entity cho module store dùng — hội tụ dần, không refactor big-bang |
