mcpField
D
dotnet-solid-principles

dotnet-solid-principles

github.com/wshaddix开发86101 次安装35 周前更新
代码仓库

关于 dotnet-solid-principles

为 .NET 开发者提供 SOLID 与 DRY 设计原则指南,适用于类设计、接口契约和依赖管理。通过 C# 反例与改进方案说明原则应用,并指出何时保留重复而不过早抽象。

主要功能

  1. 01SOLID 原则与 C# 反例
  2. 02类职责拆分指导
  3. 03策略模式扩展行为
  4. 04设计精简的角色接口
  5. 05依赖倒置与抽象边界
  6. 06DRY 判断及三次规则

适用场景

  1. 01拆分职责过多的服务或端点
  2. 02为新增行为避免修改既有类型
  3. 03缩小被调用方依赖的接口
  4. 04判断重复代码是否值得抽象

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-solid-principles

安装后在 Claude Code 中输入/dotnet-solid-principles确认已加载。

详细介绍

dotnet-solid-principles

.NET 应用程序的基础设计原则。通过具体的 C# 反模式及其修正方案,介绍每项 SOLID 原则,并提供 DRY 指南,说明在哪些情况下允许重复。这些原则适用于所有 .NET 项目类型中的类设计、接口契约和依赖管理。

范围边界: 本技能负责类和接口层面的 SOLID/DRY 基础设计原则。架构模式(垂直切片、请求管道、缓存、幂等性)由 [skill:dotnet-architecture-patterns] 负责。DI 容器机制(注册、生命周期、键控服务)由 [skill:dotnet-csharp-dependency-injection] 负责。代码异味和反模式检测由 [skill:dotnet-csharp-code-smells] 负责。

交叉参考:[skill:dotnet-architecture-patterns] 介绍整洁架构和垂直切片,[skill:dotnet-csharp-dependency-injection] 介绍 DI 注册模式和生命周期管理,[skill:dotnet-csharp-code-smells] 介绍反模式检测,[skill:dotnet-csharp-coding-standards] 介绍命名和风格约定。


单一职责原则(SRP)

一个类应该只有一个变更理由。应用「用一句话描述」测试:如果你无法在不使用「和」或「或」的情况下用一句话描述类的职责,它很可能违反了 SRP。

反模式:上帝类
// WRONG -- OrderService handles validation, persistence, email, and PDF generation
public class OrderService
{
    private readonly AppDbContext _db;
    private readonly SmtpClient _smtp;

    public OrderService(AppDbContext db, SmtpClient smtp)
    {
        _db = db;
        _smtp = smtp;
    }

    public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
    {
        // Validation logic (reason to change #1)
        if (string.IsNullOrEmpty(request.CustomerId))
            throw new ArgumentException("Customer required");

        // Persistence logic (reason to change #2)
        var order = new Order { CustomerId = request.CustomerId };
        _db.Orders.Add(order);
        await _db.SaveChangesAsync();

        // Email notification (reason to change #3)
        var message = new MailMessage("[email protected]", request.Email,
            "Order Confirmed", $"Order {order.Id} created.");
        await _smtp.SendMailAsync(message);

        // PDF generation (reason to change #4)
        GenerateInvoicePdf(order);

        return order;
    }

    private void GenerateInvoicePdf(Order order) { /* ... */ }
}
修正:分离职责
// Each class has one reason to change
public sealed class OrderCreator(
    IOrderValidator validator,
    IOrderRepository repository,
    IOrderNotifier notifier)
{
    public async Task<Order> CreateAsync(
        CreateOrderRequest request, CancellationToken ct)
    {
        validator.Validate(request);

        var order = await repository.AddAsync(request, ct);

        await notifier.OrderCreatedAsync(order, ct);

        return order;
    }
}

public sealed class OrderValidator : IOrderValidator
{
    public void Validate(CreateOrderRequest request)
    {
        ArgumentException.ThrowIfNullOrEmpty(request.CustomerId);
        // ... validation rules
    }
}

public sealed class OrderRepository(AppDbContext db) : IOrderRepository
{
    public async Task<Order> AddAsync(
        CreateOrderRequest request, CancellationToken ct)
    {
        var order = new Order { CustomerId = request.CustomerId };
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
        return order;
    }
}
反模式:臃肿的控制器
// WRONG -- controller contains business logic, mapping, and persistence
app.MapPost("/api/orders", async (
    CreateOrderRequest request,
    AppDbContext db,
    ILogger<Program> logger) =>
{
    // Validation in the endpoint
    if (request.Lines.Count == 0)
        return Results.BadRequest("At least one line required");

    // Business logic in the endpoint
    var total = request.Lines.Sum(l => l.Quantity * l.Price);
    if (total > 100_000)
        return Results.BadRequest("Order exceeds credit limit");

    // Mapping in the endpoint
    var order = new Order
    {
        CustomerId = request.CustomerId,
        Total = total,
        Lines = request.Lines.Select(l => new OrderLine
        {
            ProductId = l.ProductId,
            Quantity = l.Quantity,
            Price = l.Price
        }).ToList()
    };

    // Persistence in the endpoint
    db.Orders.Add(order);
    await db.SaveChangesAsync();

    logger.LogInformation("Order {OrderId} created", order.Id);
    return Results.Created($"/api/orders/{order.Id}", order);
});

将业务逻辑移至处理器;保持端点精简:

app.MapPost("/api/orders", async (
    CreateOrderRequest request,
    IOrderHandler handler,
    CancellationToken ct) =>
{
    var result = await handler.CreateAsync(request, ct);
    return result switch
    {
        { IsSuccess: true } => Results.Created(
            $"/api/orders/{result.Value.Id}", result.Value),
        _ => Results.ValidationProblem(result.Errors)
    };
});

开闭原则(OCP)

类应该对扩展开放,对修改关闭。通过实现新类型来添加新行为,而不是编辑现有的 switch/if 链。

反模式:按类型分支
// WRONG -- adding a new discount type requires modifying this method
public decimal CalculateDiscount(Order order)
{
    switch (order.DiscountType)
    {
        case "Percentage":
            return order.Total * order.DiscountValue / 100;
        case "FixedAmount":
            return order.DiscountValue;
        case "BuyOneGetOneFree":
            return order.Lines
                .Where(l => l.Quantity >= 2)
                .Sum(l => l.Price);
        default:
            return 0;
    }
}
修正:策略模式
public interface IDiscountStrategy
{
    decimal Calculate(Order order);
}

public sealed class PercentageDiscount(decimal percentage) : IDiscountStrategy
{
    public decimal Calculate(Order order) =>
        order.Total * percentage / 100;
}

public sealed class FixedAmountDiscount(decimal amount) : IDiscountStrategy
{
    public decimal Calculate(Order order) =>
        Math.Min(amount, order.Total);
}

// New discount type -- no existing code modified
public sealed class BuyOneGetOneFreeDiscount : IDiscountStrategy
{
    public decimal Calculate(Order order) =>
        order.Lines
            .Where(l => l.Quantity >= 2)
            .Sum(l => l.Price);
}

// Usage -- resolved via DI or factory
public sealed class OrderPricing(
    IEnumerable<IDiscountStrategy> strategies)
{
    public decimal ApplyBestDiscount(Order order) =>
        strategies.Max(s => s.Calculate(order));
}
通过抽象类扩展

当策略共享大量行为时,使用抽象基类:

public abstract class NotificationSender
{
    public async Task SendAsync(Notification notification, CancellationToken ct)
    {
        // Shared behavior: validation and logging
        ArgumentNullException.ThrowIfNull(notification);
        await SendCoreAsync(notification, ct);
    }

    protected abstract Task SendCoreAsync(
        Notification notification, CancellationToken ct);
}

public sealed class EmailNotificationSender(IEmailClient client)
    : NotificationSender
{
    protected override async Task SendCoreAsync(
        Notification notification, CancellationToken ct)
    {
        await client.SendEmailAsync(
            notification.Recipient, notification.Subject,
            notification.Body, ct);
    }
}

里氏替换原则(LSP)

子类型必须能够替换其基类型,而不影响程序正确性。子类必须遵守其父类的行为契约——不能加强前置条件,也不能削弱后置条件。

反模式:在重写方法中抛出异常
public class FileStorage : IStorage
{
    public virtual Stream OpenRead(string path) =>
        File.OpenRead(path);
}

// WRONG -- ReadOnlyFileStorage violates the base contract by
// throwing on a method the base type supports
public class ReadOnlyFileStorage : FileStorage
{
    public override Stream OpenRead(string path)
    {
        if (!File.Exists(path))
            throw new InvalidOperationException(
                "Cannot open files in read-only mode");
        return base.OpenRead(path);
    }

    // Surprise: callers expecting FileStorage behavior get exceptions
}
反模式:集合协变陷阱
// WRONG -- List<T> is not covariant; this compiles but causes runtime issues
IList<Animal> animals = new List<Dog>(); // Compile error (correctly)

// However, arrays ARE covariant in C# -- this compiles but throws at runtime:
Animal[] animals = new Dog[10];
animals[0] = new Cat(); // ArrayTypeMismatchException at runtime!
修正:使用协变接口
// IEnumerable<out T> and IReadOnlyList<out T> are covariant
IEnumerable<Animal> animals = new List<Dog>(); // Safe -- read-only
IReadOnlyList<Animal> readOnlyAnimals = new List<Dog>(); // Safe

// When you need mutability, keep the concrete type
List<Dog> dogs = [new Dog("Rex"), new Dog("Buddy")];
ProcessAnimals(dogs); // Pass to covariant parameter

void ProcessAnimals(IReadOnlyList<Animal> animals)
{
    foreach (var animal in animals)
        animal.Speak();
}
LSP 合规检查清单
  • 派生类不会抛出基类未声明的新异常类型
  • 重写的方法不会增加前置条件(例如,添加基类不要求的 null 检查)
  • 重写的方法不会削弱后置条件(例如,基类保证返回非 null 值,却返回 null)
  • 行为契约得到保留:如果 ICollection.Add 在基类上执行成功,那么在派生类型上也必须成功

接口隔离原则(ISP)

客户端不应被迫依赖其不使用的方法。优先选择精简、面向特定角色的接口,而非宽泛的「大而全」接口。

反模式:大而全的接口
// WRONG -- IWorker forces all implementations to support every capability
public interface IWorker
{
    Task DoWorkAsync(CancellationToken ct);
    void ClockIn();
    void ClockOut();
    Task<decimal> CalculatePayAsync();
    void RequestTimeOff(DateRange range);
    Task SubmitExpenseAsync(Expense expense);
}

// ContractWorker does not clock in/out or request time off
public class ContractWorker : IWorker
{
    public Task DoWorkAsync(CancellationToken ct) => /* ... */;
    public void ClockIn() => throw new NotSupportedException(); // ISP violation
    public void ClockOut() => throw new NotSupportedException(); // ISP violation
    public Task<decimal> CalculatePayAsync() => /* ... */;
    public void RequestTimeOff(DateRange range) =>
        throw new NotSupportedException(); // ISP violation
    public Task SubmitExpenseAsync(Expense expense) =>
        throw new NotSupportedException(); // ISP violation
}
修正:面向角色的接口
public interface IWorkPerformer
{
    Task DoWorkAsync(CancellationToken ct);
}

public interface ITimeTrackable
{
    void ClockIn();
    void ClockOut();
}

public interface IPayable
{
    Task<decimal> CalculatePayAsync();
}

public interface ITimeOffEligible
{
    void RequestTimeOff(DateRange range);
}

// FullTimeEmployee implements all applicable interfaces
public sealed class FullTimeEmployee :
    IWorkPerformer, ITimeTrackable, IPayable, ITimeOffEligible
{
    public Task DoWorkAsync(CancellationToken ct) => /* ... */;
    public void ClockIn() { /* ... */ }
    public void ClockOut() { /* ... */ }
    public Task<decimal> CalculatePayAsync() => /* ... */;
    public void RequestTimeOff(DateRange range) { /* ... */ }
}

// ContractWorker only implements what it needs
public sealed class ContractWorker : IWorkPerformer, IPayable
{
    public Task DoWorkAsync(CancellationToken ct) => /* ... */;
    public Task<decimal> CalculatePayAsync() => /* ... */;
}
实用的 .NET ISP

.NET BCL 很好地体现了 ISP:

宽泛接口拆分后的替代接口
IList<T>(读 + 写)IReadOnlyList<T>(只读)
ICollection<T>IReadOnlyCollection<T>
IDictionary<K,V>IReadOnlyDictionary<K,V>

接受方法实际所需的最精简接口:

// WRONG -- requires IList<T> but only reads
public decimal CalculateTotal(IList<OrderLine> lines) =>
    lines.Sum(l => l.Price * l.Quantity);

// RIGHT -- accepts IReadOnlyList<T> since it only reads
public decimal CalculateTotal(IReadOnlyList<OrderLine> lines) =>
    lines.Sum(l => l.Price * l.Quantity);

// BEST for iteration only -- accepts IEnumerable<T>
public decimal CalculateTotal(IEnumerable<OrderLine> lines) =>
    lines.Sum(l => l.Price * l.Quantity);

依赖倒置原则(DIP)

高层模块不应依赖低层模块,两者都应依赖抽象。抽象不应依赖细节。

反模式:直接依赖
// WRONG -- high-level OrderProcessor depends directly on low-level SqlOrderRepository
public sealed class OrderProcessor
{
    private readonly SqlOrderRepository _repository = new();
    private readonly SmtpEmailSender _emailSender = new();

    public async Task ProcessAsync(Order order)
    {
        await _repository.SaveAsync(order);      // Tight coupling to SQL
        await _emailSender.SendAsync(order.Email, // Tight coupling to SMTP
            "Order processed", $"Order {order.Id}");
    }
}
修正:依赖抽象
public interface IOrderRepository
{
    Task SaveAsync(Order order, CancellationToken ct = default);
    Task<Order?> GetByIdAsync(string id, CancellationToken ct = default);
}

public interface INotificationService
{
    Task NotifyAsync(string recipient, string subject,
        string body, CancellationToken ct = default);
}

// High-level module depends on abstractions
public sealed class OrderProcessor(
    IOrderRepository repository,
    INotificationService notifier)
{
    public async Task ProcessAsync(Order order, CancellationToken ct)
    {
        await repository.SaveAsync(order, ct);
        await notifier.NotifyAsync(order.Email,
            "Order processed", $"Order {order.Id}", ct);
    }
}

// Low-level modules implement abstractions
public sealed class SqlOrderRepository(AppDbContext db) : IOrderRepository
{
    public async Task SaveAsync(Order order, CancellationToken ct) =>
        /* EF Core persistence */;
    public async Task<Order?> GetByIdAsync(string id, CancellationToken ct) =>
        await db.Orders.FindAsync([id], ct);
}
DI 注册

使用 Microsoft.Extensions.DependencyInjection 注册抽象。有关生命周期管理、键控服务和装饰模式,请参阅 [skill:dotnet-csharp-dependency-injection]。

builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<INotificationService, SmtpNotificationService>();
builder.Services.AddScoped<OrderProcessor>();
DIP 边界

在模块边界应用 DIP,而不是到处应用:

  • 应该抽象基础设施(数据库、电子邮件、文件系统、HTTP 客户端)
  • 应该抽象横切关注点(日志记录已通过 ILogger<T> 抽象)
  • 不要抽象简单的值对象、DTO 或内部实现细节
  • 不要为每个类都创建 IFoo/Foo 配对——只在替换能带来价值时才进行抽象(用于测试、支持多个实现或应对预期的变更)

DRY(不要重复自己)

每条知识都应有唯一且权威的表示。但 DRY 关注的是知识重复,而非代码重复。

何时应用 DRY

当两段代码表示同一概念且必须一起变更时,应用 DRY:

// WRONG -- tax rate duplicated across two services
public sealed class InvoiceService
{
    public decimal CalculateTax(decimal amount) => amount * 0.08m;
}

public sealed class QuoteService
{
    public decimal EstimateTax(decimal amount) => amount * 0.08m;
}

// RIGHT -- single source of truth
public static class TaxRates
{
    public const decimal StandardRate = 0.08m;
}
三次原则

不要过早抽象。在同一模式出现三次后,再提取共享抽象:

  1. 第一次出现——直接内联编写
  2. 第二次出现——留意重复,但暂时保留(两处用法之后可能会产生差异)
  3. 第三次出现——提取共享方法、类或工具
可以接受重复的情况

并非所有代码相似都意味着知识重复:

// These look similar but represent DIFFERENT business concepts
// They will evolve independently -- DO NOT merge them

public sealed class CustomerValidator
{
    public bool IsValid(Customer customer) =>
        !string.IsNullOrEmpty(customer.Name) &&
        !string.IsNullOrEmpty(customer.Email);
}

public sealed class SupplierValidator
{
    public bool IsValid(Supplier supplier) =>
        !string.IsNullOrEmpty(supplier.Name) &&
        !string.IsNullOrEmpty(supplier.ContactEmail);
}

可以接受重复的情况:

  • 不同测试类中看起来相似的测试设置代码(让测试依赖共享辅助工具会使其变得脆弱)
  • 不同 API 版本的 DTO(V1 和 V2 现在可能共享字段,但之后可能会产生差异)
  • 不同环境的配置(开发和生产配置目前碰巧相似)
  • 层之间的映射代码(为了共享映射器而让各层产生耦合,会违背分层的初衷)
抽象共享行为

提取时,优先选择组合而非继承:

// Prefer: composition via a shared utility
public static class StringValidation
{
    public static bool IsNonEmpty(string? value) =>
        !string.IsNullOrWhiteSpace(value);
}

// Over: inheritance via a base class
// (couples validators to a shared base, harder to test independently)

综合应用这些原则

决策指南
症状可能违反的原则修复方法
用“和”来描述类SRP拆分为职责单一的类
添加功能时需要修改现有代码OCP使用策略/插件模式
重写方法中出现 NotSupportedExceptionLSP重新设计层次结构或使用组合
接口方法为空或会抛出异常ISP拆分为按角色划分的接口
使用 new 关键字创建依赖项DIP通过构造函数注入
多个文件中出现魔法数字/字符串DRY提取常量或配置
复制粘贴的代码块(3 个及以上)DRY提取共享方法
SRP 合规性测试

对于每个类,请回答以下问题:

  1. **一句话测试:**能否用一句话描述该类的用途,而不使用“和”或“或”?
  2. **变更原因测试:**列出该类可能需要变更的所有原因。如果超过一个,请考虑拆分。
  3. **依赖项数量测试:**构造函数是否接收超过 3–4 个依赖项?参数过多通常意味着该类承担了多项职责。

Agent 常见问题

  1. 不要为每个类都创建 IFoo/Foo 配对。 DIP 要求在模块边界(基础设施、外部服务)使用抽象,而不是为每个内部类都使用抽象。不必要的接口会增加间接层,却没有实际价值,还会使代码库杂乱。
  2. 不要合并来自不同限界上下文的相似代码。 两个外观相似、但服务于不同业务概念的验证器或 DTO 应保持独立。过早应用 DRY 会使独立演进的概念产生耦合。
  3. 不要使用继承在无关类型之间共享行为。 优先选择组合(注入共享服务或使用扩展方法),而不是继承公共基类。继承会造成紧密耦合,也更容易违反 LSP。
  4. 臃肿的控制器和上帝类违反 SRP。 生成端点处理程序时,应保持其精简——将验证、业务逻辑和持久化委托给专用服务。对每个类应用“用一句话说明职责”测试。
  5. 基于类型判别符的 switch 语句违反 OCP。 用多态(策略模式、接口分派)替代,这样无需修改现有代码即可添加新类型。
  6. C# 中的数组协变并不安全。 Animal[] animals = new Dog[10] 可以编译通过,但添加非 Dog 元素时会在运行时抛出 ArrayTypeMismatchException。对于协变只读访问,请使用 IReadOnlyList<T> 或 IEnumerable<T>。
  7. 方法应接受其所需的最窄接口类型。 遍历时使用 IEnumerable<T>,按索引只读访问时使用 IReadOnlyList<T>,仅在需要修改时使用 IList<T>。这符合 ISP,也能提高方法的复用性。

知识来源

此技能中的 SOLID 和 DRY 指引基于以下公开内容:

  • Steve Smith(Ardalis)的 SOLID 原则——在 .NET 中实践 SOLID,包括守卫子句模式、用于遵循 OCP 的规约模式,以及在项目边界处落实 DIP 的整洁架构分层。来源:https://ardalis.com/
  • Jimmy Bogard 的领域驱动设计模式——富领域模型指引将 SRP 应用于聚合设计(每个限界上下文一个聚合根),并将 OCP 应用于领域事件处理(添加新处理程序而无需修改现有处理程序)。注意:MediatR 用于商业用途时需要付费;请尽可能使用内置机制应用这些模式。来源:https://www.jimmybogard.com/

**注意:**此技能采用了公开文档中的指引,不代表或代言上述来源。

参考资料

致谢

改编自 Aaronontheweb/dotnet-skills(MIT 许可证)。

评论

支持 Markdown