opt-web — Hướng dẫn đọc source code

Dự án migration brownfield: hệ thống nghiệp vụ VB.NET WinForms của Optage được dựng lại trên Java 21 + Spring Boot 3.5 theo Clean Architecture. Tài liệu này dành cho dev mới onboard — đọc một lượt là nắm được bộ khung, quy định đặt tên, và biết "code mình viết nằm ở đâu".
Java 21 Spring Boot 3.5.14 MyBatis 3.0.5 Flyway · Oracle 23ai JSP / JSTL Spring Security Lombok ArchUnit Maven
👷 Góc nhìn: một Senior Java dev ngồi cạnh giải thích cho Junior. Mọi class trong dự án đều "neo" ngược về một màn hình VB legacy (mã CMnnnnn) — đây là điểm mấu chốt khiến codebase này khác một dự án Spring thường.

00 Dự án này là gì?

opt-web là PoC (Proof of Concept) chứng minh chiến lược migrate ~256 màn hình VB.NET của Optage sang Spring. Hiện đã hiện thực 4 màn hình mẫu (PoC scope), phần còn lại mới chỉ có khung điều hướng (nav shell).

CM06010

ウイルスバスター申込検索
Tìm kiếm đơn đăng ký virus-buster

CM05110

番ポ対象一覧
Danh sách đối tượng LNP (motel)

CM30020

顧客情報(Netflix sub)
Màn hình khách hàng — tab Netflix

CM71060

電話帳取込
Import phonebook (maintenance)

Vì sao có tiếng Nhật khắp nơi? Đây là hệ thống của khách Nhật (Optage). Tên màn hình, message, comment đều giữ nguyên bản tiếng Nhật để trace 1:1 về source VB gốc. Bạn không cần dịch — chỉ cần hiểu đó là "nhãn nghiệp vụ" và giữ nguyên khi đụng tới.

Mục tiêu thiết kế của codebase: (1) dev VB cũ của Optage navigate được new↔old trong 1 bước, (2) dev Java mới hiểu được Clean Architecture, (3) mọi hành vi reproduce 1:1 so với VB (新旧比較 / so sánh cũ–mới).

01 Bản đồ nhanh — "Cái X nằm ở đâu?"

Bảng tra cứu thần tốc. Mỗi câu hỏi onboarding thường gặp → vị trí chính xác trong source. Các mục sẽ được đào sâu ở phần 7.

Câu hỏiNằm ở đâuGhi chú
Common / dùng chung…/optweb/shared/ + config/ + view WEB-INF/views/common/shared = nav model + annotation; config = web/security/error; không có "util grab-bag"
Error lưu ở đâuconfig/GlobalErrorController.javaviews/common/error.jsp404 / 500 render trong nav shell, message M-code tiếng Nhật
Xử lý lỗi như thế nào2 cơ chế: BindingResult (validate trong controller) + GlobalErrorController (404/500)Không@ControllerAdvice — cố ý, PoC scope
Configcode: config/*.java · runtime: resources/application*.yamlprofile: local / dev / stg / prod
Routing@RequestMapping trên Controller, pattern /api/<module>/cm<nnnnn>SoT của route = .nexa/legacy-map.yaml
MiddlewareSpring Security filter chain (SecurityConfig) + convention shell() trong controllerKhông có custom interceptor/filter riêng
Service<module>/application/*Service.java@Service, gọi qua port, không biết MyBatis
DAO (data access)<module>/infrastructure/persistence/ = Repository + Mapper + Mapper.xmlStored-proc → infrastructure/procedure/
SQLresources/com/company/opt/optweb/<module>/infrastructure/persistence/*Mapper.xmlMirror đúng package của Mapper interface
DB migrationresources/db/migration/oracle/V0xx__*.sqlFlyway, chạy lúc startup
View (UI)src/main/webapp/WEB-INF/views/<module>/cm<nnnnn>.jspJSP + JSTL, layout dùng layout/*.jspf
Entry pointOptWebApplication.javaSpring Boot main() chuẩn

02 Tech stack & vì sao chọn

Đọc pom.xml là biết ngay bộ công cụ. Đây là stack "enterprise Java cổ điển nhưng bản mới" — chọn JSP thay vì SPA là chủ ý: bám sát mô hình form-render của VB WinForms để migrate cho an toàn.

LớpThư việnVai trò trong dự án
Runtimespring-boot-starter-web + tomcat-embed-jasperTomcat nhúng, render JSP server-side (không phải REST-JSON thuần)
Viewjakarta.servlet.jsp.jstl (JSP/JSTL)HTML render từ Model — tương đương "form" của VB
Persistencemybatis-spring-boot-starterSQL viết tay trong XML — map 1:1 chuỗi SQL legacy của VB
Stored-procspring-boot-starter-jdbc (JdbcTemplate)Gọi Oracle package/proc nguyên xi, không viết lại logic
DB schemaflyway-core + flyway-database-oracleVersioned migration V001…V005, có guard chống sửa bảng dùng chung
Driverojdbc11Oracle 23ai (legacy giữ nguyên signature stored-proc)
Securityspring-boot-starter-securityFilter chain — hiện permit-all + CSRF ON (PoC)
Validationspring-boot-starter-validationBean Validation trên Form (@Valid)
Boilerplatelombok@Data cho domain/form — bớt getter/setter
Kiến trúcarchunit-junit5 (test)Bắt buộc đặt tên + dependency rule bằng test — xem phần 8
Chất lượngCheckstyle · SpotlessFormat + lint lúc build

03 Cây thư mục — nhìn 1 phát hiểu cả repo

Quy tắc vàng: mỗi màn hình legacy = 1 "bounded context" = 1 package con, bên trong chia thành 4 lớp (ring) y hệt nhau. Học thuộc 1 module là đọc được cả 4.

04-coding/ ├── src/main/java/com/company/opt/optweb/ │ ├── OptWebApplication.java // @SpringBootApplication — entry point │ ├── ServletInitializer.java // deploy dạng WAR │ │ │ ├── config/ ★ FRAMEWORK RING (không có legacy gốc) │ │ ├── SecurityConfig.java // "middleware": filter chain, CSRF │ │ ├── HomeController.java // nav shell: / /screen/{cm} /jump /health │ │ └── GlobalErrorController.java // /error → trang lỗi tiếng Nhật │ │ │ ├── shared/ ★ COMMON dùng chung (← CM90Common của VB) │ │ ├── web/NavCatalog.java // model điều hướng IA, đọc ia-catalog.json │ │ └── annotation/LegacyOrigin.java // @annotation neo class về màn hình VB │ │ │ ├── cm06movirus/ BOUNDED CONTEXT (màn hình CM06010) │ │ ├── presentation/ // PRES: Controller + Form │ │ │ ├── Cm06010MovirusController.java │ │ │ └── Cm06010SearchForm.java │ │ ├── application/ // APP: use-case service │ │ │ ├── Cm06010MovirusService.java │ │ │ └── port/Cm06010SearchPort.java // interface OUTBOUND │ │ ├── domain/ // DOMAIN: entity/record, không framework │ │ │ ├── MoushiInfo.java · CodeName.java │ │ │ └── MoushiSearchCriteria.java │ │ └── infrastructure/ // INFRA: triển khai port │ │ ├── persistence/ // DAO: Repository + Mapper │ │ │ ├── Cm06010MovirusRepository.java // implements Port │ │ │ └── Cm06010MovirusMapper.java // @Mapper interface │ │ └── procedure/ // gọi Oracle stored-proc │ │ └── Cm06010MovirusProcedureGateway.java │ │ │ ├── cm05motel/ CM05110 — cùng 4-ring layout │ ├── cm30customer/ CM30020 │ └── cm70maintenance/ CM71060 │ ├── src/main/resources/ │ ├── application.yaml + application-{local,dev,stg,prod}.yaml // CONFIG runtime │ ├── ia-catalog.json // dữ liệu menu điều hướng │ ├── db/migration/oracle/V001…V005__*.sql // Flyway │ └── com/company/opt/optweb/<ctx>/infrastructure/persistence/ │ └── *Mapper.xml // SQL — mirror package của Mapper │ ├── src/main/webapp/WEB-INF/views/ // VIEW (JSP) │ ├── common/ home · error · screen-template .jsp │ ├── layout/ shell-top · shell-bottom · contract-tree .jspf │ └── cm06movirus/cm06010.jsp · cm05motel/… · … │ ├── src/test/java/…/arch/LegacyOriginMappingTest.java // ArchUnit gác cổng ├── coding-rules/ legacy-origin-mapping.md · legacy-map-check.py · CODING-TIPS.md └── pom.xml
Mẹo đọc nhanh: thấy package tên cm… → đó là màn hình nghiệp vụ, mở 4 thư mục con theo thứ tự presentation → application → domain → infrastructure. Thấy config/ hoặc shared/ → đó là khung framework, không gắn màn hình nào.

04 Clean Architecture — 4 vòng & "luật phụ thuộc"

Cốt lõi cần khắc cốt ghi tâm: phụ thuộc luôn hướng vào trong. Lớp ngoài biết lớp trong; lớp trong không bao giờ biết lớp ngoài. domain ở tâm — không import gì của Spring/MyBatis.

① Presentation presentation
Controller nhận HTTP, validate input, đổ dữ liệu vào Model → JSP. Form object + Bean Validation ở đây.
@Controller · @RequestMapping · BindingResult · Cm06010SearchForm
▼ gọi xuống
(phụ thuộc vào trong)
② Application application
Use-case / orchestration. Service điều phối nghiệp vụ, gọi DB qua Port (interface). Không hề biết MyBatis tồn tại.
@Service · application.port.Cm06010SearchPort (interface)
▼ chỉ biết
domain + port
③ Domain domain
Trái tim. Entity / record / criteria thuần. Tự do với Spring/persistence framework (chỉ dùng Lombok @Data + @LegacyOrigin nội bộ).
MoushiInfo · MoushiSearchCriteria · CodeName
⚠️ Mũi tên mà ai cũng vẽ ngược — đọc kỹ:
Lớp ④ Infrastructure infrastructure nằm ngoài cùng, nhưng nó không bị Application phụ thuộc vào. Ngược lại: Cm06010MovirusRepository (infra) implements Cm06010SearchPort (interface ở application). Đây là Dependency Inversion — mũi tên code chạy infrastructure → application.port, KHÔNG phải application → infrastructure.

Kết quả: muốn đổi MyBatis sang JPA, chỉ thay infrastructure/persistence, application + domain không đụng 1 dòng.

infrastructure Persistence (DAO)

Repository implements Port, ủy thác sang @Mapper interface; SQL nằm trong *Mapper.xml. procedure/ gateway gọi Oracle stored-proc qua JdbcTemplate.

Chỉ có outbound port

Controller gọi thẳng class Service (không qua "inbound port"). Chỉ chiều ra-DB mới có interface Port. Giữ đơn giản — đúng tinh thần PoC.

Đối chiếu từ vựng: dự án dùng từ vựng DDD-layer. Nếu bạn đọc tài liệu hexagonal cũ: adapter.in.web → presentation, adapter.out.persistence → infrastructure.persistence, adapter.out.procedure → infrastructure.procedure; use-case service nằm thẳng trong application.

05 Giải phẫu 1 màn hình — CM06010 từ A→Z

Theo dấu nghiệp vụ "tìm kiếm đơn đăng ký" (検索) chạy xuyên qua cả 4 vòng. Đọc xong cái này, 3 màn hình kia bạn đọc trong 5 phút.

presentation Controller — cửa vào HTTP

Nhận request, validate, đổ Model, trả tên JSP. Lưu ý method shell() — "middleware thủ công" gắn nav + combo cho mọi screen.

@Controller
@RequestMapping("/api/cm06movirus/cm06010")   // route = registry legacy-map.yaml
@LegacyOrigin(module="CM06MoVirus", screen="CM06010", source="FrmCM06010.vb")
public class Cm06010MovirusController {
  private final Cm06010MovirusService service;   // gọi thẳng Service (không inbound port)
  private final NavCatalog nav;

  @PostMapping("/search")        // btnSearch_Click (FrmCM06010.vb L256)
  public String search(@Valid @ModelAttribute("form") Cm06010SearchForm form,
                       BindingResult binding, Model model) {
    shell(model);                       // ← gắn nav + combo (convention)
    if (form.isAllBlank())              // Chk_Form_Pvt: tất cả trống → M99043
      binding.reject("M99043", "すべての項目が入力/選択されていません。");
    if (binding.hasErrors()) {  return VIEW; }   // XỬ LÝ LỖI tại đây
    model.addAttribute("results", service.search(form.toCriteria()));
    return VIEW;                         // "cm06movirus/cm06010" → JSP
  }
}

application Service + Port — nghiệp vụ thuần

Service chỉ điều phối, gọi qua interface Port. Không một dòng MyBatis nào lọt vào đây.

@Service
public class Cm06010MovirusService {
  private final Cm06010SearchPort port;   // ← interface, KHÔNG phải Repository cụ thể
  public List<MoushiInfo> search(MoushiSearchCriteria c) {
    return port.searchMoushiInfo(c);        // ủy thác xuống infra qua port
  }
}

// application/port/Cm06010SearchPort.java — "hợp đồng" ra-DB
public interface Cm06010SearchPort {
  List<CodeName> findMenuKbnList();
  List<MoushiInfo> searchMoushiInfo(MoushiSearchCriteria criteria);
}

domain Entity / Criteria — không framework

@Data @NoArgsConstructor @AllArgsConstructor          // Lombok — OK trong domain
@LegacyOrigin(module="CM06MoVirus", screen="CM06010", source="FrmCM06010.vb:L332")
public class MoushiInfo {
  private String ukeNo;     // 受付番号 — CN.UKENO (cột grid 0)
  private String custName;  // お客さま名 — CASE cá nhân/pháp nhân
  private String kyakuNo;   // 顧客番号 — cột ẩn, dùng khi double-click
}

infrastructure Repository → Mapper → XML (DAO thật sự)

@Repository
public class Cm06010MovirusRepository implements Cm06010SearchPort {  // ← DIP
  private final Cm06010MovirusMapper mapper;
  @Override public List<MoushiInfo> searchMoushiInfo(MoushiSearchCriteria c){
    return mapper.searchMoushiInfo(c);   // ủy thác mỏng sang MyBatis
  }
}

@Mapper  public interface Cm06010MovirusMapper {  // SQL ở file .xml cùng tên
  List<MoushiInfo> searchMoushiInfo(MoushiSearchCriteria criteria);
}

SQL nằm trong resources/com/company/opt/optweb/cm06movirus/infrastructure/persistence/Cm06010MovirusMapper.xml — viết bằng MyBatis dynamic SQL (<if>, <choose>), reproduce 1:1 chuỗi SQL mà VB nối tay. Macro VB (F_LCOMMON, F_LKYAKU…) được giải thích trong comment đầu file XML.

procedure Stored-proc gateway — gọi Oracle nguyên xi

Khi legacy gọi Oracle package/proc, tuyệt đối không viết lại logic bằng Java. Gateway gọi CALL thẳng.

@Component
@LegacyOrigin(, procedure="CCS_COMMON_PAC.SP_GET_VBUSERID")
public class Cm06010MovirusProcedureGateway {
  public String getVbUserId() {
    return jdbcTemplate.execute(
      con -> con.prepareCall("{ call CCS_COMMON_PAC.SP_GET_VBUSERID(?) }"),
      cs -> { cs.registerOutParameter(1, Types.VARCHAR); cs.execute();
              return cs.getString(1); });
  }
}

Lưu ý: chỉ cm06movirus hiện có procedure gateway. 3 màn hình kia chưa cần.

06 Vòng đời 1 request — "検索" đi qua những đâu

Từ lúc user bấm nút đến lúc bảng kết quả hiện ra. Mỗi mũi tên là 1 ranh giới vòng.

// User bấm 検索 trên cm06010.jsp (form POST + CSRF token) Browser ──POST /api/cm06movirus/cm06010/search──▶ SecurityFilterChain (middleware: check CSRF) SecurityFilterChain ──────▶ Cm06010MovirusController.search() presentation@Valid + isAllBlank() → BindingResult. Nếu lỗi: return VIEW kèm errors (M99043)Cm06010MovirusService.search(criteria) applicationgọi qua interface port.searchMoushiInfo()Cm06010MovirusRepository (implements Port) infrastructureCm06010MovirusMapper.xml ──dynamic SQL──▶ Oracle DB ▲ │ ◀── List<MoushiInfo> (domain) ── trả ngược lên nguyên xi qua từng vòng ▼ Controller model.addAttribute("results", …) ──▶ cm06010.jsp cm06010.jsp ──render HTML (bảng 受付番号/申込年月日/…)──▶ Browser
Để ý: dữ liệu trả về là object domain (MoushiInfo) đi xuyên thẳng lên controller rồi vào JSP. Không có tầng DTO trung gian — PoC giữ tối giản, JSP đọc trực tiếp bằng JSTL EL (${r.ukeNo}).

07 Tra cứu chi tiết — đúng các câu hỏi onboarding

Phần này trả lời sâu từng câu trong checklist. Mỗi thẻ: ở đâu · làm gì · ví dụ thật.

🧩 Common nằm ở đâu?

Không có thư mục util/ hổ lốn. Code dùng chung chia theo vai trò:
shared/web/NavCatalog — model điều hướng (← thay CM90Common của VB)
shared/annotation/LegacyOrigin — annotation truy vết
config/ — web/security/error/home
• view chung: WEB-INF/views/common/ + layout/*.jspf

🛑 Error lưu & xử lý thế nào?

2 cơ chế, không hơn:
1. Validate (trong từng controller): @Valid + BindingResult; lỗi nghiệp vụ reject bằng mã M99043… rồi return lại VIEW kèm errors.
2. Lỗi hệ thống / 404: GlobalErrorController bắt /error → render common/error.jsp trong nav shell, message tiếng Nhật M-code (404→「画面が見つかりません」, 500→「M99999…」).
❗ Cố ý không dùng @ControllerAdvice — tránh che giấu lỗi trong PoC.

⚙️ Config như thế nào?

2 nơi:
Code config: config/*.java (@Configuration như SecurityConfig).
Runtime config: resources/application.yaml + 4 profile local/dev/stg/prod. Ở đây khai báo Flyway, MyBatis mapper-locations, view prefix/suffix JSP, graceful shutdown.
Profile mặc định = local.

🗺️ Routing ở đâu?

Trên Controller: @RequestMapping("/api/<module>/cm<nnnnn>") + @GetMapping/@PostMapping cho action.
HomeController là "router điều hướng": /, /screen/{cm}, /jump?cm=, /health.
SoT của route = .nexa/legacy-map.yaml; route trên code bị test đối chiếu với registry này.

🧱 "Middleware" thế nào?

Java/Spring không có "middleware" kiểu Express. Ánh xạ trung thực:
Cross-cutting thật = Spring Security filter chain (SecurityConfig): hiện permitAll() + CSRF ON; mọi POST đổi-trạng-thái cần token.
Per-request setup = convention shell(model) trong từng controller, gắn nav/activeCm/located + combo.
Không có custom Interceptor/Filter riêng — sự vắng mặt cũng là thông tin.

🧠 Service thế nào?

application/*Service.java (@Service). Nhận domain criteria, điều phối nghiệp vụ, gọi DB qua port. Màn hình god-screen (CM30020) có thể tách thành nhiều Service. Service không import MyBatis/JDBC.

💾 DAO thế nào?

infrastructure/persistence/:
*Repository (@Repository) — implements Port, ủy thác mỏng.
*Mapper (@Mapper) — interface MyBatis.
*Mapper.xml (trong resources/ mirror package) — SQL thật.
Stored-proc → tách riêng infrastructure/procedure/*Gateway (JdbcTemplate).

📋 Form & Validation

presentation/*Form.java giữ input thô của màn hình (vd Cm06010SearchForm, Cm71060ImportForm), gắn Bean Validation. Controller gọi form.toCriteria() để đổi sang domain criteria sạch trước khi đưa xuống Service.

08 Quy định bắt buộc — đây là phần khác biệt nhất

Codebase này có một "luật neo về legacy" mà CI sẽ fail build nếu vi phạm. Hiểu nó trước khi commit dòng đầu tiên.

@LegacyOrigin — mỗi class neo về 1 màn hình VB

Mọi class trong package bounded-context (controller, service, entity, mapper/repository, gateway) bắt buộc@LegacyOrigin(module, screen, source). Đây là sợi dây để dev Optage trace new↔old trong 1 bước.

@LegacyOrigin(module="CM06MoVirus", screen="CM06010", source="FrmCM06010.vb")
Framework ring được miễn: OptWebApplication, config.*, shared.*, layout JSP — không có gốc legacy nên không cần @LegacyOrigin (khai báo ở legacy-map.yaml :: frameworkOnly).

② SoT = .nexa/legacy-map.yaml

File registry này map context ↔ legacyModule ↔ screen ↔ route ↔ label. Nó là chân lý; giá trị trong @LegacyOrigin và route được kiểm ngược lại registry, không phải chiều ngược.

③ Bảng đặt tên (neo = Anchored, idiom = Idiomatic)

Thành phầnQuy ướcVí dụ
Package contextmirror tên module VB viết thườngCM06MoVirus → cm06movirus
ControllerCm<nnnnn><Ctx>ControllerCm06010MovirusController
ServiceCm<nnnnn>…ServiceCm06010MovirusService
Route/api/<lower(module)>/cm<nnnnn>[/action]/api/cm06movirus/cm06010/search
JSPviews/<ctx>/cm<nnnnn>.jspcm06movirus/cm06010.jsp
Port (interface)idiom — đặt theo nghiệp vụCm06010SearchPort

④ Gác cổng tự động (sẽ fail CI nếu sai)

🧪 ArchUnit

LegacyOriginMappingTest.java — đọc bytecode, ép controller phải đặt tên anchored + có @LegacyOrigin hợp lệ. (Vì vậy annotation để RetentionPolicy.CLASS, không phải SOURCE.)

🐍 legacy-map-check.py

Chạy python3 coding-rules/legacy-map-check.py (hoặc /legacy-map) → 0 hard-fail. Đối chiếu mọi @LegacyOrigin với registry; bắt orphan/gap.

Luật phụ thuộc cũng được test: application chỉ được biết domain + port — không import MyBatis. Đây là lý do Port tồn tại.

09 Quy trình thêm 1 màn hình mới

Khi được giao port màn hình CMxxxxx, đi đúng 5 bước này (rút từ legacy-origin-mapping.md §6).

  1. Đăng ký vào registryĐảm bảo screen có trong .nexa/legacy-map.yaml với scope: poc.
  2. Tạo package context + 4 ringTạo <ctx>/{presentation,application,application/port,domain,infrastructure/persistence}. Procedure gateway chỉ tạo khi có stored-proc.
  3. Viết ControllerĐặt tên Cm<nnnnn><Ctx>Controller, gắn @LegacyOrigin + @RequestMapping = route trong registry. Đừng quên shell(model).
  4. Domain + DAOEntity = Pascal(Table)@LegacyOrigin; Port ở application/port; Repository implements Port; Mapper.xml co-located trong resources/…/persistence/.
  5. JSP + chạy validatorView ở WEB-INF/views/<ctx>/cm<nnnnn>.jsp. Chạy python3 coding-rules/legacy-map-check.py → phải 0 hard-fail trước khi commit.
Nguyên tắc tối thượng: reproduce hành vi VB 1:1. SQL bê 1:1 từ form VB vào Mapper.xml (đổi nối-chuỗi thành #{} bind param); logic stored-proc gọi chứ không viết lại. Test kiểu 新旧比較: cùng input → output Java == output VB.

10 Gotchas khi migrate (đọc kẻo mất ngày)

Tổng hợp từ coding-rules/CODING-TIPS.md — những cái bẫy đã có người dẫm.

🧮 FarPoint Spread grid

Control thương mại của VB (lưới ở CM30020 sub-tab, CM06010), không có tương đương 1:1 trong Java. Phải chốt thư viện grid sớm — đừng giả định.

🔤 Encoding CP932

Source VB là CP932. Mọi string tiếng Nhật (message §5) phải round-trip đúng — coi chừng mojibake.

📞 Stored-proc 1:1

CWS_SERVICE_PAC.SF_TEL*, SP_GET_VBUSERID… Oracle 23ai giữ nguyên signature. Không re-implement logic bằng Java — gọi proc.

🔗 CM90Common

Lib nội bộ Optage, 65 import xuyên màn hình PoC. Trách nhiệm chưa rõ — confirm trước khi code. (Trong dự án phần này map vào shared/.)

🗄️ 60 bảng dùng chung kakin

Đổi schema Oracle 23ai có thể ảnh hưởng batch kakin ngoài PoC. Không sửa schema các bảng protected (có migration-guard gác).

🎯 Scope CM30020 đã khóa

Chỉ làm parent UI + Netflix sub — KHÔNG đụng cả họ 106-file. Cẩn thận message table §6 lẫn source-noise: re-extract từ FrmCM30020.vb.