# ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture) * **Status**: Accepted * **Date**: 2026-08-21 * **EPIC / Task**: R01-T01 * **Owner**: 🔵 Team Duy (Tech Lead) * **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team) --- ## 1. Context (Bối cảnh) `cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python, được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR: | Vấn đề | Bằng chứng cụ thể trong repo | | :--- | :--- | | **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng | | **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` | | **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán | | **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh | | **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) | Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song sẽ hội tụ về đúng cấu trúc rối như cũ. ## 2. Decision (Quyết định) Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau: ```text ┌─────────────────────────────────────────────────────────────┐ │ presentation/ PySide6 widgets, Qt signals/slots │ │ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │ └───────────────────────────┬─────────────────────────────────┘ │ gọi xuống (được phép) ┌───────────────────────────▼─────────────────────────────────┐ │ application/ Pure Python orchestration │ │ (conversations, Điều phối use-case, không biết Qt │ │ model_routing…) và không biết HTTP/đĩa cụ thể │ └───────────────────────────┬─────────────────────────────────┘ │ gọi xuống (được phép) ┌───────────────────────────▼─────────────────────────────────┐ │ domain/ Pure Python entities & events │ │ (agents, models…) Frozen dataclass, enum, quy tắc │ │ nghiệp vụ thuần. KHÔNG import gì │ │ từ 3 tầng còn lại. │ └───────────────────────────▲─────────────────────────────────┘ │ implement interface của domain ┌───────────────────────────┴─────────────────────────────────┐ │ infrastructure/ Adapters: network, keyring, đĩa, │ │ (providers, telemetry…) process, Qt-free I/O │ └─────────────────────────────────────────────────────────────┘ ``` ### 2.1 Quy tắc bất biến (Invariants) | # | Quy tắc | Được kiểm bởi | | :--- | :--- | :--- | | **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) | | **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` | | **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` | | **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) | | **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 | | **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test | ### 2.2 Chiều phụ thuộc được phép | Từ tầng | Được import | Bị cấm | | :--- | :--- | :--- | | `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) | | `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 | | `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 | | `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 | ### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI `application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều đi qua **callback thuần Python nhận một `AgentEvent` có kiểu** (`domain/agents/agent_event.py`, R04-T02): ```python # application layer — pure Python, không biết Qt tồn tại service.run_turn(request, on_event=my_callback) # presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này def my_callback(event: AgentEvent) -> None: self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread ``` Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không bao giờ gọi trực tiếp từ worker thread. ## 3. Vị trí sở hữu theo team | Tầng / thư mục | Team | EPIC | | :--- | :--- | :--- | | `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 | | `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 | | `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 | ## 4. Chiến lược di trú (Strangler Fig, không big-bang) Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần: 1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`). 2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter). 3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ `application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại scorer/selector đã có test). 4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ. Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ team khác refactor xong. ## 5. Consequences (Hệ quả) ### Tích cực * Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây. * Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng. * Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`. * Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review. ### Tiêu cực / chi phí phải chấp nhận * Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code. * Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong. * Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety. ## 6. Alternatives considered (Phương án đã cân nhắc) | Phương án | Lý do loại | | :--- | :--- | | **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. | | **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. | | **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. | | **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. | ## 7. Enforcement (Thực thi) ```bash python scripts/check_imports.py # I1, I2, I3 — quét AST python scripts/check_loc.py # I4 — giới hạn 400 dòng python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest ``` CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`. ## 8. Tài liệu liên quan * `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC * `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task * `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)