我为什么为个人项目选择这套 Java 后端栈:jOOQ-first,Hibernate-local
我最终还是给自己的个人项目确定了一套 Java 后端技术栈。
但这并不是一份“现在 Java 圈流行什么,我就把什么装进去”的依赖清单,也不是为了证明个人项目可以拥有和大公司一样长的架构图。
我的主要背景仍然是 TypeScript/JavaScript。我也使用 Go 和 Python。面对大多数普通 API、轻量服务、自动化工具和边缘逻辑,我完全可以继续使用这些语言。Java 如果只是换一种语法重新写 CRUD,对我没有价值;它必须在事务、关系数据、复杂查询、长期维护和系统稳定性上,给出足以抵消自身重量的收益。
所以这篇文章真正要回答的不是“Java 后端能用哪些技术”,而是:
一个偏爱显式、直观、低魔法的 TypeScript/Go 开发者,怎样在不继承整套传统企业 Java 习惯的前提下,建立一套自己愿意长期维护的 Java 后端?
我的答案是:
Java 25 + Spring Boot 4.1 + PostgreSQL + Flyway + jOOQ。
第一版只使用 jOOQ;Hibernate 只有在真实聚合写入证明其价值后,才允许局部进入。
我把这条数据访问策略概括为:
jOOQ-first,Hibernate-local。
如果要写得更严格一点,则是:
jOOQ-first,Hibernate-on-proof。
这不是一套公司级超大型系统的缩小版
先说明项目边界。
我想构建的是一个由我独立开发、最大预计为中等规模、需要运行和维护很多年的后端服务。它可能有几十张表、复杂筛选、全文搜索、统计报表、后台任务和明确的业务状态,但它没有几十个团队,也没有必须独立扩缩容的数百个服务。
因此,我不会先假定自己需要:
- 微服务;
- CQRS;
- Event Sourcing;
- Kafka 或其他消息总线;
- Redis;
- Kubernetes;
- Spring Cloud;
- WebFlux;
- 六边形架构模板;
- 每个操作一个 Command、Handler 和十层接口;
- 为“将来可能替换数据库”而抹平 PostgreSQL 的能力。
这些技术并不天然错误。错误的是在问题还没有出现时,就提前支付它们的概念成本、代码成本、部署成本和调试成本。
一个人的项目最稀缺的资源不是服务器,也不是框架能力,而是注意力。每多加入一个技术,我都要长期理解它的版本、边界、失败模式、升级路径和与其他组件的相互作用。依赖不会因为写在 pom.xml 里就变成免费的。
我的技术审美不是“代码越少越好”
我喜欢简洁,但我并不把“少写几行代码”当作最高目标。
真正重要的是:当系统出错、升级或者半年后重新打开时,我能不能快速回答下面这些问题:
- 一次请求从哪里进入?
- 数据在哪一层被验证?
- 哪段代码开启了事务?
- 最终执行了什么 SQL?
- 数据库 schema 由谁定义?
- 某个字段为什么存在?
- 这项抽象解决了什么真实问题?
- 不打开某个特定 IDE,我是否仍然能构建、测试和理解项目?
因此,我的“低魔法”不是拒绝所有框架,而是拒绝无法被解释、无法被约束、无法被替换的隐式行为。
我接受 Spring Boot 的自动配置,因为它解决了依赖装配、生命周期和默认配置的问题,而且这些行为有明确文档、可以覆盖、可以通过日志和 Actuator 检查。我接受 jOOQ 的代码生成,因为它把真实数据库 schema 提升为可编译的 Java 类型。我也可能接受 Hibernate 的 dirty checking,但前提是它只服务于一个清楚的聚合写入边界,而不是接管整个系统的数据模型。
我的判断标准可以概括成几条。
复杂度必须支付租金
一项技术不能只提供“以后也许有用”的可能性。它必须在当前项目中减少错误、减少重复、提高表达能力,或者显著降低长期维护成本。
数据库不是实现细节
如果系统的核心价值来自关系、约束、查询、索引、事务和报表,那么数据库就是架构的一部分。为了假装数据库可以随时被替换,而故意不使用 PostgreSQL 的能力,对我来说不是解耦,而是主动削弱系统。
IDE 是加速器,不是隐形运行时
VS Code 可以提供补全、导航、调试和重构,但项目不能依赖 IntelliJ IDEA 里的某个菜单、数据库窗口或生成配置才能运行。
所有关键流程都必须能从命令行完成。IDE 可以调用命令,但不能成为唯一知道这些命令的人。
目录结构应该表达业务,而不是框架
我不想打开项目后先看到:
1 | controller/ |
这种结构在文件少时看起来整齐,规模增长后却把同一个功能拆散到整个仓库。
我更希望看到:
1 | catalog/ |
框架是实现手段,业务 feature 才是维护单位。
默认方案只是当前最好的假设
技术栈不是宗教,也不是身份。某项技术一旦不再产生价值,就应该被移除。所谓“最终选择”,指的是第一版明确采用的默认路径,而不是未来永远不能变化的誓言。
最终的 Java 技术栈
我的第一版会使用下面这套组合:
| 领域 | 选择 | 它在项目中的职责 |
|---|---|---|
| 语言与运行时 | Java 25 LTS | 长期维护的运行时、现代 Java 语言能力、成熟 JVM 工具链 |
| 应用框架 | Spring Boot 4.1.x | 依赖管理、配置、应用生命周期和各组件的集成底座 |
| Web | Spring MVC | 清楚的同步请求模型,适合 JDBC/jOOQ 和普通业务后端 |
| 参数验证 | Jakarta Validation | 在系统入口声明输入约束 |
| JSON | Jackson | HTTP JSON 序列化与反序列化 |
| HTTP Client | RestClient | 调用外部 HTTP API 的同步、流式接口 |
| 构建 | Maven Wrapper | 统一构建入口,不要求宿主机安装 Maven |
| 数据库 | PostgreSQL | 关系数据、事务、约束、索引、搜索和数据库特有能力 |
| Schema 演进 | Flyway | 数据库结构的唯一历史和唯一权威来源 |
| 数据访问 | jOOQ | 默认查询与写入方式,类型化表达 SQL |
| ORM | 第一版不加入 | Hibernate 只在真实聚合写入证明价值后局部加入 |
| 测试 | JUnit Jupiter + AssertJ | 单元测试和可读断言 |
| 集成测试 | Testcontainers + PostgreSQL | 使用真实 PostgreSQL 验证迁移、SQL 和事务行为 |
| 运行状态 | Actuator + Micrometer | 基础健康检查、指标和诊断入口 |
| 开发环境 | Dev Container + Docker | 可复现、与宿主机解耦的开发环境 |
| CI | GitHub Actions | 使用与本地相同的 Maven Wrapper 命令验证项目 |
| 架构 | Feature-first modular monolith | 以业务功能组织的单体应用,保留清楚模块边界 |
这张表最重要的部分不是技术数量,而是每个组件只有一个明确职责。
Java 25:我需要的是长期基线,不是语法竞赛
选择 Java 25,首先是因为它是一条适合新项目的长期支持基线。
我并不需要为了使用 Java 而回到十年前的 Java 写法。现代 Java 已经有 record、sealed class、改进后的 switch、模式匹配、更好的并发工具和持续改善的运行时。它仍然不像 TypeScript 或 C# 那样轻盈,但已经足以让我用更少的仪式表达 DTO、值对象和有限状态。
不过,我选择 Java 25 的核心原因并不是某个单独语法特性,而是这些更基础的东西:
- JVM 的长期稳定性;
- 成熟的性能分析和诊断能力;
- 大量经过生产验证的数据库、HTTP、测试和可观测性库;
- 对长期运行服务非常清楚的升级路线;
- 静态类型和编译期反馈对中等规模业务模型的保护。
我也不会因为 Java 25 支持更多并发能力,就在第一版主动使用所有新特性。虚拟线程、复杂并发模型或者自定义调度策略,应该由真实吞吐和阻塞问题触发,而不是因为版本号已经允许。
版本选择的目的,是给项目一个现代而稳定的地基,不是制造“我已经使用了最新 Java”的展示项目。
Spring Boot 4.1:把它当作集成底座,而不是世界观
Spring Boot 是这套方案里最容易引起矛盾感的部分。
一方面,我不喜欢大量注解、隐式扫描和历史上常见的企业 Java 样板;另一方面,Spring Boot 确实解决了一组非常实际的问题:
- 统一依赖版本;
- 启动和关闭应用;
- 读取配置;
- 装配 Web、数据库、验证、日志和监控;
- 管理环境差异;
- 提供成熟的测试与生产运行路径。
如果不用 Spring Boot,我仍然需要自己选择并连接这些部件。那不一定更显式,只可能把成熟框架已经解决的问题重新手写一遍。
所以我的态度不是“Spring 很优雅”,也不是“Spring 太重所以完全不用”,而是:
只让 Spring Boot 负责它真正擅长的集成工作,不让 Spring 的习惯替我决定整个代码结构。
具体来说:
- 允许 constructor injection,不使用字段注入;
- 配置使用明确的 typed configuration,而不是到处读取字符串;
- 不为每个类机械地添加接口;
- 不把所有业务逻辑塞进带注解的 service;
- 不因为存在 Spring Data,就默认所有数据访问都必须是 Repository;
- 不让 framework annotation 成为领域模型的主要语言;
- 不让 Spring package 分层覆盖 feature 边界。
Spring Boot 在我的项目里是 chassis,不是 architecture。
Spring MVC:同步模型已经足够,而且更诚实
我的主要数据访问方式是 PostgreSQL + JDBC + jOOQ。它们的核心执行模型是同步、阻塞的。
在这种前提下使用 Spring WebFlux,往往意味着为了一个并不存在的全链路响应式系统,引入 Reactor、Mono、Flux、新的错误传播方式、新的调试方式和更多线程语义。最后数据库调用仍然阻塞,复杂度却已经进入了每个 endpoint。
这不是我需要的能力。
Spring MVC 的请求模型更符合这个项目:
1 | HTTP request |
它的好处不是“老”,而是因果关系直接。一次请求对应一次清楚的调用链,异常可以沿普通 Java 堆栈传播,数据库事务也不需要跨越响应式上下文。
如果未来出现经过测量的并发瓶颈,我可以评估虚拟线程、连接池、查询优化或者异步任务。第一反应不会是把整个系统改写成响应式编程。
Jakarta Validation:把入口约束写在入口
输入验证属于真正有价值的声明式能力。
例如,一个创建内容的请求可以直接表达:
1 | public record CreatePostRequest( |
这些约束不是领域规则的全部,但它们适合处理 HTTP 边界上的结构问题:必填、长度、格式、集合大小。
我不会把所有业务规则都塞进 annotation。比如“只有已发布的合集才能被公开引用”涉及数据库状态和业务语义,应该由明确的 application/domain code 判断。
验证框架的职责是尽早拒绝无效输入,而不是把业务逻辑变成元数据谜题。
Jackson:使用默认主路,不建立自己的 JSON 世界
Spring Boot 已经把 Jackson 作为成熟的 JSON 处理主路。对普通 REST API 来说,它足够稳定,也有广泛的生态支持。
我的原则是:
- API request/response 使用专门的 DTO;
- 不直接序列化 jOOQ generated record;
- 如果未来加入 Hibernate,也不直接序列化 entity;
- 对日期、枚举和 nullable 字段建立清楚约定;
- 只在必要时写自定义 serializer/deserializer;
- 不为了“统一”创建一层没有业务价值的通用 mapper 框架。
JSON 是外部契约,数据库 record 和持久化 entity 是内部实现。它们不应该因为省几行代码而被绑在一起。
RestClient:外部 HTTP 调用也保持同步和清楚
这套应用使用 Spring MVC,因此调用少量外部 HTTP API 时,我会选择 RestClient。
它提供流式 API,但仍然保持普通同步代码的可读性。对个人项目常见的 webhook、第三方元数据、邮件或外部内容接口,这比为了几个调用引入整套响应式客户端更合适。
我会把外部系统边界写成具体 client,例如:
1 | GitHubClient |
而不是建立一个“万能 HTTP service”。
每个 client 负责:
- base URL;
- authentication;
- request/response model;
- timeout;
- 错误转换;
- 与外部协议有关的重试策略。
这样故障发生时,我知道失败属于哪个外部边界,而不是在一个通用工具类里寻找字符串 URL。
Maven Wrapper:构建必须脱离我的机器记忆
我会使用 Maven,而不是因为它最现代,而是因为它的生命周期、插件模型和命令入口足够明确,特别适合把 Flyway、jOOQ code generation、编译和测试串成一条可复现流程。
真正关键的是 Maven Wrapper:
1 | ./mvnw clean verify |
宿主机不需要安装 Maven,也不需要知道项目使用哪个 Maven 版本。Dev Container、CI 和我自己的终端都调用同一个 wrapper。
我会坚持几个构建规则:
- 第一版只使用一个 Maven module;
- 所有代码生成必须能从 Maven lifecycle 或明确的 Maven profile 触发;
- 不依赖 IntelliJ 的 Run Configuration 才能完成迁移或生成代码;
./mvnw clean verify必须是最终可信入口;- CI 不重复发明另一套构建脚本;
pom.xml只承载真实构建需求,不把所有可能用到的插件提前装上。
多 module 并不等于 modular monolith。模块边界首先应该在 package、依赖方向和业务职责里成立。只有当编译边界、发布边界或者构建速度真的需要时,才值得拆 Maven module。
PostgreSQL:不是一个可以随时替换的黑盒
我选择 PostgreSQL,不只是因为它“流行”,而是因为它能覆盖这类长期个人系统真正需要的能力:
- 可靠事务;
- 外键、唯一约束和 check constraint;
- 丰富索引;
- JSONB;
- 数组与 range;
- full-text search;
- CTE、窗口函数和复杂聚合;
RETURNING;- advisory lock;
- 成熟的备份、恢复和监控工具。
我不会为了维持一个虚假的“数据库无关性”,把这些能力全部隐藏在最低公分母后面。
所谓数据库可维护性,不是保证明天可以无痛把 PostgreSQL 换成 MySQL,而是保证:
- schema 有版本历史;
- SQL 可以被读懂和测试;
- 索引有依据;
- 约束尽可能靠近数据;
- 数据库升级有演练;
- 备份可以真实恢复。
如果一个系统高度依赖关系数据,那么承认数据库的重要性,比假装它只是 repository 后面的实现细节更诚实。
Flyway:数据库结构的唯一权威来源
数据库 schema 必须只有一个 owner。
在这套方案里,这个 owner 是 Flyway migration。
1 | V001__create_users.sql |
每一次结构变化都通过版本化 SQL 记录。它的目的不是追求“SQL-first”标签,而是让我能够回答:
- 生产数据库为什么是现在这个形状?
- 一个字段何时加入?
- 某个索引为了解决什么问题?
- 从空数据库能否重建当前 schema?
- 升级失败时具体执行到了哪里?
Flyway 也意味着其他工具不能同时修改 schema。
如果未来加入 Hibernate:
1 | spring.jpa.hibernate.ddl-auto=validate |
Hibernate 可以验证 mapping,但不能 create 或 update 数据库。jOOQ codegen 从 Flyway 建立的真实 schema 生成代码,测试环境同样先执行 migration。
一套数据库,不能存在两个互相竞争的历史版本系统。
jOOQ:让 SQL 成为类型化的一等语言
jOOQ 是这套 Java 栈的中心选择。
我不想把 SQL 隐藏起来,也不想让 SQL 退化成散落在 annotation、XML 和字符串里的文本。jOOQ 的价值在于,它既承认关系模型和 SQL 的表达能力,又把表、列、类型和查询结构带进 Java 编译器。
一个普通查询可以保持接近 SQL 的形状:
1 | public List<PostSummary> findPublishedPosts(String keyword, int limit) { |
这段代码没有假装查询是对象导航。它明确展示了:
- 选择哪些字段;
- 从哪些表读取;
- 怎样 join;
- 过滤条件是什么;
- 排序和限制是什么;
- 最终映射成什么读模型。
jOOQ code generation 的意义
jOOQ 根据真实 schema 生成 table、column 和数据类型。这样,当 migration 删除列、修改类型或者重命名表后,重新生成代码会把受影响的调用点暴露给编译器。
它不是绝对的静态证明,但比字符串 SQL 更早、更系统地发现问题。
我的预期流程是:
1 | Flyway migrations |
底层可以通过开发容器和 CI 中的 PostgreSQL service 完成,但对开发者暴露的入口必须仍然是 Maven Wrapper。
为什么不是 JdbcTemplate
JdbcTemplate 足够显式,也很可靠,但大量查询最终仍然是字符串 SQL、参数和手工 row mapping。对于复杂筛选和可组合查询,它缺少 jOOQ 的结构化 DSL 与 schema 类型。
为什么不是 MyBatis
MyBatis 能让 SQL 保持可见,但它通常把 SQL 放在 XML、annotation 或字符串中,动态组合依赖额外模板语义,字段和映射错误也更容易推迟到运行时。
这并不意味着 MyBatis 不能构建优秀系统。它只是没有解决我最关心的问题:如何同时保留 SQL 的表达力,并让编译器理解更多数据库结构。
为什么不默认使用 Spring Data JPA
Spring Data JPA 最擅长围绕 entity repository 提供便利。我的主要需求却包括查询、筛选、报表、聚合和 PostgreSQL 特有能力。把这些问题全部塞回 entity graph、method-name query、Specification 或 JPQL,会让我离真实 SQL 越来越远。
我宁愿先选择与主要问题形状一致的工具。
jOOQ-first 不等于所有代码都写成 SQL
选择 jOOQ,不代表 application layer 消失,也不代表 controller 直接拼查询。
我仍然会区分:
1 | HTTP contract |
例如:
1 |
|
这里的业务意图仍然明确:只有 draft、版本一致时才能发布。数据库 update count 或 RETURNING 结果直接承担并发判断,不需要为了“面向对象”先加载整棵 entity graph。
SQL 是实现业务规则的一种语言,但它不会取代业务边界本身。
Hibernate-local:允许,但不预装
我并不认为 Hibernate 是一个没有品味或者没有价值的工具。
Hibernate 真正擅长的是对象状态管理:
- persistence context;
- identity map;
- dirty checking;
- relationship management;
- optimistic locking;
- 以聚合为单位的加载、修改和提交。
如果某个 feature 的写入逻辑确实是:
1 | 加载一个聚合 |
那么 Hibernate 可能比手工维护多条 update 更清楚。
但“可能以后会有复杂业务”不足以成为第一版依赖。
第一版为什么不加入 Hibernate
一旦 Hibernate 进入项目,我就必须同时维护:
- jOOQ generated model;
- Hibernate entity model;
- mapping;
- persistence context;
- flush 时机;
- lazy/eager loading;
- jOOQ 与 Hibernate 在同一事务内的可见性;
- 两种数据访问方式的选择规则。
如果当前写入只是单表 insert/update、批量操作或者显式状态转换,这些成本没有回报。
因此第一版是 jOOQ-only。Hibernate-local 是未来允许采用的边界政策,不是第一天就安装两套持久化框架。
只有这些证据能让 Hibernate 进入
我会要求至少满足下面几项:
- 某个 feature 已经出现真实、稳定的聚合边界;
- 一次写入需要协调多个对象状态;
- 手写更新逻辑明显开始重复或容易遗漏;
- optimistic locking 与 persistence context 能显著降低错误;
- Hibernate 可以被限制在该 feature 内;
- 加入后整体代码不仅更短,而且更容易解释和测试。
Hibernate 一旦加入,必须遵守的边界
- Flyway 仍然是唯一 schema authority;
ddl-auto=validate;open-in-view=false;- Hibernate 不负责列表、搜索、报表和复杂 projection;
- entity 不跨 feature 共享;
- 不建立全系统可导航的双向 entity graph;
- 不把
JpaRepository暴露成 application API; - 不为了统一而把 jOOQ query 包装成 entity repository;
- 尽量避免在同一个事务里交替操作 jOOQ 和未 flush 的 Hibernate entity;
- 需要混用时显式控制 flush 和执行顺序。
这就是我说的 Hibernate-local:不是两个工具平分项目,而是 jOOQ 默认拥有数据访问,Hibernate 只租用一小块被证明适合它的写模型。
事务边界:业务操作拥有事务,不是 repository 方法拥有事务
事务应该围绕完整业务操作,而不是每次数据库调用。
例如“发布文章并记录审核日志”是一个事务;“查询首页列表”通常不是一个需要显式写事务的复杂流程。
我会把 @Transactional 放在清楚的 application operation 上,而不是到处散布:
1 | PublishPostService.publish(...) |
这样可以直接回答:一次业务动作的原子边界在哪里。
我不会创建一个通用 TransactionManagerHelper,也不会让 controller 自己管理 commit/rollback。框架负责事务机制,业务服务声明事务范围。
JUnit Jupiter + AssertJ:测试应该描述行为,而不是展示 mock 技巧
JUnit Jupiter 是稳定、直接的 Java 测试基础。AssertJ 则让断言更接近自然语言,特别适合集合、异常和结构化结果。
我会把测试分成两类。
快速测试
不启动 Spring context,直接测试:
- 值对象;
- 纯业务规则;
- 状态转换;
- mapper;
- parser;
- SQL 之外的组合逻辑。
例如:
1 |
|
真实数据库集成测试
使用 PostgreSQL Testcontainers 测试:
- Flyway migration 能否从零执行;
- jOOQ 查询是否返回正确结果;
- PostgreSQL 特有表达式;
- constraint;
- transaction rollback;
- optimistic concurrency;
- 索引相关的关键查询计划,必要时单独验证。
我不会用 H2 模拟 PostgreSQL。
H2 可以快速,但当项目依赖 PostgreSQL 方言、JSONB、full-text search、数组、range、RETURNING 或真实锁行为时,它测试的是另一个数据库。绿色测试无法证明生产查询成立。
Testcontainers 的成本是测试需要 Docker,回报则是本地和 CI 对着同一种数据库说话。对我的 Dev Container 工作流,这个交换非常合理。
Actuator + Micrometer:只建立足够的运行可见性
一个长期运行的服务至少应该知道自己是否健康,以及关键资源是否正常。
第一版我会使用 Actuator 和 Micrometer,但范围很克制:
- health;
- readiness/liveness,确有部署需求时启用;
- JVM 基础指标;
- HTTP 请求指标;
- 数据库连接池指标;
- 少量真正重要的业务计数器;
- build/version 信息。
我不会因为加入 Micrometer,就立刻搭建 Prometheus、Grafana、Tempo、告警平台和完整 tracing pipeline。
可观测性应该从“我需要回答哪些运行问题”开始,而不是从“生态里有哪些 dashboard”开始。
例如,比起记录几十个没有人看的指标,我更关心:
- 最近一次后台导入是否成功;
- 数据库连接是否耗尽;
- 某个外部 API 是否持续失败;
- 一类关键写入冲突是否突然增加;
- 备份上一次成功时间是什么。
工具只负责暴露信号,信号必须对应真实维护动作。
Dev Container:宿主机只负责编辑和运行容器
我的标准开发环境是 Dev Container。
宿主机只需要:
- Git;
- Docker;
- VS Code;
- Dev Containers extension。
JDK、Maven、PostgreSQL client 和项目工具链都在容器里。
这不是为了追求“容器化开发”的标签,而是为了解决几个长期问题:
- 新机器可以快速恢复环境;
- 不同项目不争夺全局 Java/Maven 版本;
- CI 与本地环境更接近;
- 文档不需要依赖我脑中的安装历史;
- 宿主机可以保持干净;
- VS Code 仍然只是前端,不是项目构建真相。
Dev Container 也必须保持简单。第一版通常只需要:
1 | app development container |
我不会在开发环境里复制一套迷你生产集群。
Docker:封装运行边界,不用来假装分布式系统
Docker 在这里有三个用途:
- 提供可复现开发环境;
- 为集成测试启动真实依赖;
- 构建可部署的应用镜像。
它不意味着每个内部 feature 都应该变成一个 container。
第一版部署很可能只是:
1 | Java application |
这已经是一套完整系统。把一个 modular monolith 拆成五个镜像,并不会自动增加架构质量,只会增加网络、配置、部署和故障面。
GitHub Actions:CI 只重复本地已经成立的命令
CI 不应该拥有一套只有云端知道的流程。
GitHub Actions 的核心任务是:
1 | ./mvnw clean verify |
必要时,它会启动 PostgreSQL service、执行 Flyway、运行 jOOQ codegen、编译、测试并构建镜像。但这些步骤都必须对应本地可执行的入口。
我希望 CI 检查:
- wrapper 可用;
- migration 能从空数据库执行;
- generated sources 与 schema 一致;
- 编译通过;
- unit/integration tests 通过;
- 应用镜像能构建;
- repository 没有依赖本地 IDE 状态。
CI 是可复现性的验证者,不是隐藏脚本的收容所。
Feature-first modular monolith:以功能为维护单位
我的项目不会先拆微服务,也不会先建立四个 Maven module 模拟 Clean Architecture。
我会从一个进程、一个部署单元、一个 PostgreSQL 数据库开始,但内部按 feature 组织:
1 | src/main/java/com/koharu/archive/ |
这个例子不是固定模板。简单 feature 可能只有 endpoint、query 和 response;复杂 feature 才需要 application service 或更明确的 domain model。
我不会要求每个 feature 都拥有同样数量的层。
modular 的含义
这里的 modular 指:
- feature 有明确职责;
- 默认不读取其他 feature 的内部表模型;
- 跨 feature 调用经过少量公开 operation;
- 依赖方向可说明;
- 共享代码足够小;
- 未来真有需要时,某些边界具备拆分可能。
它不意味着我要预先建立远程接口、消息协议和 distributed tracing。
monolith 的价值
对单人项目,单体意味着:
- 本地调试是一条堆栈;
- transaction 可以覆盖一个业务操作;
- 部署和备份更直接;
- 版本只有一套;
- 重构可以跨 feature 完成;
- 不需要为内部调用支付网络失败和序列化成本。
这不是“还没成长到微服务”,而是当前问题下更优的架构。
一次请求应该如何流动
以创建一个收藏条目为例,我希望调用链大致是:
1 | POST /collections/{id}/items |
每一步都有清楚职责:
- endpoint 处理 HTTP;
- validation 处理结构约束;
- application service 处理业务动作和事务;
- jOOQ 处理数据库操作;
- DTO 定义外部契约。
我不需要在中间强制加入:
1 | Command |
当某个中间层没有自己的决策和边界时,它只是把调用链拉长。
第一版明确不加入什么
这份“负面清单”与技术栈本身同样重要。
不加入 Spring Data JPA 和 Hibernate
原因不是永远反对 ORM,而是当前没有证据证明第二套持久化模型值得存在。
不加入 WebFlux 和 Reactor
当前链路是同步 JDBC。响应式抽象不会免费产生吞吐,只会先增加认知负担。
不加入 Spring Cloud
没有服务发现、配置中心、熔断器矩阵和微服务基础设施需求。
不加入 Spring Modulith
feature-first package 已经足够表达第一版边界。等到真的需要自动验证模块依赖或模块事件时再评估。
不加入 Lombok
现代 Java 已经可以用 record、constructor 和 IDE 生成处理大部分样板。我不希望源码语义依赖额外 annotation processing,尤其不使用会模糊 equality、mutability 和 inheritance 的通用 @Data。
不加入 MapStruct
映射数量还没有大到值得引入第二套生成规则。简单显式 constructor mapping 更容易读。如果未来重复映射成为真实问题,再用数据证明。
不加入通用 Repository、BaseService、BaseEntity
不同 feature 的查询和业务动作并不相同。通用 CRUD 抽象会把最有价值的数据库能力压缩成 save/find/delete,然后在每个复杂场景旁边开逃生口。
不加入 CQRS 框架
我会自然地区分读模型和写操作,但不需要 Command Bus、Query Bus 和每个请求一个 handler。概念上的读写分离不等于必须购买整套框架结构。
不加入消息队列、Outbox 和 Event Sourcing
第一版没有跨服务可靠消息问题。应用内同步调用和数据库事务足够。
不加入 Redis
在真实性能数据出现前,PostgreSQL、正确索引和进程内短期缓存已经足够。缓存会带来失效、同步和观测问题。
不加入 Elasticsearch/OpenSearch
PostgreSQL full-text search 先满足需求。只有搜索成为产品核心、相关性和规模超出 PostgreSQL 能力时再引入独立搜索系统。
不加入多 Maven module
package boundary 先成立。构建拆分由实际编译、依赖或发布问题触发。
不加入 GraalVM Native Image
这个项目不是冷启动极端敏感的函数平台。JVM 常驻服务的成熟性和调试体验更重要。
不加入 H2 测试数据库
生产使用 PostgreSQL,集成测试也使用 PostgreSQL。
刻意不做不是消极,而是在保护项目的注意力预算。
这套 Java 栈什么时候真正比 TypeScript 或 Go 更有价值
我不会把 Java 当作所有后端问题的默认答案。
如果项目只是:
- 几个 CRUD endpoint;
- 少量表;
- 没有复杂事务;
- 没有长期领域状态;
- 部署成本比业务复杂度更重要;
那么 TypeScript 或 Go 往往更直接。
Java 开始产生净收益的场景是:
- 数据关系和约束本身构成产品核心;
- 有大量组合筛选、统计和报表;
- 写入需要清楚的事务与并发控制;
- 业务状态会长期演进;
- 系统需要运行多年;
- 测试、监控、诊断和升级能力很重要;
- 类型系统能够保护不断增长的模型。
对我来说,Java 的意义不是“企业级”,而是在系统长期变复杂时仍然有很强的托底能力。jOOQ 则负责避免这种托底能力被过度抽象和历史习惯吞掉。
适合这套栈的个人项目
我不想再做一个只有用户、文章和评论的教程项目。真正适合这套 Java 栈的项目,应该让关系数据和查询能力成为核心价值。
例如,一个自托管的兴趣内容档案与社区系统:
- 游戏、书、视频、文章等内容条目;
- 自定义收藏集与层级标签;
- 评论、引用和关联关系;
- 多条件筛选;
- PostgreSQL full-text search;
- 时间线与状态历史;
- 数据导入、去重和合并;
- 统计与报表;
- 审核记录;
- 乐观并发;
- 可导出和可备份。
在这个项目里,jOOQ 不只是替代 ORM 的另一种写法,而是可以直接表达产品能力。Java 的类型系统、事务和长期运行生态也有足够空间证明自己的价值。
我会用什么规则防止项目重新变重
技术栈确定之后,真正困难的是持续拒绝不必要的增长。
我会保留几条维护规则:
新依赖必须写清楚替代了什么
“这个库很好用”不是理由。需要说明:
- 当前痛点;
- 不加它时的实现;
- 加入后的收益;
- 新增的维护面;
- 如何移除。
一个 feature 先用最少结构完成
不要预先复制完整模板。代码增长到出现重复和边界问题时,再提取结构。
查询先看最终 SQL
无论 DSL 多漂亮,数据库执行的仍然是 SQL。关键查询需要检查生成 SQL、索引和执行计划。
测试保护昂贵边界
优先测试迁移、权限、并发、恢复、复杂查询和曾经失败过的路径,而不是追求覆盖率百分比。
每次升级只解决一个维度
不要同时升级 Java、Spring Boot、jOOQ、PostgreSQL 并重构数据层。可回滚的小步变化比一次“现代化”更适合长期个人项目。
删除技术也是正常演进
如果 Hibernate 加入后没有产生价值,就移除;如果某个抽象只剩一个实现,就折叠;如果 dashboard 没有人看,就停止维护。
架构质量不以技术栈长度衡量。
最后的结论
我的 Java 方案最终不是一套“轻量 Spring”,也不是一套“拒绝所有框架的纯 SQL 后端”。
它是在两种能力之间做出的明确分工:
1 | Spring Boot |
这套方案承认 Java 的重量,也利用 Java 的托底能力;承认 Spring 的成熟,也拒绝让 Spring 习惯替项目决定一切;承认 ORM 的价值,也拒绝在没有对象状态管理需求时提前支付 ORM 成本。
我需要的不是“最像企业最佳实践”的 Java,而是一套我能解释、能从命令行重建、能在 VS Code 中维护、能清楚看到 SQL,并且几年后仍然愿意继续打开的 Java。
这就是我的选择:
Java 25 + Spring Boot 4.1 + PostgreSQL + Flyway + jOOQ-only v1。
长期原则是 jOOQ-first,Hibernate-local;执行原则是 Hibernate-on-proof。
下一篇我会写对应的 C#/.NET 方案,以及为什么在真正开始下一个个人项目时,我会先选择 C#,而不是这套 Java。
参考资料
以下是这套选择依赖的主要官方资料。具体版本应始终使用项目基线下的当前补丁版本。
- OpenJDK JDK 25
- Oracle Java SE Support Roadmap
- Spring Boot System Requirements
- Spring MVC Reference
- Spring REST Clients
- Apache Maven Wrapper
- Flyway Versioned Migrations
- jOOQ DSL API
- jOOQ Code Generation
- Hibernate ORM User Guide
- Testcontainers for Java: PostgreSQL
- Spring Boot Actuator Metrics
- VS Code Dev Containers
- GitHub Actions Documentation










