前端分层架构设计 — 从 Harness Engineering 到落地实践
源自对 OpenAI《Harness Engineering》文章观点 5.1(刚性分层架构)的深度映射与工程化落地。
一、分层架构解决的核心问题
一句话
改了一个地方,影响范围是否可预测、可控。
不分层时的五个真实痛点
| 场景 | 不分层 | 分层 + 单向依赖 |
|---|---|---|
| 换 API 方案(REST → GraphQL) | axios 散落在 80 个组件里,逐个替换 | 只改 repo 层几个文件,上游一行不动 |
| 改业务规则(过期时间 30→60 分钟) | 规则散落各处,不确定改全了没 | 改 service 层一处,全局生效 |
| 跨模块依赖(订单 ←→ 用户) | 互相耦合,改一个另一个坏 | 通过 Provider 解耦,各自独立演化 |
| 新人/AI 不知道代码放哪 | 5 个人 5 种做法 | 分层规则直接回答:每种代码有唯一归属 |
| 重构换技术(Vuex → Pinia) | 全局考古,不确定是否改干净 | 只改 runtime 层,其他层保证不受影响 |
核心原理
不分层时,任意两个文件都可能互相依赖:
A.ts ←→ B.ts ←→ C.ts ←→ D.ts
改 A → 不知道谁会坏 → 可能全坏
分层后,依赖方向是单向的:
A.ts → B.ts → C.ts → D.ts
改 A → 只需检查 B、C、D(下游)
改 D → 没有任何文件会坏(没有人依赖它)
类比:水从水厂 → 净水站 → 主管道 → 分管道 → 水龙头。换了净水站的滤芯,只需检查下游。水龙头换了款式,上游完全不受影响。
二、前端六层架构映射
分层模型
以电商平台”订单管理”业务域为例:
src/domains/order/
├── types/ ← 第1层:类型定义
├── config/ ← 第2层:配置
├── repo/ ← 第3层:数据访问
├── service/ ← 第4层:业务逻辑
├── runtime/ ← 第5层:运行时编排
├── ui/ ← 第6层:UI 展示
└── providers/ ← 跨切面:注入层
依赖方向(单向,不可逆):
Types → Config → Repo → Service → Runtime → UI
↑
└──────── Providers(侧面注入到任意层)────┘
对照表
| 层 | 目录 | 一句话职责 | 允许依赖 | 禁止 |
|---|---|---|---|---|
| Types | types/ | 定义数据形状 | 无 | 运行时代码 |
| Config | config/ | 静态规则和常量 | Types | API 调用、副作用 |
| Repo | repo/ | 外部数据交互 + 边界解析 | Types, Config | 业务逻辑、Vue API |
| Service | service/ | 纯业务逻辑计算 | Types, Config, Repo | ref/reactive/watch、DOM |
| Runtime | runtime/ | 状态管理和副作用编排 | Types, Config, Repo, Service | 直接操作 DOM |
| UI | ui/ | 纯渲染展示 | 所有前置层 | 直接调 API、业务计算 |
| Providers | providers/ | 跨域公共能力注入 | 侧面接入任意层 | - |
三、逐层详解 + 代码示例
第1层:Types — 类型定义
职责:定义该业务域的所有数据形状。纯类型,零逻辑,零依赖。
规则:不允许 import 本域内其他任何层,也不允许 import 外部业务域。
// src/domains/order/types/order.ts
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
export interface OrderItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number; // 单位:分
}
export interface Order {
id: string;
userId: string;
items: OrderItem[];
status: OrderStatus;
totalAmount: number;
createdAt: string; // ISO 8601
updatedAt: string;
}
export interface CreateOrderInput {
items: Array<{ productId: string; quantity: number }>;
}
// 这里不能出现:
// ❌ import { api } from '../repo/api'
// ❌ import { useOrder } from '../runtime/hooks'
// ❌ import { UserProfile } from '../../user/types' (跨域依赖要走 Providers)为什么:Types 是所有层的”共同语言”。如果类型层依赖了业务逻辑,就会产生循环依赖。
第2层:Config — 配置
职责:该业务域的静态配置、常量、阈值。可以引用 Types。
// src/domains/order/config/order.config.ts
import type { OrderStatus } from '../types/order';
// 订单状态流转的合法路径
export const ORDER_STATUS_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
pending: ['paid', 'cancelled'],
paid: ['shipped', 'cancelled'],
shipped: ['delivered'],
delivered: [],
cancelled: [],
};
// 业务规则常量
export const ORDER_LIMITS = {
maxItemsPerOrder: 50,
maxQuantityPerItem: 999,
orderExpirationMinutes: 30, // 未支付订单30分钟过期
} as const;
// 分页默认值
export const ORDER_LIST_DEFAULTS = {
pageSize: 20,
defaultSort: 'createdAt' as const,
defaultDirection: 'desc' as const,
};类比:Config 就是一张”规则表”——不需要调接口就知道的”死规矩”。
第3层:Repo — 数据访问
职责:所有外部数据交互。在边界处用 Schema 解析数据形状(parse, don’t validate)。
// src/domains/order/repo/order.repo.ts
import { z } from 'zod';
import type { Order, CreateOrderInput } from '../types/order';
import { ORDER_LIST_DEFAULTS } from '../config/order.config';
// 在边界处用 Schema 解析,不盲目信任 API 返回
const OrderSchema = z.object({
id: z.string(),
userId: z.string(),
items: z.array(z.object({
productId: z.string(),
productName: z.string(),
quantity: z.number().int().positive(),
unitPrice: z.number().int().nonnegative(),
})),
status: z.enum(['pending', 'paid', 'shipped', 'delivered', 'cancelled']),
totalAmount: z.number().int().nonnegative(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
const OrderListSchema = z.object({
orders: z.array(OrderSchema),
total: z.number(),
hasMore: z.boolean(),
});
export type OrderListResult = z.infer<typeof OrderListSchema>;
export async function fetchOrders(params: {
page?: number;
pageSize?: number;
}): Promise<OrderListResult> {
const pageSize = params.pageSize ?? ORDER_LIST_DEFAULTS.pageSize;
const page = params.page ?? 1;
const res = await fetch(`/api/orders?page=${page}&pageSize=${pageSize}`);
if (!res.ok) throw new Error(`Failed to fetch orders: ${res.status}`);
const raw = await res.json();
return OrderListSchema.parse(raw); // 出了这一层,数据形状就是确定的
}
export async function fetchOrderById(id: string): Promise<Order> {
const res = await fetch(`/api/orders/${encodeURIComponent(id)}`);
if (!res.ok) throw new Error(`Failed to fetch order ${id}: ${res.status}`);
return OrderSchema.parse(await res.json());
}
export async function createOrder(input: CreateOrderInput): Promise<Order> {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`Failed to create order: ${res.status}`);
return OrderSchema.parse(await res.json());
}类比:Repo 是”外交官”——只有它和外界打交道,把原始数据翻译成内部认可的格式。
第4层:Service — 业务逻辑
职责:纯业务规则计算。不关心数据从哪来,不关心页面长什么样。
// src/domains/order/service/order.service.ts
import type { Order, OrderStatus } from '../types/order';
import { ORDER_STATUS_TRANSITIONS, ORDER_LIMITS } from '../config/order.config';
export function canTransition(current: OrderStatus, target: OrderStatus): boolean {
return ORDER_STATUS_TRANSITIONS[current].includes(target);
}
export function calculateTotal(items: Array<{ unitPrice: number; quantity: number }>): number {
return items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0);
}
export function isOrderExpired(order: Order): boolean {
if (order.status !== 'pending') return false;
const created = new Date(order.createdAt).getTime();
return Date.now() - created > ORDER_LIMITS.orderExpirationMinutes * 60 * 1000;
}
export function getAvailableActions(order: Order): OrderStatus[] {
if (isOrderExpired(order)) return ['cancelled'];
return ORDER_STATUS_TRANSITIONS[order.status];
}
export function formatAmount(amountInCents: number): string {
return `¥${(amountInCents / 100).toFixed(2)}`;
}类比:Service 是”业务专家”——只管规则,不管数据从哪查的,也不管按钮长什么样。
第5层:Runtime — 运行时编排
职责:连接数据层和 UI 层的粘合剂。管理状态、副作用、异步流程。
// src/domains/order/runtime/useOrders.ts
import { useState, useEffect, useCallback } from 'react';
import type { Order } from '../types/order';
import type { OrderListResult } from '../repo/order.repo';
import { fetchOrders } from '../repo/order.repo';
import { isOrderExpired, getAvailableActions } from '../service/order.service';
interface UseOrdersState {
orders: Order[];
total: number;
hasMore: boolean;
loading: boolean;
error: string | null;
}
export function useOrders(page: number = 1) {
const [state, setState] = useState<UseOrdersState>({
orders: [], total: 0, hasMore: false, loading: true, error: null,
});
const load = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const result: OrderListResult = await fetchOrders({ page });
setState({ orders: result.orders, total: result.total, hasMore: result.hasMore, loading: false, error: null });
} catch (e) {
setState(prev => ({ ...prev, loading: false, error: e instanceof Error ? e.message : 'Unknown error' }));
}
}, [page]);
useEffect(() => { load(); }, [load]);
return { ...state, reload: load };
}
export function useOrderActions(order: Order) {
const expired = isOrderExpired(order);
const availableActions = getAvailableActions(order);
const cancel = useCallback(async () => { /* 调 repo 层执行取消 */ }, [order.id]);
return { expired, availableActions, cancel };
}类比:Runtime 是”调度中心”——编排数据获取和业务规则,提供给 UI 即插即用的接口。
第6层:UI — 展示
职责:纯展示。不包含业务逻辑,不直接调 API。
// src/domains/order/ui/OrderList.tsx
import { useOrders } from '../runtime/useOrders';
import { formatAmount } from '../service/order.service';
import type { Order } from '../types/order';
export function OrderList() {
const { orders, loading, error, hasMore, reload } = useOrders();
if (loading) return <div className="skeleton" />;
if (error) return <div className="error">{error}</div>;
return (
<div>
<table>
<thead>
<tr>
<th>订单号</th><th>金额</th><th>状态</th><th>下单时间</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id}>
<td>{order.id}</td>
<td>{formatAmount(order.totalAmount)}</td>
<td>{order.status}</td>
<td>{new Date(order.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
{hasMore && <button onClick={reload}>加载更多</button>}
</div>
);
}类比:UI 是”服务员”——只负责端菜和传单,不做菜不采购。
Providers — 跨切面注入层
职责:认证、遥测、特性开关等跨多个业务域的公共关注点。唯一允许跨域的通道。
// src/providers/auth.provider.ts
import { createContext, useContext } from 'react';
interface AuthContext {
userId: string | null;
token: string | null;
isAuthenticated: boolean;
}
const AuthCtx = createContext<AuthContext>({ userId: null, token: null, isAuthenticated: false });
export const useAuth = () => useContext(AuthCtx);
// src/providers/telemetry.provider.ts
export function trackEvent(name: string, props?: Record<string, unknown>) { /* ... */ }
export function trackError(error: Error, context?: Record<string, unknown>) { /* ... */ }跨域依赖规则:
❌ 错误:Order 的 UI 直接 import User 域
src/domains/order/ui/OrderDetail.tsx
→ import { UserProfile } from '../../user/types/user'
✅ 正确:通过 Provider 传入
src/providers/user.provider.ts (暴露 useCurrentUser hook)
src/domains/order/runtime/useOrderDetail.ts (从 provider 获取 userId)
四、更高层的抽象:固定模式减少 AI 犯错
分层是手段之一,但核心原则更通用:把开放题变成填空题。
固定”骨架”,只留”肉”给 AI 填。
六种非分层的固定模式
1. Convention 契约模式
原理:用文件名/目录名代替配置和判断。AI 只需要”照着规矩放文件”。
src/pages/
├── order/
│ ├── list/
│ │ ├── index.vue ← 自动注册为路由 /order/list
│ │ ├── meta.ts ← 自动读取路由元信息
│ │ └── permissions.ts ← 自动注册权限
// meta.ts — AI 只需要填这个固定结构
export default {
title: '订单列表',
icon: 'order',
menuOrder: 2,
requiredRole: ['admin', 'sales'],
};框架自动扫描注册,AI 不需要碰路由配置文件。消除的错误:注册遗漏、路径错误。
2. Template 模板模式
原理:为常见任务提供固定模板,AI 从”创造”变成”填空”。
export default defineCrudPage<Order>({
name: 'order',
api: { list: orderApi.fetchOrders, delete: orderApi.deleteOrder },
columns: [
{ key: 'id', label: '订单号' },
{ key: 'totalAmount', label: '金额', render: (v) => `¥${(v/100).toFixed(2)}` },
{ key: 'status', label: '状态', tag: true },
],
searchFields: [
{ key: 'status', label: '状态', type: 'select', options: STATUS_OPTIONS },
],
});50 个 CRUD 页面,结构 100% 一致。消除的错误:结构不一致、逻辑遗漏。
3. State Machine 状态机模式
原理:业务流程定义为有限状态机,AI 不需要用 if-else 猜下一步。
const orderFlowMachine = createMachine({
id: 'order-checkout',
initial: 'selectItems',
states: {
selectItems: { on: { NEXT: { target: 'fillAddress', guard: 'hasItems' } } },
fillAddress: { on: { NEXT: { target: 'choosePayment', guard: 'addressValid' }, BACK: { target: 'selectItems' } } },
choosePayment: { on: { SUBMIT: { target: 'confirming', guard: 'paymentSelected' }, BACK: { target: 'fillAddress' } } },
confirming: { on: { SUCCESS: { target: 'done' }, FAILURE: { target: 'choosePayment' } } },
done: { type: 'final' },
},
});不可能出现”没填地址就到了支付页”。消除的错误:非法状态跳转。
4. Schema-Driven 数据驱动模式
原理:一份 Schema 同时驱动表单渲染、校验、类型、Mock。
export const orderFormSchema = {
fields: {
customerName: { type: 'string', label: '客户姓名', required: true, maxLength: 50 },
phone: { type: 'string', label: '联系电话', required: true, pattern: /^1[3-9]\d{9}$/ },
amount: { type: 'number', label: '订单金额', required: true, min: 0.01, precision: 2 },
},
};
// 自动派生:TypeScript 类型、表单组件、校验规则、Mock 数据消除的错误:多处不同步(字段名拼错、类型不匹配)。
5. Pipeline 管道模式
原理:固定执行阶段和顺序,AI 只实现每个阶段的处理函数。
const submitOrderPipeline = createPipeline('submit-order', [
{ name: 'validate', handler: validateOrderData },
{ name: 'transform', handler: transformForApi },
{ name: 'pre-submit', handler: checkInventory },
{ name: 'submit', handler: callCreateOrderApi },
{ name: 'post-submit', handler: clearCartCache },
{ name: 'notify', handler: showSuccessToast },
]);消除的错误:步骤遗漏、顺序错乱。
6. Registry 注册表模式
原理:所有同类事物在一个注册表中声明,框架自动发现和组装。
const chartRegistry = createRegistry<ChartRegistration>([
{ type: 'bar', label: '柱状图', component: BarChart, defaultConfig: { stacked: false } },
{ type: 'line', label: '折线图', component: LineChart, defaultConfig: { smooth: false } },
// AI 新增图表 → 往数组里加一个对象
]);消除的错误:功能挂载遗漏。
总结
| 模式 | 固定的是什么 | AI 填的是什么 | 消除的错误类型 |
|---|---|---|---|
| Convention | 文件位置和命名规则 | 文件内容 | 注册遗漏、路径错误 |
| Template | 页面骨架和交互逻辑 | 数据列和字段配置 | 结构不一致、逻辑遗漏 |
| State Machine | 状态和转换规则 | 守卫条件和副作用 | 非法状态跳转 |
| Schema-Driven | 派生规则和渲染映射 | 字段描述 | 多处不同步 |
| Pipeline | 执行阶段和顺序 | 每阶段的处理函数 | 步骤遗漏、顺序错乱 |
| Registry | 注册结构和自动发现 | 注册项的具体内容 | 功能挂载遗漏 |
五、分层 vs 固定模式:辨析
将分层理解为”设计固定模式减少 AI 犯错”抓住了一个真实维度,但丢掉了其他维度。
对的部分
- 核心洞察成立:减少决策空间 → 减少出错
- 泛化方向正确:分层只是手段之一
丢失的部分
1. 分层的核心不是”固定”,而是”方向”
- 固定模式:约束了每个模块长什么样(形状)
- 分层架构:约束了模块之间谁能依赖谁(关系/流向)
类比:固定模式 = 所有房间必须是方形的;分层架构 = 水管只能从上往下流。
2. 目的不只是减少 AI 犯错
分层架构的三个效果:
1. AI 决策更少 → 犯错更少 ✅ 抓住了
2. 变更影响可预测 → 系统长期可维护 ❌ 丢了
3. 共同词汇表建立 → 沟通成本趋零 ❌ 丢了
3. 约束 ≠ 模式
| 模式 (Pattern) | 约束 (Constraint) | |
|---|---|---|
| 说的是 | ”这样做" | "不许那样做” |
| 限制的是 | 实现方式 | 边界 |
| 灵活度 | 低——要照着写 | 高——边界内自由 |
分层是约束(你不能从 UI 层调 Repo),不是模式(它不规定 UI 层内部怎么写)。
更准确的理解
分层的本质不是”给 AI 一个固定的模式去遵循”,而是”给系统设置不可违反的边界,边界内任何写法都可以”。模式约束了怎么做,边界约束了不能做什么——后者给的自由度更大。
六、机械化强制:保证 Agent 遵守规则
核心问题
即使通过文档告诉了 Agent,由于上下文劣化,它可能不遵守。解决方案:从”请遵守”升级到”不可能违反”。
四级强制力金字塔
Level 4 ┌────────────────────────────────────┐
(最强) │ 物理不可能 / 类型系统 │ 编译不过 = 不存在
│ http.get 必须传 schema │
├────────────────────────────────────┤
Level 3 │ 架构测试 / CI 阻断 │ 测试失败 = PR 无法合并
│ 依赖矩阵、文件结构检查 │
├────────────────────────────────────┤
Level 2 │ Lint Error(非 Warning) │ 本地写代码时即时报错
│ 错误信息 = 修复指南 │ Agent 读到错误后自动修复
├────────────────────────────────────┤
Level 1 │ 文档 / AGENTS.md │ 上下文劣化后会被忽略
(最弱) │ 口头约定 / Slack 讨论 │
└────────────────────────────────────┘
规则数据源(所有 lint/测试共享)
// arch-rules/layers.ts
export const LAYERS = ['types', 'config', 'repo', 'service', 'runtime', 'ui'] as const;
export type Layer = typeof LAYERS[number];
/** 每一层允许 import 哪些层 */
export function getAllowedDeps(layer: Layer): Layer[] {
const idx = LAYERS.indexOf(layer);
return LAYERS.slice(0, idx) as unknown as Layer[];
}
/** 从文件路径中提取层名 */
export function getLayerFromPath(filePath: string): Layer | null {
for (const layer of LAYERS) {
if (filePath.includes(`/${layer}/`)) return layer;
}
return null;
}
/** 从文件路径中提取域名 */
export function getDomainFromPath(filePath: string): string | null {
const match = filePath.match(/\/domains\/([^/]+)\//);
return match ? match[1] : null;
}
/** 从 import 路径中提取域名和层名 */
export function parseImportPath(importPath: string): { domain: string | null; layer: Layer | null } {
const domainMatch = importPath.match(/domains\/([^/]+)/);
const domain = domainMatch ? domainMatch[1] : null;
let layer: Layer | null = null;
for (const l of LAYERS) {
if (importPath.includes(`/${l}/`) || importPath.endsWith(`/${l}`)) {
layer = l;
break;
}
}
return { domain, layer };
}ESLint 规则1:层级依赖方向检查
// eslint-rules/layer-dependency-direction.js
const { LAYERS, getLayerFromPath, parseImportPath, getDomainFromPath, getAllowedDeps } = require('../arch-rules/layers');
module.exports = {
meta: {
type: 'error',
messages: {
wrongDirection: `
❌ 层级依赖方向违规
文件所在层:{{ currentLayer }}
试图导入层:{{ importedLayer }}
✅ {{ currentLayer }} 层只能导入:{{ allowed }}
💡 修复方法:
- 如果需要调用下游逻辑,把该逻辑上移到当前层或更上层
- 如果需要下游的类型,把类型定义移到 types/ 层
参考:docs/ARCHITECTURE.md#layer-rules
`.trim(),
},
},
create(context) {
const currentFile = context.getFilename();
const currentLayer = getLayerFromPath(currentFile);
const currentDomain = getDomainFromPath(currentFile);
if (!currentLayer || !currentDomain) return {};
return {
ImportDeclaration(node) {
const importPath = node.source.value;
const { domain: importedDomain, layer: importedLayer } = parseImportPath(importPath);
if (!importedLayer) return;
if (importedDomain && importedDomain !== currentDomain) return; // 跨域交给另一规则
if (importedLayer === currentLayer) return;
const allowed = getAllowedDeps(currentLayer);
if (!allowed.includes(importedLayer)) {
context.report({
node,
messageId: 'wrongDirection',
data: {
currentLayer,
importedLayer,
allowed: allowed.length > 0 ? allowed.join(', ') : '无(这是最底层)',
},
});
}
},
};
},
};ESLint 规则2:禁止跨域直接 import
// eslint-rules/no-cross-domain-import.js
const { getDomainFromPath, parseImportPath } = require('../arch-rules/layers');
module.exports = {
meta: {
type: 'error',
messages: {
crossDomain: `
❌ 跨域直接导入被禁止
当前域:{{ currentDomain }}
导入域:{{ importedDomain }}
✅ 跨域通信必须通过 providers/ 层
1. 在 src/providers/ 中暴露接口
2. 当前文件通过 import { xxx } from '@/providers/...' 使用
💡 直接跨域依赖会让两个模块耦合——改一个另一个就坏。
参考:docs/ARCHITECTURE.md#cross-domain
`.trim(),
},
},
create(context) {
const currentFile = context.getFilename();
const currentDomain = getDomainFromPath(currentFile);
if (!currentDomain) return {};
return {
ImportDeclaration(node) {
const importPath = node.source.value;
const { domain: importedDomain } = parseImportPath(importPath);
if (!importedDomain) return;
if (importedDomain === currentDomain) return;
if (importPath.includes('/providers/') || importPath.includes('/shared/')) return;
context.report({
node,
messageId: 'crossDomain',
data: { currentDomain, importedDomain },
});
},
};
},
};ESLint 规则3:HTTP 客户端只能在 repo 层使用
// eslint-rules/http-only-in-repo.js
const { getLayerFromPath } = require('../arch-rules/layers');
const HTTP_PACKAGES = ['axios', 'ky', 'ofetch', '@/lib/http', '@/utils/request'];
module.exports = {
meta: {
type: 'error',
messages: {
httpOutsideRepo: `
❌ HTTP 客户端只能在 repo/ 层使用
当前层:{{ layer }}
导入包:{{ pkg }}
✅ 修复步骤:
1. 在 repo/ 层创建数据访问函数
2. 在 service/ 层调用 repo 函数处理业务逻辑
3. 在 runtime/ 层创建 composable 编排调用
4. 在 ui/ 层使用 composable
💡 如果 80 个组件都直接调 axios,换 API 方案时要改 80 个文件。
收口到 repo 层,只改一处。
`.trim(),
},
},
create(context) {
const currentFile = context.getFilename();
const layer = getLayerFromPath(currentFile);
if (!layer || layer === 'repo') return {};
return {
ImportDeclaration(node) {
const pkg = node.source.value;
if (HTTP_PACKAGES.some(p => pkg.includes(p))) {
context.report({ node, messageId: 'httpOutsideRepo', data: { layer, pkg } });
}
},
CallExpression(node) {
if (node.callee.name === 'fetch' && layer !== 'repo') {
context.report({ node, messageId: 'httpOutsideRepo', data: { layer, pkg: 'fetch()' } });
}
},
};
},
};ESLint 规则4:各层职责守卫
// eslint-rules/layer-responsibility.js
const { getLayerFromPath } = require('../arch-rules/layers');
module.exports = {
meta: {
type: 'error',
messages: {
noSideEffectInService: `
❌ service/ 层禁止副作用
发现了:{{ thing }}
✅ service 层只放纯函数。需要副作用 → 放 runtime/ 层
`.trim(),
noDomInRuntime: `
❌ runtime/ 层禁止直接操作 DOM
发现了:{{ thing }}
✅ DOM 操作属于 ui/ 层。runtime 层通过返回数据驱动 UI 变化。
`.trim(),
},
},
create(context) {
const layer = getLayerFromPath(context.getFilename());
if (!layer) return {};
const rules = {};
if (layer === 'service') {
rules.ImportDeclaration = (node) => {
if (node.source.value === 'vue') {
const specifiers = node.specifiers.map(s => s.imported?.name).filter(Boolean);
const banned = ['ref', 'reactive', 'watch', 'onMounted', 'onUnmounted', 'nextTick'];
const found = specifiers.filter(s => banned.includes(s));
if (found.length > 0) {
context.report({ node, messageId: 'noSideEffectInService', data: { thing: `import { ${found.join(', ')} } from 'vue'` } });
}
}
};
}
if (layer === 'runtime') {
rules.MemberExpression = (node) => {
const domAPIs = ['querySelector', 'getElementById', 'innerHTML', 'appendChild'];
if (domAPIs.includes(node.property.name)) {
context.report({ node, messageId: 'noDomInRuntime', data: { thing: node.property.name } });
}
};
}
return rules;
},
};类型系统强制:API 调用必须传 Schema
// lib/http.ts — 从底层让"不校验"变得不可能
import { ZodSchema, ZodError } from 'zod';
interface TypedHttp {
// get 方法签名强制要求传入 schema — 不是文档约定,是类型系统强制
get<T>(url: string, schema: ZodSchema<T>): Promise<T>;
post<T>(url: string, data: unknown, schema: ZodSchema<T>): Promise<T>;
}
export function createHttpClient(): TypedHttp {
return {
async get<T>(url: string, schema: ZodSchema<T>): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new HttpError(res.status, url);
const raw = await res.json();
try {
return schema.parse(raw);
} catch (e) {
if (e instanceof ZodError) {
reportSchemaViolation({ url, errors: e.errors, raw });
throw new SchemaValidationError(url, e);
}
throw e;
}
},
};
}
// 不传 schema → TypeScript 编译失败
// http.get('/api/users') → TS Error: Expected 2 arguments, but got 1.架构测试(兜底)
// tests/architecture/layer-deps.test.ts
import { describe, it, expect } from 'vitest';
import fg from 'fast-glob';
import fs from 'fs';
import { LAYERS, getLayerFromPath, getDomainFromPath, getAllowedDeps, parseImportPath } from '../../arch-rules/layers';
describe('分层架构约束', () => {
it('所有 import 遵循层级依赖方向', async () => {
const files = await fg('src/domains/**/*.{ts,tsx,vue}');
const violations: string[] = [];
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8');
const currentLayer = getLayerFromPath(file);
const currentDomain = getDomainFromPath(file);
if (!currentLayer || !currentDomain) continue;
const importMatches = content.matchAll(/(?:import|from)\s+['"]([^'"]+)['"]/g);
const allowed = getAllowedDeps(currentLayer);
for (const match of importMatches) {
const { domain, layer } = parseImportPath(match[1]);
if (!layer || domain !== currentDomain) continue;
if (layer === currentLayer) continue;
if (!allowed.includes(layer)) {
violations.push(`${file}\n ${currentLayer} → ${layer} (禁止)\n import: ${match[1]}`);
}
}
}
expect(violations).toEqual([]);
});
it('跨域 import 必须通过 providers', async () => {
const files = await fg('src/domains/**/*.{ts,tsx,vue}');
const violations: string[] = [];
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8');
const currentDomain = getDomainFromPath(file);
if (!currentDomain) continue;
const importMatches = content.matchAll(/(?:import|from)\s+['"]([^'"]+)['"]/g);
for (const match of importMatches) {
const importPath = match[1];
const { domain: importedDomain } = parseImportPath(importPath);
if (!importedDomain || importedDomain === currentDomain) continue;
if (importPath.includes('/providers/') || importPath.includes('/shared/')) continue;
violations.push(`${file}\n ${currentDomain} → ${importedDomain} (必须走 providers)\n import: ${importPath}`);
}
}
expect(violations).toEqual([]);
});
it('每个域都包含必需的层级目录', async () => {
const domainDirs = await fg('src/domains/*', { onlyDirectories: true });
const requiredLayers = ['types', 'repo', 'service', 'runtime', 'ui'];
const violations: string[] = [];
for (const domainDir of domainDirs) {
const domainName = domainDir.split('/').pop();
const existingDirs = await fg(`${domainDir}/*`, { onlyDirectories: true });
const existingNames = existingDirs.map(d => d.split('/').pop());
for (const required of requiredLayers) {
if (!existingNames.includes(required)) {
violations.push(`域 ${domainName} 缺少 ${required}/ 目录`);
}
}
}
expect(violations).toEqual([]);
});
it('types/ 层只包含类型定义,无运行时代码', async () => {
const typeFiles = await fg('src/domains/*/types/**/*.ts');
const violations: string[] = [];
const runtimePatterns = [/export\s+(function|const|let|var|class)\s+/, /console\./, /fetch\(/, /new\s+\w+/];
for (const file of typeFiles) {
const content = fs.readFileSync(file, 'utf-8');
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line || line.startsWith('//') || line.startsWith('*') || line.startsWith('import')) continue;
for (const pattern of runtimePatterns) {
if (pattern.test(line) && !/export\s+(type|interface|enum)\s+/.test(line)) {
violations.push(`${file}:${i + 1} types 层包含运行时代码: ${line.substring(0, 80)}`);
}
}
}
}
expect(violations).toEqual([]);
});
});ESLint 插件注册
// eslint-plugins/arch/index.js
module.exports = {
rules: {
'layer-dependency-direction': require('../../eslint-rules/layer-dependency-direction'),
'no-cross-domain-import': require('../../eslint-rules/no-cross-domain-import'),
'http-only-in-repo': require('../../eslint-rules/http-only-in-repo'),
'layer-responsibility': require('../../eslint-rules/layer-responsibility'),
},
};// .eslintrc.js
module.exports = {
plugins: ['arch'],
rules: {
'arch/layer-dependency-direction': 'error',
'arch/no-cross-domain-import': 'error',
'arch/http-only-in-repo': 'error',
'arch/layer-responsibility': 'error',
},
};CI 集成
# .github/workflows/arch-guard.yml
name: Architecture Guard
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- name: ESLint 架构规则
run: npx eslint 'src/domains/**/*.{ts,tsx,vue}'
- name: 架构测试
run: npx vitest run tests/architecture/
# 任一步骤失败 → PR 无法合并新建域脚手架脚本
#!/bin/bash
# scripts/create-domain.sh
DOMAIN_NAME=$1
if [ -z "$DOMAIN_NAME" ]; then
echo "用法: ./scripts/create-domain.sh <domain-name>"
exit 1
fi
BASE="src/domains/$DOMAIN_NAME"
mkdir -p "$BASE"/{types,config,repo,service,runtime,ui}
cat > "$BASE/types/index.ts" << 'EOF'
// types 层:纯类型定义,零逻辑,零依赖
// 允许 import:无
EOF
cat > "$BASE/config/index.ts" << 'EOF'
// config 层:静态配置和常量
// 允许 import:types
EOF
cat > "$BASE/repo/index.ts" << 'EOF'
// repo 层:数据访问(API 调用、存储读写)
// 允许 import:types, config
// 要求:所有 API 返回必须用 schema 解析
EOF
cat > "$BASE/service/index.ts" << 'EOF'
// service 层:纯业务逻辑
// 允许 import:types, config, repo
// 禁止:Vue API(ref/reactive/watch)、DOM 操作
EOF
cat > "$BASE/runtime/index.ts" << 'EOF'
// runtime 层:状态管理和副作用编排(composables)
// 允许 import:types, config, repo, service
// 导出函数必须以 use 开头
EOF
cat > "$BASE/ui/index.ts" << 'EOF'
// ui 层:纯展示组件
// 允许 import:所有上游层
// 禁止:直接调用 HTTP 客户端、包含业务计算逻辑
EOF
echo "✅ 域 $DOMAIN_NAME 创建完成:$BASE"运作流程
Agent 写代码
│
▼
本地 ESLint 实时检查 ── 违规 ──→ 报错(含修复指南)──→ Agent 自动修复
│ │
│ 通过 │
▼ ▼
git push → CI 运行 再次提交
│
├── ESLint 架构规则 ── 失败 ──→ PR 标红,无法合并
├── 架构测试 ────── 失败 ──→ 输出完整违规清单
└── 全部通过 ──→ PR 可合并 ✅
关键设计原则
- 错误信息就是修复指南 — Agent 不需要”记住”规则,违反时错误信息告诉它怎么修
- 越重要的规则,越往高层放 — 核心架构用类型系统强制,风格偏好用 Lint
- 让正确的做法成为唯一简单的做法 — 不是禁止错误写法,而是让正确写法更容易
- 文档只用来解释”为什么” — “怎么做”由工具链强制
不要依赖 Agent 的”记忆力”和”自觉性”。把规则编码到工具链中,让违规代码写不出来或合不进去。