Recep Şen

TB1REC

CTO @Taptoweb

Taptoweb의 CTO로서, 250,000명 이상의 사용자를 보유한 노코드 모바일 앱 빌더 Easyapp.ai를 구동하는 28개 서비스 마이크로서비스 플랫폼을 설계했습니다. .NET/C#, 도메인 주도 설계, 에이전틱 AI 시스템을 전문으로 합니다.

이력서 다운로드
Recep Şen - CTO profile photo
← Back to Blog
The Modern Way to Manage C# Business Rules: Rule Engine Pattern
· 8 min read

The Modern Way to Manage C# Business Rules: Rule Engine Pattern

A modern C# pattern for managing business rules that provides centralized control, high testability, and optimal performance through the Result Pattern.

In the modern software world, managing business rules is becoming increasingly critical. Especially in sectors like finance, e-commerce, and healthcare, managing, maintaining, and testing hundreds or even thousands of business rules has become a significant challenge. Scattered implementation of these rules leads to many problems such as duplicate code, inconsistent error messages, and testability issues.

In this article, we’ll explore a modern and effective way to manage business rules in the C# world: Rule Engine Pattern. With this pattern, you can:
- 🎯 Centralize your business rules
- 🎯 Make your code more testable
- 🎯 Reduce maintenance costs
- 🎯 Optimize performance

🤔 Problem and Solution: Rule Engine Pattern

Challenges of Traditional Approaches

**Technical Aspects
**- ❌ Scattered and duplicate validation code
- ❌ Low test coverage and difficult testability
- ❌ Poor error handling and inconsistent messages
- ❌ Performance issues
- ❌ Complex and difficult maintainability

**Business Process Aspects
**- ❌ Insufficient documentation
- ❌ Slow adaptation to changing rules
- ❌ Complex rule dependencies
- ❌ Lack of centralized management

**Solution with Rule Engine Pattern
**The Rule Engine Pattern solves these problems by:

- ✅ Centralized Management: All rules are managed from a single place
- ✅ High Testability: Each rule can be tested independently
- ✅ Easy Maintenance: Rules are modular and follow single responsibility principle
- ✅ Type Safety: Secure operations with compile-time type checking
- ✅ Performance: Optimum performance with record struct and immutable design
- ✅ Consistent Error Handling: Transparent and rich error details with Result pattern
- ✅ Quick Adaptation: New rules can be easily added and modified
- ✅ Clean Code: SOLID-compliant, readable, and maintainable code

💡 Core Components of Rule Engine Pattern

**1. Result Pattern
**The Result Pattern, which is the heart of the Rule Engine, is the key to modern error handling. By using Result type instead of exceptions:
- 🎯 You prevent performance loss
- 🎯 Make your code more predictable
- 🎯 Better manage error cases

2. Rule Types and Definition Approaches

The Rule Engine supports both OOP and functional programming approaches. You can define rules using the following structures:

public sealed class UserRule : IRule<User> { }
public sealed record UserRule : IRule<User> { }
public struct UserRule : IRule<User> { }
public readonly record struct UserRule : IRule<User> { }

Advantages of using `readonly record struct`:
- ✅ No heap allocation as it’s a value type
- ✅ Thread-safe due to immutability
- ✅ Easy implementation with record syntax
- ✅ Performance optimization with readonly
- ✅ Small memory footprint

**3. Rule Interfaces
**The Rule Engine provides specialized interfaces for different scenarios:

**3.1. Simple Rules
**Rules that perform a single validation:

IRule<TContext>
IRule<TContext, TResult>
IAsyncRule<TContext>

IAsyncRule<TContext, TResult>

internal readonly record struct AdultRule : IRule<User>{
    public Result Evaluate(User context) =>
        context.Age >= 18
            ? Result.Success()
            : Error.Validation("USER.NOT_ADULT", "User must be 18 or older");
}

**3.2. Linear Rules
**Chain of rules that follow each other:

ILinearRule<TContext>
ILinearRule<TContext, TResult>
ILinearAsyncRule<TContext>

ILinearAsyncRule<TContext, TResult>

internal readonly record struct EmailFormatRule : ILinearRule<string>{
    public IRuleBase<string>? Next => new DomainRule();
    public Result Evaluate(string email) =>
        email.Contains('@')
            ? Result.Success()
            : Error.Validation("EMAIL.INVALID_FORMAT", "Email must contain @");
}

**3.3. Logical Rules
**Rules that can be combined with AND/OR operators:

IAndRule<TContext>
IAndRule<TContext, TResult>
IAndAsyncRule<TContext>
IAndAsyncRule<TContext, TResult>
IOrRule<TContext>

IOrRule<TContext, TResult>
IOrAsyncRule<TContext>
IOrAsyncRule<TContext, TResult>

internal readonly record struct PaymentMethodRule : IOrRule<Payment>{
    public IRuleBase<Payment>[] Rules =>
    [
        new CreditCardRule(),
        new BankTransferRule(),
        new CryptoRule()
    ];
}

**3.4. Conditional Rules
**Rules that can branch based on the result:

IConditionalRule<TContext>

IConditionalRule<TContext, TResult>
IConditionalAsyncRule<TContext>
IConditionalAsyncRule<TContext, TResult>

internal readonly record struct CardTypeRule : IConditionalRule<CreditCard>{
    public IRuleBase<CreditCard>? Success => new AmexRule();
    public IRuleBase<CreditCard>? Failure => new MasterCardRule();
    public Result Evaluate(CreditCard context) =>
        context.Number.Length == 15
            ? Result.Success()
            : Result.Failure();
}

🚀 Implementation Examples

1. E-Commerce Order Validation

public readonly record struct OrderValidationRule : IAndRule<Order>{
    private readonly IStockService _stockService;
    private readonly IPaymentService _paymentService;
    public OrderValidationRule(IStockService stockService, IPaymentService paymentService)
    {
        _stockService = stockService;
        _paymentService = paymentService;
    }
    public IRuleBase<Order>[] Rules =>
    [
        new OrderAmountRule(minimumAmount: 50),
        new StockAvailabilityRule(_stockService),
        new PaymentMethodValidationRule(_paymentService),
        new ShippingAddressRule(),
        new UserValidationRule()
    ];
}

2. Finance: Credit Application

public readonly record struct CreditApplicationRule : ILinearRule<CreditApplication>{
    private readonly ICreditScoreService _creditScoreService;
    private readonly IBlacklistService _blacklistService;
    public CreditApplicationRule(ICreditScoreService creditScoreService, IBlacklistService blacklistService)
    {
        _creditScoreService = creditScoreService;
        _blacklistService = blacklistService;
    }
    public IRuleBase<CreditApplication>? Next => new CreditScoreRule(_creditScoreService);
    public async ValueTask<Result> EvaluateAsync(CreditApplication application)
    {
        var blacklistResult = await _blacklistService.CheckAsync(application.UserId);
        if (blacklistResult.IsBlacklisted)
            return Error.Validation("CREDIT.BLACKLISTED", "User is blacklisted");
        if (application.Age < 18)
            return Error.Validation("CREDIT.UNDERAGE", "Must be 18 or older");
        if (application.MonthlyIncome < 5000)
            return Error.Validation(
                code: "CREDIT.LOW_INCOME",
                description: "Insufficient monthly income",
                metadata: new ErrorMetadata(
                    ("MinimumIncome", 5000),
                    ("ActualIncome", application.MonthlyIncome)
                )
            );
        return Result.Success();
    }
}

🎯 Best Practices

  1. **Single Responsibility Principle
    **Each rule should check only one thing.
  2. **Centralized Error Management
    **Define error objects in a central place instead of creating them in methods:
internal static class UserErrors{
    public static Error NotAdult => Error.Validation(
        code: "USER.NOT_ADULT",
        description: "User is not adult"
    );
    public static Error InvalidSalary(decimal minSalary, decimal actualSalary) => Error.Validation(
        code: "USER.INVALID_SALARY",
        description: "User has insufficient salary",
        metadata: new ErrorMetadata(
            new KeyValuePair<string, object?>("MinSalary", minSalary),
            new KeyValuePair<string, object?>("ActualSalary", actualSalary)
        )
    );
}
public Result Evaluate(User context)
{
    if (context.Age < 18)
        return UserErrors.NotAdult;
    if (context.Salary < 5000)
        return UserErrors.InvalidSalary(5000, context.Salary);
    return Result.Success();
}

Advantages of this approach:
- ✅ Centralized management of error codes and messages
- ✅ Prevents code duplication
- ✅ Consistent error messages
- ✅ Easy maintenance and updates
- ✅ IntelliSense support

3. Descriptive Error Codes
-
Domain/operation-based grouping (USER.*, PAYMENT.*, ORDER.*)
- Meaningful and descriptive codes (NOT_FOUND, INVALID_FORMAT, INSUFFICIENT_FUNDS)
- Consistent naming convention

4. **Rich Metadata Usage
**- Enrich error details with metadata
- Add useful information for debugging and logging
- Provide helpful details to the client

5. Testability
-
Each rule should be independently testable
- Write separate test cases for error conditions
- Test metadata values as well

6. Performance
-
Minimize heap allocation using record struct
- Reduce object creation with static error definitions
- Avoid unnecessary string concatenation

7. **Rule Definitions
**- Preferably use `readonly record struct`
- Design immutable rule state
- Manage dependencies with constructor injection
- Keep rules small and focused

🔄 Functional Style Uses

**AND Rules
**Cases where all rules must succeed:

Result result = RuleEngine.And(
    rules: [
        UserRules.ActiveCheck,
        UserRules.AdultCheck,
        UserRules.SalaryCheck,
        UserRules.CoupleCheck
    ],
    context: user);
Result result = RuleEngine.And(
    rules: [
        input => input % 2 == 0,
        input => input % 3 == 0
    ],
    context: 120);

**OR Rules
**Cases where at least one rule must succeed:

Result result = RuleEngine.Or(
    rules: [
        PaymentRules.CreditCardCheck,
        PaymentRules.BankTransferCheck
    ],
    context: payment);
Result result = RuleEngine.Or(
    rules: [
        input => input > 0,
        input => input % 2 == 0
    ],
    context: 120);

**Linear Rules
**Cases requiring sequential validation:

Result result = RuleEngine.Linear(
    rules: [
        EmailRules.EmptyCheck,
        EmailRules.AtSignCheck,
        EmailRules.LocalPartCheck,
        EmailRules.DomainCheck
    ],
    context: email);
Result result1 = RuleEngine.Linear(
    rules: [
        input => input > 0,
        input => input < 100,
        input => input % 2 == 0,
        input => input % 3 == 0
    ],
    context: 120);
Result result2 = RuleEngine.Linear(
    rules: [
        input => input > 0
            ? Result.Success()
            : Error.Validation("input_greater_than_0"),
        input => input < 100
            ? Result.Success()
            : Error.Validation("input_less_than_100"),
        input => input % 2 == 0
            ? Result.Success()
            : Error.Validation("input_even"),
        input => input % 3 == 0
            ? Result.Success()
            : Error.Validation("input_multiple_of_3")
    ],
    context: 120);

Conditional Rules

Result result = RuleEngine.If(
    rule: input => input > 0,
    success: input => input < 100,
    failure: input => input % 2 == 0,
    context: 120);

🎯 Rule Types and Function Signatures

1. OOP Rule Types

1.1. Simple Rules

IRule<TContext>
IRule<TContext, TResult>
IAsyncRule<TContext>
IAsyncRule<TContext, TResult>

1.2. Linear Rules

ILinearRule<TContext>
ILinearRule<TContext, TResult>
ILinearAsyncRule<TContext>
ILinearAsyncRule<TContext, TResult>

1.3. OR Rules

IOrRule<TContext>
IOrRule<TContext, TResult>
IOrAsyncRule<TContext>
IOrAsyncRule<TContext, TResult>

1.4. AND Rules

IAndRule<TContext>
IAndRule<TContext, TResult>
IAndAsyncRule<TContext>
IAndAsyncRule<TContext, TResult>

1.5. Conditional Rules

IConditionalRule<TContext>
IConditionalRule<TContext, TResult>
IConditionalAsyncRule<TContext>
IConditionalAsyncRule<TContext, TResult>

2. Functional Approach Signatures

2.1. Simple Function Signatures

Func<TContext, Result>
Func<TContext, CancellationToken, Result>
Func<TContext, CancellationToken, ValueTask<Result>>

2.2. Generic Result Returning Functions

Func<TContext, Result<TResult>>
Func<TContext, CancellationToken, Result<TResult>>
Func<TContext, CancellationToken, ValueTask<Result<TResult>>>

This rich type system allows you to:
- ✅ Choose the appropriate rule type for each scenario
- ✅ Mix sync/async operations
- ✅ Enhance type safety with generic results
- ✅ Improve resource management with cancellation token support
- ✅ Use both functional and OOP approaches together

🚀 Conclusion

The Rule Engine Pattern helps make your business rules:
- ✅ More organized
- ✅ Easier to maintain
- ✅ More testable
- ✅ More performant

Using this pattern, you can centralize your validation logic and improve your code quality.

🔗 Useful Links

📦 NuGet Package
💻 GitHub Repository
📚 All My NuGet Packages

🤝 Open Source Contribution

I’ve shared the project as open source on GitHub. You can examine the source code, fork the project if you want to contribute, and send pull requests. If you encounter any issues or have feature suggestions, you can open an issue on GitHub. If you want to try it and provide feedback, I’m looking forward to your comments!

Visit our GitHub repository for the examples we’ve seen in this article and more. Don’t forget to leave your comments for questions and suggestions 👋

이메일 보내기
WhatsApp 메시지 보내기