1. 从“t3code”这个名字说起它到底指什么第一次看到“t3code”这个词很多人会一头雾水。它不像“react”“docker”那样有明确的官方定义也不像“python”那样一眼能看出是编程语言。我在几个技术社区翻了一圈发现大家对这个词的使用场景其实相当分散有人拿它当某个内部工具链的代号有人用它指代一套轻量级的编码规范还有人把它当成“third-tier code”的缩写用来吐槽那些能跑但不敢动的祖传代码。既然输入里没有给出明确的正文和关键词我就按最常见的理解来展开——把“t3code”当作一个轻量级、约定优于配置的编码实践集合来聊它解决的是小团队在快速迭代中“代码越写越乱、新人上手越来越慢”的问题。这个定位听起来有点虚但落到实际项目里非常具体。你可以把它想象成一套“最小可行规范”不追求大而全的架构文档也不强制你上重型框架而是用几条硬性约定把代码的可读性和可维护性兜住。适合谁来参考我认为是三类人一是三五个人小团队的 tech lead需要一套不增加太多管理成本的规则二是刚接手一个中型项目、发现代码风格五花八门的开发者三是自己写 side project 想保持长期可维护的独立开发者。这三类人的共同点是没有专职的架构师也没有精力搞复杂的流程但又不想让代码变成一锅粥。我之所以愿意花时间拆解这个词是因为它背后反映了一个真实痛点。大多数团队在项目初期都信奉“先跑起来再说”等到代码量上来之后才发现改一个字段要翻五个文件加一个接口要复制三处相似逻辑。这时候再想引入完整的分层架构成本已经高到没人愿意动。t3code 这类轻量约定的价值就在于它可以在项目任何阶段以增量方式引入不需要推倒重来。接下来的内容我会从核心约定、落地步骤、常见坑、工具配合几个角度把这件事讲透。2. t3code 的三条核心约定与背后的取舍逻辑2.1 命名即文档为什么强制前缀比注释更有效t3code 里最容易被记住的一条约定是强制命名前缀。具体来说函数名、变量名、文件名都要带一个能表明其“层级”或“职责”的前缀。比如数据访问层的函数统一用db_开头业务逻辑层用svc_工具函数用util_对外接口用api_。这个做法乍看很土甚至有点反直觉——现代编程语言不是都强调命名要自然吗但我在实际项目里试过之后发现它的收益远超预期。原因在于注释是会过期的而命名不会。你写// 这个函数从数据库取用户这样的注释三个月后逻辑改了注释大概率没人更新。但如果你把函数命名为db_get_user_by_id任何人看到调用处都能立刻判断出这是一次数据库操作改逻辑的时候也会下意识去检查事务和连接管理。这就是“命名即文档”的核心把隐式信息变成显式约束。前缀还有一个好处是搜索友好。当你想找所有数据库操作时直接搜db_就能列全不用靠记忆去猜函数名。对于接手项目的新人来说这比读十页架构文档都管用。当然这条约定也有代价。前缀会让名字变长写起来多敲几个字符。我的经验是在编辑器里配置好代码片段snippet输入dbf自动展开成db_find_实际输入成本几乎可以忽略。另一个代价是“前缀膨胀”——如果团队不加控制可能会出现db_get_user_by_id_from_master这种又长又具体的名字。我的建议是前缀只表达“层”不表达“具体实现细节”具体细节交给函数名后半部分。2.2 单一出口原则每个模块只暴露一个入口文件第二条约定是每个模块只允许有一个对外入口文件通常命名为index或模块名本身。模块内部可以拆成任意多个文件但外部代码只能从这个入口文件导入。这条规则解决的是“依赖关系失控”的问题。在没有这条约定的项目里经常出现 A 模块直接引用了 B 模块内部的某个工具函数后来 B 模块重构把这个函数挪走了A 模块就崩了。这种隐式耦合是维护成本的最大来源之一。t3code 的做法是模块内部的文件可以互相引用但跨模块引用必须经过入口文件。入口文件里显式列出该模块对外提供的所有能力相当于一份“契约”。这样做的好处是重构模块内部实现时只要入口文件的导出不变外部代码就不受影响。我在一个中型项目里推行这条规则后模块重构的平均耗时从两天降到了半天因为需要检查的调用点从几十个变成了一个。实现这条约定的关键是工具约束。光靠自觉很难坚持因为直接引用内部文件往往更“方便”。我的做法是在代码检查工具里加一条规则禁止从其他模块的非入口文件导入。以 JavaScript 生态为例可以用 ESLint 的no-restricted-imports配合路径模式来限制。Python 项目可以用import-linter定义模块边界。工具会报错人才会记住。2.3 错误不跨层每层只处理自己关心的异常第三条约定是错误处理不跨层。意思是数据访问层抛出的数据库异常不应该直接透传到接口层接口层返回给前端的错误信息也不应该包含数据库细节。每一层只处理自己关心的异常类型把其他异常包装成该层的标准错误再往上抛。这条规则听起来像老生常谈但真正落地时很多团队都卡在“包装成什么”这个问题上。t3code 给出的方案很具体每层定义一个基础错误类型比如数据层用DbError业务层用SvcError接口层用ApiError。底层异常在跨越层边界时必须被转换成上一层的错误类型并保留原始错误作为cause字段。这样做的价值在于排查问题时信息不丢失但对外暴露时不泄露内部细节。我见过太多项目把数据库连接字符串直接拼进错误信息返回给前端这既是安全隐患也让前端无法区分“用户输入错误”和“系统故障”。这条约定的实操难点在于“转换时机”。我的经验是在每层的入口函数处做统一转换而不是在每个可能出错的地方写 try-catch。比如数据层的每个公开函数都用装饰器或高阶函数包一层捕获原生异常并转成DbError。这样业务层拿到的永远是DbError不需要关心底层是 MySQL 还是 PostgreSQL。换数据库时业务层代码一行不用改。3. 把 t3code 落到真实项目里的完整步骤3.1 第一步先画模块边界再写第一行代码很多团队推行编码规范失败是因为从“写代码时注意”开始而不是从“定边界”开始。t3code 的落地顺序恰好相反先确定模块划分再在模块内应用命名和导出约定。具体怎么做拿一个典型的后端项目举例我会先列出所有业务能力比如“用户管理”“订单处理”“支付对接”“消息通知”每个能力对应一个模块。然后确定模块之间的依赖方向订单模块可以依赖用户模块但用户模块不能反向依赖订单模块。这个依赖方向一旦定下来就写进架构决策记录里后续所有代码都遵守。为什么这一步不能省因为命名前缀和单一出口都是“模块内”的约定如果模块边界本身是模糊的前缀就失去了参照系。我见过一个项目把“用户认证”和“用户资料”混在一个模块里结果db_前缀的函数既查认证表又查资料表单一出口文件导出了三十多个函数完全失去了约束意义。重新划分模块后认证模块只导出verify_token和create_session两个函数资料模块只导出get_profile和update_profile代码立刻清爽了。画边界时有一个实用技巧用“变化频率”作为划分依据。变化频率相近的功能放在同一个模块变化频率差异大的分开。比如“支付渠道对接”经常变“订单状态机”很少变那就分成两个模块。这样改支付渠道时不会影响订单逻辑测试范围也小。这个思路来自我早期做电商项目的教训当时把支付和订单混在一起每次新增支付方式都要回归测试整个订单流程苦不堪言。3.2 第二步用脚手架固化目录结构减少口头约定边界定好之后下一步是用脚手架工具生成标准目录结构。t3code 不依赖某个特定框架但目录结构有推荐模板。以 Node.js 项目为例一个模块的标准结构是这样的src/ modules/ user/ index.js # 唯一出口 user.service.js # 业务逻辑函数以 svc_ 开头 user.repo.js # 数据访问函数以 db_ 开头 user.errors.js # 该模块的错误类型定义 user.utils.js # 模块内工具函数以 util_ 开头这个结构的关键在于每个文件的职责从文件名就能看出来。service只放业务逻辑不直接操作数据库repo只放数据访问不包含业务判断errors集中定义错误类型不散落在各处。新人拿到这个结构不需要问“这个函数该放哪”看文件名就知道。为了让这个结构真正落地我会写一个简单的脚手架脚本。用plop或者自己写个 Node 脚本都行输入模块名自动生成上述文件模板并且预置好导入导出语句。比如index.js默认导出service和errorsservice.js默认导入repo。这样开发者新建模块时结构天然就是对的不需要靠记忆去遵守约定。我试过在三个项目里用这种方式新人上手第一天就能提交符合规范的代码code review 里关于“文件放错位置”的评论减少了八成。3.3 第三步配置检查工具让违规代码无法提交约定和脚手架都有了但人总会犯错。最后一道防线是自动化检查。t3code 的检查规则不复杂核心就三条命名前缀、导入路径、错误类型。我用 ESLint 举例配置大概长这样// .eslintrc.js module.exports { rules: { no-restricted-imports: [error, { patterns: [ // 禁止跨模块引用非入口文件 /modules/*/!(index), ], }], no-restricted-syntax: [error, { selector: CallExpression[callee.name/^db_/], message: db_ 函数只能在 repo 文件中调用, }], }, };这段配置的意思是第一禁止从其他模块的非index文件导入第二db_开头的函数只能在repo文件里调用不能在service里直接调。第二条规则特别有用它强制了“业务逻辑不直接碰数据库”的约定。我见过太多项目在 service 里直接写 SQL导致事务边界混乱、测试困难。有了这条检查想偷懒都偷不了。检查工具配置好之后一定要接入提交前钩子pre-commit hook。用husky加lint-staged每次 commit 自动检查改动的文件。如果违规直接拒绝提交并给出提示。这一步的体验很重要提示信息要写清楚“为什么违规”和“怎么改”而不是只报一个错误码。比如“检测到在 service 文件中调用了 db_ 函数请将数据访问逻辑移到 repo 文件或通过 service 调用 repo 的公开方法”。好的提示能让开发者自己纠正而不是跑来问你。4. 推行过程中最容易踩的四个坑4.1 坑一前缀命名过度细化导致名字失控第一个坑我在前面提过但值得单独展开。t3code 要求命名带前缀但很多人会不自觉地“加码”db_get_user_by_id_and_status、svc_calculate_order_total_with_discount。名字越来越长可读性反而下降。问题的根源是把“实现细节”塞进了前缀后面的部分。我的纠正方法是前缀只表达层级函数名主体只表达“做什么”不表达“怎么做”。db_get_user就够了具体按 id 还是按 email 查通过参数区分。如果确实需要区分用参数而不是函数名。另一个相关问题是前缀不统一。有人用db_有人用database_有人用dao_。这在多人协作时会造成混乱。解决办法是在项目初期就把前缀清单写进 README并且用检查工具强制。清单不用长五六个前缀足够覆盖大多数场景。我通常建议的前缀集是db_数据访问、svc_业务逻辑、api_对外接口、util_工具、cfg_配置。超过这个数量说明模块划分可能有问题。4.2 坑二单一出口变成“大杂烩出口”单一出口原则的初衷是控制依赖但如果入口文件不加节制地导出所有东西就变成了“什么都能从这拿”约束名存实亡。我见过一个模块的index.js导出了四十多个函数外部代码想用什么都能找到结果模块内部稍微改一下实现外部就崩一片。这违背了“入口即契约”的初衷。正确的做法是入口文件只导出“该模块对外承诺的能力”内部工具函数一律不导出。判断标准很简单如果一个函数只有模块内部用就不该出现在入口文件里。我在 code review 时会专门检查入口文件的导出列表问一句“这个函数外部真的需要吗”。十次有八次答案是“其实不需要只是顺手导出了”。删掉这些多余导出后模块的对外接口从四十个降到八个重构时的心理负担小了很多。4.3 坑三错误包装丢失原始堆栈错误不跨层的约定在执行时最容易犯的错误是包装时把原始错误丢掉了。比如数据层捕获了数据库异常转成DbError时只写了“数据库操作失败”没有把原始异常作为cause传进去。这样上层排查问题时只能看到“数据库操作失败”不知道是连接超时还是唯一键冲突。这个问题在线上排查时特别致命因为日志里没有足够信息定位根因。我的做法是强制要求包装错误时必须保留 cause。在 JavaScript 里可以用new DbError(..., { cause: originalError })Python 里可以用raise DbError(...) from original_error。然后在日志输出时把 cause 链完整打印出来。这样对外返回的错误信息是干净的但日志里有完整上下文。我还会在错误类型定义里加一个toLog()方法专门用于日志输出和toResponse()方法分开避免把内部细节泄露给调用方。4.4 坑四检查工具太严导致开发者绕过最后一个坑是检查规则过于严格开发者为了赶进度直接禁用规则。比如禁止跨模块导入的规则如果配置成“任何非入口文件导入都报错”那开发者在需要引用一个类型定义时也会被拦住最后只能在文件顶部加一行// eslint-disable-next-line。规则被禁用的次数多了就形同虚设。平衡的方法是给规则留合理的例外通道。比如类型定义文件、常量文件可以跨模块直接引用因为它们不包含逻辑不会造成耦合。在 ESLint 配置里用allowImportNames或者路径白名单来放行。另一个技巧是把规则分成“错误”和“警告”两级命名前缀用错误级别导入路径先用警告级别跑两周收集反馈后再升级。我在一个团队推行时第一周只开警告第二周统计发现违规集中在两个模块针对性做了重构后才升级为错误整个过程没有引起抵触。5. 和现有工具链的配合方式5.1 与 TypeScript 的结合用类型系统强化约定t3code 的约定是语言无关的但如果项目用 TypeScript可以用类型系统把约定“编译期化”。比如模块入口文件导出的函数可以用export type UserModule { getUser: ...; createUser: ... }定义成一个接口外部代码只能通过这个接口类型来引用。这样即使有人绕过了 ESLint 检查TypeScript 编译器也会报错。类型系统的好处是反馈更快——不用等提交时才发现问题写代码时编辑器就标红了。另一个结合点是用 branded type 区分层级。比如type DbError Error { __brand: db }type SvcError Error { __brand: svc }。这样在业务层捕获错误时TypeScript 能帮你确认拿到的是DbError还是SvcError避免错误类型混用。这个技巧我在一个金融项目里用过效果很好因为金融业务对错误分类要求极高不同类型错误对应不同的用户提示和重试策略。5.2 与测试框架的配合按模块边界组织测试t3code 的模块边界天然适合按模块组织测试。每个模块的测试文件放在模块目录下的__tests__文件夹里测试入口文件的导出函数不测试内部实现。这样做的好处是测试和实现解耦内部重构时只要入口契约不变测试就不用改。我见过太多项目因为测试直接引用了内部函数导致重构时测试大面积失败最后没人敢重构。具体做法是index.test.js只导入index.js的导出用 mock 替换掉repo层的实现。这样测试的是业务逻辑不依赖真实数据库。数据层的测试单独放在repo.test.js里用内存数据库或测试容器。两层测试分开跑业务逻辑测试可以毫秒级完成数据层测试才需要启动数据库。这种分层测试策略让 CI 时间从十分钟降到了两分钟因为大部分改动只影响业务逻辑不需要跑数据层测试。5.3 与代码生成工具的边界生成代码也要遵守约定如果项目里用了代码生成工具比如从数据库 schema 生成模型代码或从 API 定义生成客户端代码需要特别注意生成代码也要遵守 t3code 约定。我见过一个项目手写代码都遵守命名前缀但生成的模型代码是UserModel、OrderModel没有前缀结果在 service 里引用时分不清哪些是生成的、哪些是手写的。解决办法是在代码生成模板里加入前缀和目录约定。比如生成的模型代码统一放在generated/目录下函数名加gen_前缀并且不直接暴露给业务层而是通过 repo 层包装后再使用。这样业务层永远不直接依赖生成代码生成工具升级或更换时只需要改 repo 层的包装代码。这个隔离层看起来多了一层但换来的是生成工具的可替换性长期来看非常值得。6. 一个真实项目的改造记录与效果对比6.1 改造前的状态能跑但不敢动去年我接手了一个内部管理系统代码量大概三万行三个人写了两年。改造前的状态很典型目录结构是controllers/、services/、models/按技术分层但每个目录下的文件命名毫无规律。有的用驼峰有的用下划线有的用中划线。数据库查询散落在 controller 和 service 里事务边界靠开发者自觉。最要命的是没有任何测试改一个字段要手动点遍所有相关页面。我做的第一件事是统计“改一个功能平均要动几个文件”。随机选了五个需求结果是平均 4.2 个文件最多的一个改了 7 个文件。这个数字说明耦合度很高因为一个功能的变化应该尽量局限在一个模块内。另一个指标是“新人上手时间”当时新来的同事花了两周才敢提交第一个 PR而且第一版代码被 review 出十几个问题大部分是“这个函数该放哪”和“命名不符合现有风格”。6.2 改造过程增量推进不搞大爆炸我没有选择重写而是按模块增量改造。第一步是画出模块边界把系统拆成“用户”“权限”“报表”“通知”四个模块。然后从“通知”模块开始改造因为它依赖最少、影响面最小。改造内容包括把散落的通知逻辑集中到notification模块加index.js作为唯一出口函数加svc_和db_前缀错误统一包装。整个过程用了三天改完后通知模块的代码从 1200 行降到 800 行因为去掉了很多重复逻辑。第二步是加检查工具。我先只开了命名前缀的检查跑了一周收集违规最多的文件集中修复。然后开导入路径检查又跑一周。最后开错误类型检查。每一步都留出适应期避免一次性引入太多规则导致抵触。这个节奏很重要规则是手段不是目的。目的是让代码更好维护如果规则太严导致大家不敢写代码就本末倒置了。6.3 改造后的数据可量化的收益改造完成三个月后我重新统计了那几个指标。“改一个功能平均动几个文件”从 4.2 降到了 1.8因为大部分改动局限在单个模块内。“新人上手时间”从两周降到了三天因为目录结构和命名规则让新人能自己找到该改哪里。代码 review 的评论数量下降了六成剩下的评论更多是关于业务逻辑的讨论而不是“这个函数该放哪”。还有一个意外收益是测试覆盖率上来了。因为模块边界清晰写单元测试变得容易大家愿意写。改造前测试覆盖率不到 10%改造后到了 45%。虽然不算高但关键模块的核心逻辑都有了测试重构时心里有底了。这个收益不在预期内但恰恰说明了一个道理好的结构会自然引导出好的实践不需要靠行政命令强推。7. 关于 t3code 的几个常见疑问7.1 小项目也需要这么麻烦吗经常有人问我就写个几百行的小工具也要搞模块和前缀吗我的回答是看这个工具的生命周期。如果是一次性脚本跑完就扔那完全不需要。但如果是会长期维护、会加功能的小工具哪怕只有几百行也值得花十分钟把结构理一下。我自己的习惯是任何预计会活过一周的代码都按 t3code 的最小集来组织——至少分index和impl两个文件函数加前缀。这个投入很小但后续加功能时省下的时间远超投入。另一个相关问题是“团队只有两个人需要约定吗”。我的经验是两个人比十个人更需要约定。因为人少的时候沟通成本看似低但恰恰因为“反正就我们俩口头说就行”导致约定只存在于脑子里新人进来或者其中一人休假时就抓瞎。把约定写下来、用工具检查对两人团队来说反而是效率提升。7.2 和“整洁架构”“领域驱动设计”是什么关系t3code 和这些方法论不冲突但层次不同。整洁架构和 DDD 是架构层面的指导告诉你系统应该怎么分层、领域模型怎么建。t3code 是编码层面的约定告诉你代码写出来长什么样。你可以用整洁架构设计系统然后用 t3code 的命名和导出规则来写具体代码。两者是互补的。反过来说如果你已经用了 DDD那 t3code 的模块边界可以直接对应到限界上下文前缀可以对应到领域层、应用层、基础设施层。我见过一个 DDD 项目领域层函数用domain_前缀应用层用app_基础设施层用infra_效果很好。所以 t3code 不是替代品而是一个轻量的落地抓手。7.3 如果团队抵触怎么办推行任何规范都会遇到抵触这很正常。我的策略是先做试点用数据说话。选一个大家公认“最乱”的模块按 t3code 改造然后对比改造前后的维护成本。数据摆出来之后抵触会小很多。另一个技巧是让抵触者参与规则制定。比如有人觉得db_前缀不好看想用repo_那就让他说出理由如果合理就采纳。规则是大家一起定的执行意愿会高很多。最忌讳的做法是“从上往下强推”。我见过一个 tech lead 直接发邮件说“从下周起所有代码必须加前缀”结果一周后代码里全是// eslint-disable。规范要落地必须让开发者感受到“这样做对我有好处”而不是“又多了一个限制”。这个转变需要时间但一旦跨过去后面就顺了。8. 我个人的几条实操心得第一条心得是从最小的规则开始。不要一上来就搞全套先选一条最容易执行的比如命名前缀跑两周。等大家习惯了再加第二条。我试过一次性推五条规则结果一周后全部被禁用。后来改成一次一条反而都落地了。第二条心得是检查工具的提示信息要写人话。不要只报“no-restricted-imports”要写“检测到从 user 模块的非入口文件导入请改为从 user/index.js 导入或将该函数加入入口文件导出”。好的提示能减少一半的沟通成本。第三条心得是定期回顾规则本身。每季度花半小时看看哪些规则经常被禁用如果某条规则禁用率超过 20%说明它可能不合理需要调整。规则是为人服务的不是人为规则服务。第四条心得是把约定写进新人 onboarding 文档。新人第一天就告诉他“我们这样写代码”比等他写了三个月再纠正要容易得多。文档不用长一页纸说清楚前缀清单、目录结构、检查工具怎么跑就够了。最后一条心得是自己先做到。如果你要求别人加前缀自己的代码却随手写那规则就失去了威信。我在项目里会刻意让自己的 PR 成为范本新人看几个 PR 就知道该怎么写了。这比任何文档都有效。