PAYATHON 2026

如何让 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 生成类同时负责数据库映射、业务规则和依赖注入,模型会逐渐受制于持久化技术。

备注:内容仅供参考。