前端分层架构设计 — 从 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(侧面注入到任意层)────┘

对照表

目录一句话职责允许依赖禁止
Typestypes/定义数据形状运行时代码
Configconfig/静态规则和常量TypesAPI 调用、副作用
Reporepo/外部数据交互 + 边界解析Types, Config业务逻辑、Vue API
Serviceservice/纯业务逻辑计算Types, Config, Reporef/reactive/watch、DOM
Runtimeruntime/状态管理和副作用编排Types, Config, Repo, Service直接操作 DOM
UIui/纯渲染展示所有前置层直接调 API、业务计算
Providersproviders/跨域公共能力注入侧面接入任意层-

三、逐层详解 + 代码示例

第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 可合并 ✅

关键设计原则

  1. 错误信息就是修复指南 — Agent 不需要”记住”规则,违反时错误信息告诉它怎么修
  2. 越重要的规则,越往高层放 — 核心架构用类型系统强制,风格偏好用 Lint
  3. 让正确的做法成为唯一简单的做法 — 不是禁止错误写法,而是让正确写法更容易
  4. 文档只用来解释”为什么” — “怎么做”由工具链强制

不要依赖 Agent 的”记忆力”和”自觉性”。把规则编码到工具链中,让违规代码写不出来或合不进去。