如何让 ORM Entity Class 满足 OCP
结论
要让 Payment 在新增信用卡等支付方式时保持不变,重点不在于继续添加 CashPayments、GiftCouponPayments、CreditCardPayments 之类的集合,而是将它们统一抽象为一种支付组件:
Payment
└── PaymentComponents
├── CashPayment
├── GiftCouponPayment
└── CreditCardPayment
Payment 只依赖 PaymentComponent 抽象,通过多态计算实收金额。新增支付方式时,只需增加新的组件类型和 ORM 映射,不必修改 Payment.GetTotalAmountCollected()。
OCP 并不要求新增支付方式时整个系统一行代码都不能改。数据库表结构、ORM 映射和对象创建配置通常还是需要扩展。它要保护的是已经稳定的业务逻辑,避免反复改动 Payment 中的计算代码。
当前设计为什么不满足 OCP
当前的 Payment 直接持有多个具体类型的集合:
payment.CashPayments
payment.GiftCouponPayments
加入信用卡支付后,通常还得再添加:
payment.CreditCardPayments
金额计算也会逐渐变成:
public decimal GetTotalAmountCollected()
{
return CashPayments.Sum(x => x.CurrencyValue)
+ GiftCouponPayments.Sum(x => x.CouponValue)
+ CreditCardPayments.Sum(x => x.Amount);
}
以后增加银行卡、积分或储值卡支付时,这个方法还得继续修改。根本原因在于 Payment 依赖具体的支付类型,没有依赖统一的支付组件抽象。
推荐的领域模型
先定义所有支付组件都必须提供的行为:
public abstract class PaymentComponent
{
public int PaymentComponentID { get; set; }
public int PaymentID { get; set; }
public abstract decimal GetCollectedAmount();
}
每种支付方式自行计算有效金额。
现金支付:
public class CashPayment : PaymentComponent
{
public int CurrencyNumber { get; set; }
public decimal CurrencyValue { get; set; }
public override decimal GetCollectedAmount()
{
return CurrencyValue;
}
}
礼券支付:
public class GiftCouponPayment : PaymentComponent
{
private static readonly DateTime MinimumValidIssueDate =
new DateTime(2000, 1, 1);
public int CouponNumber { get; set; }
public decimal CouponValue { get; set; }
public DateTime IssuedDate { get; set; }
public override decimal GetCollectedAmount()
{
return IssuedDate < MinimumValidIssueDate
? 0m
: CouponValue;
}
}
Payment 只保留一个统一的集合:
public class Payment
{
private readonly IList<PaymentComponent> _components =
new List<PaymentComponent>();
public int PaymentID { get; set; }
public string PaymentType { get; set; }
public IEnumerable<PaymentComponent> Components
{
get { return _components; }
}
public void AddComponent(PaymentComponent component)
{
if (component == null)
{
throw new ArgumentNullException("component");
}
_components.Add(component);
}
public decimal GetTotalAmountCollected()
{
return _components.Sum(x => x.GetCollectedAmount());
}
}
这样一来,Payment 不必知道组件是现金、礼券还是信用卡,只要求每个组件返回自己的有效实收金额。
新增信用卡支付
信用卡支付可以直接作为新的组件加入:
public class CreditCardPayment : PaymentComponent
{
public string TransactionReference { get; set; }
public decimal Amount { get; set; }
public override decimal GetCollectedAmount()
{
return Amount;
}
}
使用方式如下:
Payment payment = new Payment
{
PaymentID = 1,
PaymentType = "PurchaseP"
};
payment.AddComponent(new GiftCouponPayment
{
PaymentComponentID = 1,
CouponNumber = 123,
CouponValue = 300m,
IssuedDate = new DateTime(2010, 6, 1)
});
payment.AddComponent(new GiftCouponPayment
{
PaymentComponentID = 2,
CouponNumber = 124,
CouponValue = 200m,
IssuedDate = new DateTime(2012, 8, 1)
});
payment.AddComponent(new CashPayment
{
PaymentComponentID = 3,
CurrencyNumber = 125,
CurrencyValue = 50m
});
payment.AddComponent(new CreditCardPayment
{
PaymentComponentID = 4,
TransactionReference = "TX-10001",
Amount = 100m
});
decimal total = payment.GetTotalAmountCollected();
加入 CreditCardPayment 后,Payment 和 GetTotalAmountCollected() 都不用改。
在 LINQ to SQL 中实现
使用统一的组件表
如果数据库结构允许调整,可以建立统一的 PaymentComponent 表,再用类型字段区分支付方式。例如:
Payment
-------
PaymentID
PaymentType
PaymentComponent
----------------
PaymentComponentID
PaymentID
ComponentType
Amount
CouponNumber
CouponIssuedDate
CurrencyNumber
TransactionReference
ComponentType 可以保存以下值:
Cash
GiftCoupon
CreditCard
LINQ to SQL 支持通过 discriminator column 映射单表继承。可以在 DBML 中将 PaymentComponent 配置为基类,再根据 ComponentType 映射到不同的派生类。
采用这种结构后,Payment 只需要一条关联:
EntitySet<PaymentComponent> PaymentComponents
不必为每种支付方式增加一个新的 EntitySet<T>。
LINQ to SQL 的继承映射主要针对单表继承。如果想采用类似 Entity Framework TPT 的”每种派生类型一张表”结构,LINQ to SQL 很难直接表达这种继承关系。此时可以调整持久化模型,或者将领域模型与 LINQ to SQL 实体分开。
使用 partial class 保留自定义行为
DBML 生成的类通常是 partial class。不要直接编辑自动生成的 .designer.cs 文件,否则重新生成 DBML 后,改动可能被覆盖。自定义行为可以放在独立文件中:
public partial class PaymentComponent
{
public virtual decimal GetCollectedAmount()
{
return Amount;
}
}
礼券类型可以覆盖这个行为:
public partial class GiftCouponPayment
{
public override decimal GetCollectedAmount()
{
DateTime cutoff = new DateTime(2000, 1, 1);
return CouponIssuedDate < cutoff
? 0m
: CouponValue;
}
}
Payment 的行为也应放在单独的 partial class 文件中:
public partial class Payment
{
public decimal GetTotalAmountCollected()
{
return PaymentComponents.Sum(
component => component.GetCollectedAmount());
}
}
这种写法要求 DBML 中确实定义了统一的 PaymentComponent 基类和 PaymentComponents 关联。仅添加 partial class,无法把原有的两个独立 EntitySet 自动合并成一个多态集合。
Repository 不应了解每种支付方式
Repository 的职责是保存聚合,不应针对每种支付类型编写大量分支:
public interface IPaymentRepository
{
void Insert(Payment payment);
void SubmitChanges();
}
LINQ to SQL 的实现可以很简单:
public class LijosPaymentRepository : IPaymentRepository
{
private readonly DataContext _dataContext;
public LijosPaymentRepository(DataContext dataContext)
{
if (dataContext == null)
{
throw new ArgumentNullException("dataContext");
}
_dataContext = dataContext;
}
public void Insert(Payment payment)
{
if (payment == null)
{
throw new ArgumentNullException("payment");
}
_dataContext.GetTable<Payment>().InsertOnSubmit(payment);
}
public void SubmitChanges()
{
_dataContext.SubmitChanges();
}
}
这里有两个前提:DBML 的关联配置正确,组件也通过生成的关联集合连接到了 Payment。是否需要对各组件表显式调用 InsertOnSubmit,取决于实体有没有进入当前 DataContext 的变更跟踪范围,不能假定任何对象图都会自动级联插入。
数据库结构不能修改时
如果必须保留 CashPayment、GiftCouponPayment 等独立表,而 LINQ to SQL 又无法将它们映射为统一的继承层次,更稳妥的方式是分离 ORM 实体和领域对象:
LINQ to SQL entities
↓ Repository 映射
Domain Payment + PaymentComponent
领域层仍然使用统一的 PaymentComponent:
public abstract class PaymentComponent
{
public abstract decimal GetCollectedAmount();
}
Repository 将不同表中的记录转换成对应的领域组件:
public Payment GetById(int paymentId)
{
DBML_Project.Payment entity =
_dataContext.GetTable<DBML_Project.Payment>()
.Single(x => x.PaymentID == paymentId);
Payment payment = new Payment
{
PaymentID = entity.PaymentID,
PaymentType = entity.PaymentType
};
foreach (DBML_Project.CashPayment cash in entity.CashPayments)
{
payment.AddComponent(new CashPayment
{
CurrencyNumber = cash.CurrencyNumber,
CurrencyValue = cash.CurrencyValue
});
}
foreach (DBML_Project.GiftCouponPayment coupon
in entity.GiftCouponPayments)
{
payment.AddComponent(new GiftCouponPayment
{
CouponNumber = coupon.CouponNumber,
CouponValue = coupon.CouponValue,
IssuedDate = coupon.IssuedDate
});
}
return payment;
}
在这种实现中,新增支付类型仍需修改持久化映射,但 Payment 的核心计算逻辑不用变。对于 ORM 能力有限或数据库结构已经固定的项目,这样划分 OCP 边界更符合实际情况。
礼券规则是否应该使用 Strategy
“2000 年以前发行的礼券不计金额”是礼券自身明确且稳定的业务规则,目前直接封装在 GiftCouponPayment.GetCollectedAmount() 中就足够了。
如果礼券有效性规则会随地区、活动、商户或日期频繁变化,再引入 Strategy 更合适:
public interface ICouponValuationStrategy
{
decimal GetValue(GiftCouponPayment coupon);
}
默认规则:
public class IssueDateCouponValuationStrategy
: ICouponValuationStrategy
{
private static readonly DateTime CutoffDate =
new DateTime(2000, 1, 1);
public decimal GetValue(GiftCouponPayment coupon)
{
if (coupon == null)
{
throw new ArgumentNullException("coupon");
}
return coupon.IssuedDate < CutoffDate
? 0m
: coupon.CouponValue;
}
}
礼券将计算工作交给 Strategy:
public class GiftCouponPayment : PaymentComponent
{
private readonly ICouponValuationStrategy _valuationStrategy;
public GiftCouponPayment(
ICouponValuationStrategy valuationStrategy)
{
if (valuationStrategy == null)
{
throw new ArgumentNullException("valuationStrategy");
}
_valuationStrategy = valuationStrategy;
}
public int CouponNumber { get; set; }
public decimal CouponValue { get; set; }
public DateTime IssuedDate { get; set; }
public override decimal GetCollectedAmount()
{
return _valuationStrategy.GetValue(this);
}
}
不过,LINQ to SQL 加载实体时通常要求无参数构造函数,也不会自动注入 Strategy。如果 ORM 实体直接包含领域行为,可以在无参数构造函数中设置默认策略,或者通过加载后的 partial method 完成初始化。更清晰的方案还是分离领域对象和 ORM 实体,再由 Repository 或 Factory 创建带有依赖的领域对象。
注意事项
- 金额应使用
decimal,不要使用float或double。 - 必须明确
IssuedDate是否允许为空。如果数据库允许NULL,还要定义空日期表示无效、未知还是有效。 - 不应在普通业务表中明文保存信用卡号。通常只保存支付渠道返回的交易引用、卡品牌和脱敏后的尾号。
GetTotalAmountCollected()最好只表示”有效实收金额”,不要把授权中、支付失败、退款等状态判断塞进这个方法。如果需要处理这些规则,应由具体组件返回当前的有效金额。- 不要直接修改 DBML 自动生成的文件。自定义行为应放在 partial class 或独立领域模型中。
- 如果新增支付方式必须增加数据库表或字段,ORM 映射随之变化很正常,并不意味着 OCP 失败。需要保持稳定的是
Payment的聚合结构和金额计算逻辑。 - 对于较复杂的支付系统,优先采用”独立领域模型 + Repository 映射”。如果让 ORM 生成类同时负责数据库映射、业务规则和依赖注入,模型会逐渐受制于持久化技术。
备注:内容仅供参考。
