C# 程序组织结构:解决方案、项目、命名空间的层次结构

开始一个新项目时,先建几个文件夹、再拆几个类库,这些决策会影响未来的可维护性。这篇把 .NET 程序的完整组织层次讲清楚:从最外层的解决方案到最内层的类型,每一层该放什么、什么时候该拆、什么时候不该拆。

  1. 五层组织模型:解决方案 → 项目 → 程序集 → 命名空间 → 类型
  2. 什么时候拆项目:三个正当理由 vs 过早拆分警告
  3. 命名空间命名规范:文件夹匹配 + 按功能而非按类型分组
  4. 访问修饰符策略 :默认 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}";
 }

注意两个易错点:

  1. 根命名空间默认取项目文件名,但子文件夹中的类型不会自动 获得子命名空间 ------ 你必须在文件中用 namespace MyApp.Services; 显式声明
  2. 可以在 .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);

代码解析:

  1. 接口 + 实现 + 数据模型 :全部放在 MyApp.Payments 命名空间下
  2. 相关代码集中一处:当需要修改「支付」功能时,所有代码都在同一个文件夹和命名空间中
  3. 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/ 文件夹)
文件夹与命名空间 命名空间镜像文件夹路径 不一致的映射

最后

程序组织的核心就两个原则:在需要的时候才拆分,按功能而不是按类型分组。一个健康的项目结构不是一开始规划出来的,而是在重构中长出来的。先跑起来,等代码真的"喊疼"了再拆。