开始一个新项目时,先建几个文件夹、再拆几个类库,这些决策会影响未来的可维护性。这篇把 .NET 程序的完整组织层次讲清楚:从最外层的解决方案到最内层的类型,每一层该放什么、什么时候该拆、什么时候不该拆。
- 五层组织模型:解决方案 → 项目 → 程序集 → 命名空间 → 类型
- 什么时候拆项目:三个正当理由 vs 过早拆分警告
- 命名空间命名规范:文件夹匹配 + 按功能而非按类型分组
- 访问修饰符策略 :默认
internal,只在必要时public
一、五层组织模型
.NET 程序的完整组织层次:
| 层级 | 概念 | 物理载体 | 作用 |
|---|---|---|---|
| 解决方案 | 相关项目的容器 | .slnx文件 |
管理开发工作流,一次打开一组项目 |
| 项目 | 构建单元 | .csproj文件 |
决定哪些文件一起编译 |
| 程序集 | 编译产物 | .dll或.exe |
部署和版本控制的最小单元 |
| 命名空间 | 类型的逻辑分组 | 声明在源代码中 | 防止命名冲突,可跨程序集 |
| 类型 | 类、结构、接口等 | 源代码中的定义 | 封装数据和行为 |
graph TD
Solution["解决方案 (.slnx)"] --> Project1["项目 A (.csproj)"]
Solution --> Project2["项目 B (.csproj)"]
Project1 --> Assembly1["程序集 A (.dll)"]
Project2 --> Assembly2["程序集 B (.dll)"]
Assembly1 --> NS1["命名空间 MyApp.Services"]
Assembly1 --> NS2["命名空间 MyApp.Models"]
NS1 --> Type1["class OrderService"]
NS1 --> Type2["interface IPaymentProcessor"]
二、项目与程序集:什么时候拆
一个项目编译为一个程序集。最基本的原则是:从单个项目开始,不要过早拆分。
2.1 创建多个项目的三个正当理由
| 理由 | 场景 | 示例 |
|---|---|---|
| 代码共享 | 多个应用需要同一套逻辑 | 提取SharedLibrary.csproj,Web 和桌面端共同引用 |
| 关注点分离 | 数据访问、业务逻辑、表现层职责清晰 | MyApp.Data/MyApp.Core/MyApp.Web |
| 依赖控制 | 项目只能使用显式引用的类型 | 表现层不直接引用数据访问库 |
2.2 什么是不该拆的
- "以防万一将来会用" → 不要拆,需要时再提取
- "这个类以后会很复杂" → 等它真的复杂了再说
- "标准做法就是分三层" → 先看项目规模,小项目单项目就够了
划重点: 拆分项目会带来跨项目引用、版本协调、生成顺序等额外复杂度。只有当前项目确实出现了上述三条理由中的至少一条,才考虑拆分。
三、命名空间与文件夹结构
3.1 基本规则:命名空间应镜像文件夹路径
csharp
// File: Services/OrderService.cs
// 命名空间与文件夹路径一致
using MyApp.Core;
namespace MyApp.Services;
public class OrderService
{
public Order CreateOrder(string product, int quantity, decimal price) =>
new() { ProductName = product, Quantity = quantity, UnitPrice = price };
public string FormatSummary(Order order) =>
$"{order.Quantity}x {order.ProductName} = {order.Total:C}";
}
注意两个易错点:
- 根命名空间默认取项目文件名,但子文件夹中的类型不会自动 获得子命名空间 ------ 你必须在文件中用
namespace MyApp.Services;显式声明 - 可以在
.csproj中通过<RootNamespace>自定义根命名空间
3.2 按功能组织,而非按类型种类
❌ 不推荐 --- 按类型种类分:
csharp
MyApp.Interfaces/IPaymentProcessor.cs
MyApp.Implementations/CreditCardProcessor.cs
MyApp.Models/PaymentResult.cs
✅ 推荐 --- 按功能分:
csharp
// 文件:Payments/ 文件夹下,所有相关类型放在同一个命名空间
namespace MyApp.Payments;
public interface IPaymentProcessor
{
bool ProcessPayment(decimal amount);
}
public class CreditCardProcessor : IPaymentProcessor
{
public bool ProcessPayment(decimal amount)
{
Console.WriteLine($"Processing credit card payment of {amount:C}");
return true;
}
}
public record PaymentResult(bool Success, string? TransactionId);
代码解析:
- 接口 + 实现 + 数据模型 :全部放在
MyApp.Payments命名空间下 - 相关代码集中一处:当需要修改「支付」功能时,所有代码都在同一个文件夹和命名空间中
-
record 作为数据传输对象,与业务逻辑共存于功能命名空间中,无需单独拆Models文件夹
四、访问修饰符:默认 internal
访问修饰符与项目/程序集结构紧密配合:
| 修饰符 | 可见范围 |
|---|---|
public |
任何引用此程序集的代码 |
internal |
仅同一程序集内(顶级类型的默认值) |
protected internal |
同一程序集 或 派生类 |
private |
仅包含类型内 |
protected |
包含类型及其派生类 |
private protected |
同一程序集内的派生类 |
最佳实践
csharp
namespace MyApp.Inventory;
// public --- 其他项目需要调用的 API
public class InventoryService
{
public int GetStockLevel(string productName) =>
StockDatabase.Lookup(productName);
}
// internal --- 仅此程序集内使用的实现细节
internal static class StockDatabase
{
private static readonly Dictionary<string, int> _stock = new()
{
["Widget"] = 42,
["Gadget"] = 17
};
internal static int Lookup(string productName) =>
_stock.GetValueOrDefault(productName);
}
划重点: 默认用
internal,只在真正需要时改成public。把访问权放大容易,缩小是破坏性更改(breaking change)。
五、推荐做法汇总
| 方面 | 推荐 | 不推荐 |
|---|---|---|
| 命名空间命名 | Company.Product.Feature模式(如Contoso.Inventory.Shipping) |
随意命名,无层次感 |
| 命名空间语法 | 文件范围声明namespace X; |
块范围namespace X { }(减少缩进) |
| 项目数量 | 从单个开始,有理由再拆 | 过早拆分成多个项目 |
| 访问级别 | 默认internal |
随意public |
| 代码分组 | 按功能聚合同一命名空间 | 按类型种类(Interfaces/、Models/ 文件夹) |
| 文件夹与命名空间 | 命名空间镜像文件夹路径 | 不一致的映射 |
最后
程序组织的核心就两个原则:在需要的时候才拆分,按功能而不是按类型分组。一个健康的项目结构不是一开始规划出来的,而是在重构中长出来的。先跑起来,等代码真的"喊疼"了再拆。