单实体单DAO vs 单实体多DAO的架构设计抉择咨询
我对“单实体单仓库”的通用实践有疑问:是不是必须为SQL数据库里的特定实体(比如表或列)创建对应的DAO类?这类DAO会包含很多方法,但不同客户端请求事件里,往往只用得到其中一个或一小部分,尤其是当部分业务逻辑通过数据库联表查询、子查询实现时,方法的使用会随事件变化。
以我开发的大富翁(Monopoly)游戏为例,我写了一个针对玩家资金列的PlayerMoneyDAO类,代码如下:
module.exports = class PlayerMoneyDAO { constructor() {} static async deductMoneyFromPlayer(amount, playerId, connection) { // 这个简单方法通常在应用服务器内完成更多逻辑时调用 try { await connection.execute(`UPDATE players SET Money = money - (?) WHERE id = (?) `, [amount, playerId]); } catch (e) { throw new Error("server problem with deducting a players money on the database"); } } static async addMoneyToPlayer(amount, playerId, connection) { try { await connection.execute(`UPDATE players SET Money = money + (?) WHERE id = (?) `, [amount, playerId]); } catch (e) { throw new Error("Failed to increase the money for the receiving player"); } } static async moneyAdjustmentToPayStationRent(connection) { // 这类逻辑我放在数据库端实现,所以查询会更复杂 try { await connection.execute(`UPDATE stations as stationLanded INNER JOIN board ON stationLanded.board_id = board.id INNER JOIN players as playerLanded ON playerLanded.board_id = board.id INNER JOIN game_logic ON playerLanded.id = game_logic.player_turn INNER JOIN players as playerOwner ON stationLanded.owner_id = playerOwner.id INNER JOIN stations_rent set playerOwner.money = CASE WHEN playerLanded.money >= stations_rent.rent_price THEN playerOwner.money + stations_rent.rent_price else playerOwner.money end, playerLanded.money = CASE WHEN playerLanded.money >= stations_rent.rent_price THEN playerLanded.money - stations_rent.rent_price else playerLanded.money end WHERE stations_rent.number_of_stations_owned = ( SELECT count(id) from stations WHERE owner_id = stationLanded.owner_id);`); } catch (e) { throw new Error("player money updates failed when landing on an owned station"); } } static async moneyAdjustmentToPayPropertyRent(connection) { try { await connection.execute(`UPDATE players AS playerLander INNER JOIN game_logic ON playerLander.id = game_logic.player_turn INNER JOIN board ON playerLander.board_id = board.id INNER JOIN properties ON properties.board_id = board.id INNER JOIN rent ON rent.property_id = properties.id INNER JOIN players as playerOwner ON properties.owner_id = playerOwner.id SET playerLander.money = CASE WHEN playerLander.money >= rent.rent_price THEN playerLander.money - rent.rent_price else playerLander.money end, playerOwner.money = CASE WHEN playerLander.money >= rent.rent_price THEN playerOwner.money + rent.rent_price else playerOwner.money end WHERE properties.rent_price_point = rent.rent_price_point_value;`); } catch (e) { throw new Error("player money updates failed when landing on an owned property"); } } static async checkIfUpdatesOccured(connection) { const result = await connection.execute("SELECT ROW_COUNT() AS affected_rows"); return result[0][0].affected_rows; } static async getMoneyOfPlayerWhomHasJustReceivedPayment(connection, id) { try { const result = await connection.execute(`SELECT money,piece FROM players WHERE id = (?)`, [id]); return result[0][0]; } catch (e) { throw new Error("couldnt get the updated money of the player that was paid money so no transaction has taken place"); } } static async getMoneyUpdatesAfterRentPaid(connection, tableName) { const result = await connection.execute(`SELECT piece,money FROM players WHERE id = ( SELECT player_turn FROM game_logic) UNION SELECT playerOwner.piece, playerOwner.money FROM ${tableName} INNER JOIN board ON board.id = ${tableName}.board_id INNER JOIN players ON players.board_id = board.id INNER JOIN game_logic ON players.id = game_logic.player_turn INNER JOIN players AS playerOwner WHERE ${tableName}.owner_id = playerOwner.id`); return result[0]; } };
这个DAO里的所有方法都针对同一个实体(玩家资金列),而且后续还要加更多方法。
我同时有依赖DAO的服务对象,上层中介类会整合各类服务对象的依赖,负责控制业务逻辑中调用服务类方法的时机。每个服务类方法调用时会访问内部依赖的DAO并执行其方法,这些服务对象依赖的DAO都遵循“单实体单仓库”原则,包含所有操作玩家表资金列的方法。
比如RentPaymentManager这个服务对象,它依赖PlayerMoneyDAO,但只用得到其中的moneyAdjustmentToPayPropertyRent和getMoneyUpdatesAfterRentPaid方法。这些服务对象都是事件特定的,不像DAO那样包含通用方法:有的服务对象只包含一个调用DAO单一方法的方法,有的则需要调用DAO的2-3个方法。如果有需要复用的服务方法,我会单独封装成类,通过继承或组合来复用。但目前每个事件特定的服务对象都会接收完整的PlayerMoneyDAO,实际却只用得上其中一部分方法。之前我试过在实例化服务对象时绑定所需的DAO方法,甚至创建只调用对应DAO方法的通用服务对象。
现在我纠结的点在于:
- 是应该针对每个事件创建不同的DAO仓库类?还是坚持单实体单仓库,让事件特定的服务对象通过自身方法访问依赖的DAO来获取所需功能?
- 比如让
RentPaymentManager不再接收完整的玩家资金仓库,而是用事件特定的DAO?但这样就变成了单实体多仓库,和我之前得到的建议相悖。 - 或者还是让
RentPaymentManager接收同一个DAO,这个DAO被所有事件特定的服务类共享?
综上,我是不是应该在服务层创建继承或组合体系,同时保持传入服务类的DAO为通用型?如果采用单实体多DAO的方式,就违背了“单实体单仓库”的建议,到底该怎么选?
解答
首先要明确:“单实体单仓库”是指导原则而非硬性规则,核心目的是让数据访问逻辑和业务逻辑解耦,同时保证实体相关的操作有统一的入口,避免重复代码。
1. 优先保留单实体单DAO,在服务层做封装
你的PlayerMoneyDAO本身是合理的——它把所有和玩家资金相关的数据库操作都集中在了一起,不管是简单的加减还是复杂的租金支付逻辑,本质上都是对玩家资金实体的操作。服务层只用到DAO的部分方法是完全正常的,这正是分层的意义:DAO负责所有数据操作的实现,服务层根据业务场景选择调用对应的方法。
如果觉得服务层依赖完整DAO有“冗余”感,可以在服务层做一层细粒度的封装:
- 比如针对租金支付场景,创建
RentMoneyService,它依赖PlayerMoneyDAO,只暴露和租金相关的方法(比如payPropertyRent、getRentPaymentResults),内部调用DAO对应的方法。 - 其他场景同理,比如创建
PlayerWalletService来封装简单的加减钱操作,供需要这类逻辑的服务调用。
这种方式既保留了单实体单DAO的统一性,又在服务层实现了业务场景的隔离,不会出现单实体多DAO的混乱。
2. 不要为每个事件拆分DAO
如果为每个事件创建单独的DAO,会导致以下问题:
- 代码重复:不同DAO可能会有相似的数据库操作逻辑,比如多个DAO都需要查询玩家资金,你得重复写SQL。
- 维护成本上升:后续如果要修改玩家资金的存储逻辑(比如改字段名),你得在多个DAO里同步修改,很容易遗漏。
- 违反单一职责:DAO的职责是封装实体的数据访问,而不是对应业务事件,拆分后DAO会变成业务逻辑的附属,失去了数据访问层的独立性。
3. 服务层的复用策略
你提到的“将复用的服务方法单独封装为类,通过继承或组合复用”是正确的方向。比如:
- 把通用的“资金变更校验”“结果查询”逻辑封装成
MoneyOperationHelper类,让各个服务类通过组合的方式复用这些逻辑。 - 事件特定的服务类(比如
RentPaymentManager)只专注于当前事件的流程控制,调用DAO和通用工具类的方法完成业务。
内容的提问来源于stack exchange,提问作者Kevin Greetham

