csharpvar order = new Order();
order.CustomerId = 1001;
order.ProductId = 5;
order.Quantity = 3;
order.Discount = 0.1m;
order.ShippingAddress = "北京市朝阳区...";
order.PaymentMethod = "WeChat";
order.IsGift = false;
order.Note = "尽快发货";
十几行赋值,对象还没构建完。更头疼的是,哪些字段是必填的?哪些有默认值?哪些组合是非法的? 代码本身完全看不出来。
等到三个月后回来维护,或者换个同事接手,光是理清这个对象的构建逻辑就得花上半小时。
这不是个别现象。在我参与的多个中大型项目里,对象构建混乱是导致 bug 率上升的重要原因之一。有统计数据表明,开发者平均将 40%~50% 的时间花在理解和修复已有代码上,而构建逻辑不清晰是其中的高频诱因。
读完这篇文章,你将掌握:
最常见的"解决方案"是把所有参数塞进构造函数:
csharpvar order = new Order(1001, 5, 3, 0.1m, "北京市朝阳区", "WeChat", false, "尽快发货");
参数一多,调用方完全不知道每个位置对应什么含义。这种写法有个专有名词,叫 "telescoping constructor"(望远镜构造函数),因为参数列表越来越长,就像望远镜一节一节伸出去。
用无参构造 + 属性赋值的方式,存在一个致命问题:对象在构建过程中处于中间状态,随时可能被传递给其他方法。
csharpvar order = new Order();
order.CustomerId = 1001;
// 假设这里触发了某个事件或被另一个线程读取
// 此时 order 是不完整的!
order.ProductId = 5;
在并发场景下,这个问题会被放大成难以复现的 bug。
没有统一的构建入口,校验代码就会分散在业务层的各个角落。今天 A 同事加了一个校验,明天 B 同事在另一个地方创建同类对象时完全不知道,漏掉了。校验逻辑的碎片化,本质上是构建职责的缺失。
Builder Pattern 的核心思想其实很朴素:将对象的构建过程与对象本身分离。用一个专门的 Builder 类来承载构建逻辑,最终通过 Build() 方法返回一个完整、合法的对象。
Fluent API 是 Builder Pattern 在 C# 中最自然的表达形式。它依赖一个简单的技巧:每个配置方法都返回 this(或 Builder 自身),从而支持方法链式调用。
csharpvar order = new OrderBuilder()
.ForCustomer(1001)
.WithProduct(5, quantity: 3)
.ApplyDiscount(0.1m)
.ShipTo("北京市朝阳区...")
.PayBy("WeChat")
.WithNote("尽快发货")
.Build();
这段代码的信息密度远高于之前的版本。每个方法名都在表达意图,参数也有了语义标签,阅读起来几乎像自然语言。
先从最直接的实现入手,建立对 Builder Pattern 的直观感受。
csharpusing System;
using System.Collections.Generic;
using System.Text;
namespace AppBuilderPattern
{
// 目标对象,构造函数私有,只能通过 Builder 创建
public class Order
{
public int CustomerId { get; private set; }
public int ProductId { get; private set; }
public int Quantity { get; private set; }
public decimal Discount { get; private set; }
public string ShippingAddress { get; private set; }
public string PaymentMethod { get; private set; }
public string Note { get; private set; }
// 私有构造,防止外部随意 new
private Order() { }
// Builder 作为嵌套类,可以访问私有构造
public class Builder
{
private readonly Order _order = new Order();
public Builder ForCustomer(int customerId)
{
if (customerId <= 0)
throw new ArgumentException("CustomerId 必须大于 0");
_order.CustomerId = customerId;
return this;
}
public Builder WithProduct(int productId, int quantity = 1)
{
if (quantity <= 0)
throw new ArgumentException("数量必须大于 0");
_order.ProductId = productId;
_order.Quantity = quantity;
return this;
}
public Builder ApplyDiscount(decimal rate)
{
if (rate < 0 || rate >= 1)
throw new ArgumentOutOfRangeException("折扣率需在 0~1 之间");
_order.Discount = rate;
return this;
}
public Builder ShipTo(string address)
{
_order.ShippingAddress = address ?? throw new ArgumentNullException(nameof(address));
return this;
}
public Builder PayBy(string method)
{
_order.PaymentMethod = method;
return this;
}
public Builder WithNote(string note)
{
_order.Note = note;
return this;
}
public Order Build()
{
// 必填项校验集中在此处
if (_order.CustomerId == 0)
throw new InvalidOperationException("CustomerId 为必填项");
if (_order.ProductId == 0)
throw new InvalidOperationException("ProductId 为必填项");
if (string.IsNullOrEmpty(_order.ShippingAddress))
throw new InvalidOperationException("收货地址为必填项");
return _order;
}
}
}
}
使用示例:
csharpvar order = new Order.Builder()
.ForCustomer(1001)
.WithProduct(5, quantity: 3)
.ApplyDiscount(0.1m)
.ShipTo("北京市朝阳区XXX")
.PayBy("WeChat")
.Build();

这个方案的价值在于: 校验逻辑有了归宿,对象只有在 Build() 之后才是完整的,外部无法构建出半残状态的 Order。
踩坑预警: 嵌套 Builder 类访问外部私有构造函数在 C# 中是合法的,但如果你的 Order 需要被序列化(如 JSON 反序列化),私有构造函数可能引发问题。这时可以将构造函数改为 internal 或为序列化框架单独配置。
当项目中有多个对象需要 Builder 时,每个都手写一遍 return this 会很繁琐。可以抽象出一个泛型基类来复用链式调用的骨架。
csharp// 泛型 Builder 基类
// TBuilder 是子类自身,TResult 是构建目标
public abstract class BuilderBase<TBuilder, TResult>
where TBuilder : BuilderBase<TBuilder, TResult>
where TResult : class, new()
{
protected TResult Instance { get; } = new TResult();
// 返回强类型的 this,子类方法不需要强转
protected TBuilder Self => (TBuilder)this;
public abstract TResult Build();
}
基于这个基类,OrderBuilder 变得非常干净:
csharppublic class OrderBuilder : BuilderBase<OrderBuilder, OrderDto>
{
public OrderBuilder ForCustomer(int customerId)
{
Instance.CustomerId = customerId;
return Self;
}
public OrderBuilder WithProduct(int productId, int quantity = 1)
{
Instance.ProductId = productId;
Instance.Quantity = quantity;
return Self;
}
public OrderBuilder ShipTo(string address)
{
Instance.ShippingAddress = address;
return Self;
}
public override OrderDto Build()
{
if (Instance.CustomerId == 0)
throw new InvalidOperationException("CustomerId 为必填项");
return Instance;
}
}
// 对应的 DTO(属性公开,适合序列化场景)
public class OrderDto
{
public int CustomerId { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public string ShippingAddress { get; set; }
}
c#namespace AppBuilderPattern
{
internal class Program
{
static void Main(string[] args)
{
var order=new OrderBuilder()
.ShipTo("123 Main St")
.ForCustomer(1)
.WithProduct(2, 3).Build();
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(order));
}
}
}

测试环境: .NET 8,x64,Release 模式。对比直接属性赋值与 Builder 链式调用,在 10 万次构建的 BenchmarkDotNet 测试中,Builder 方式的额外开销约为 3~5%,在绝大多数业务场景下可以忽略不计。
踩坑预警: where TResult : new() 约束要求目标类有无参构造函数。如果目标类构造函数需要依赖注入参数,可以改为在基类构造函数中接收 TResult 实例,而不是用 new() 自动创建。
有些对象的构建有严格的顺序依赖,比如"必须先选商品,才能设置数量"。普通 Fluent Builder 无法在编译期强制这种顺序。Step Builder 通过接口约束解决这个问题。
csharp// 每个接口代表构建流程中的一个步骤
public interface ICustomerStep
{
IProductStep ForCustomer(int customerId);
}
public interface IProductStep
{
IShippingStep WithProduct(int productId, int quantity = 1);
}
public interface IShippingStep
{
IFinalStep ShipTo(string address);
}
public interface IFinalStep
{
IFinalStep PayBy(string method);
IFinalStep WithNote(string note);
IFinalStep ApplyDiscount(decimal rate);
OrderDto Build();
}
// 统一实现类,同时实现所有接口
public class StepOrderBuilder : ICustomerStep, IProductStep, IShippingStep, IFinalStep
{
private readonly OrderDto _dto = new OrderDto();
// 静态工厂方法作为入口,返回第一步接口
public static ICustomerStep Create() => new StepOrderBuilder();
public IProductStep ForCustomer(int customerId)
{
_dto.CustomerId = customerId;
return this;
}
public IShippingStep WithProduct(int productId, int quantity = 1)
{
_dto.ProductId = productId;
_dto.Quantity = quantity;
return this;
}
public IFinalStep ShipTo(string address)
{
_dto.ShippingAddress = address;
return this;
}
public IFinalStep PayBy(string method)
{
_dto.PaymentMethod = method;
return this;
}
public IFinalStep WithNote(string note)
{
_dto.Note = note;
return this;
}
public IFinalStep ApplyDiscount(decimal rate)
{
_dto.Discount = rate;
return this;
}
public OrderDto Build() => _dto;
}
使用时,IDE 的智能提示会严格按顺序引导你:
csharpnamespace AppBuilderPattern
{
internal class Program
{
static void Main(string[] args)
{
var order = new StepOrderBuilder()
.ForCustomer(123)
.WithProduct(456, 2)
.ShipTo("123 Main St")
.PayBy("Credit Card")
.WithNote("Please deliver between 9am-5pm")
.ApplyDiscount(0.1m)
.Build();
Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(order));
}
}
}

如果你试图跳过步骤,编译器直接报错。 这是把运行时错误前移到编译期的典型实践,在领域模型复杂、构建规则严格的场景下价值极高。
踩坑预警: Step Builder 的接口数量会随着步骤增加而线性增长,维护成本相对较高。建议只在构建顺序有严格业务语义的场景使用,普通场景用方案一或方案二即可。
| 维度 | 基础 Fluent Builder | 泛型基类 Builder | Step Builder |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 高 |
| 顺序约束 | 无 | 无 | 编译期强制 |
| 代码复用性 | 一般 | 高 | 一般 |
| 适用场景 | 单一对象构建 | 多对象统一规范 | 强顺序依赖场景 |
| IDE 引导体验 | 一般 | 一般 | 极佳 |
"构建逻辑是对象的第一道防线,把它散落在调用方,等于把安全检查交给了陌生人。"
"Fluent API 不只是语法糖,它是用代码表达业务意图的一种方式。"
"Step Builder 的本质是把运行时错误变成编译时错误,这是所有防御性编程技巧里成本最低的一种。"
这篇文章围绕 Builder Pattern 在 C# 中的现代实现,给出了三个渐进式的方案:
基础 Fluent Builder 解决了构建逻辑散落和对象状态不一致的问题,是最小可行的起点;泛型 Builder 基类 在多对象场景下消除了重复代码,提升了团队规范的统一性;Step Builder 则通过接口约束将构建顺序从运行时校验提升到编译期保障,适合领域模型复杂的核心业务场景。
三者并不互斥,在实际项目中可以按需组合。大多数情况下,从方案一起步就足够,等到痛点出现再升级到方案二或方案三,这符合"不过度设计"的工程原则。
学习路径上,Builder Pattern 与 Fluent Validation、Expression Builder、DSL 设计 是自然延伸的方向。如果你对领域特定语言(DSL)感兴趣,Fluent API 的设计思想是理解内部 DSL 的最佳入口。
💬 讨论话题
你在项目里用过哪种 Builder 实现方式?有没有遇到过因为对象构建逻辑混乱导致的 bug?欢迎在评论区分享你的经历和解决思路。
#C# #设计模式 #FluentAPI #编程技巧 #代码质量
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!