Recep Şen

TB1REC

CTO @Taptoweb

CTO at Taptoweb, where I designed a 28-service microservices platform powering Easyapp.ai - a no-code mobile app builder with 250,000+ users. I specialize in .NET/C#, Domain-Driven Design, and agentic AI systems.

Download CV
Recep Şen - CTO profile photo
← Back to Blog
The Modern Way to Manage C# Business Rules: Rule Engine Pattern
· 11 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.

I've been building business applications in C# for a long time. Finance, e-commerce, healthcare — every domain comes with its own set of rules. "The user must be 18 or older." "The order total must exceed the minimum threshold." "The credit applicant must not be on the blacklist." These rules start simple. A few if statements here, a validation method there. But they never stay simple.

When I first encountered this problem at scale, I was working on a project with hundreds of business rules scattered across dozens of services. The same validation logic was duplicated in three different places, each with slightly different error messages. One service checked the user's age with >=18, another with >17. A bug in one place would get fixed, but the copy in another service would keep failing silently. Testing was a nightmare because the rules were buried inside thick method bodies with six levels of nesting.

That experience pushed me to find a better way. What I landed on was the Rule Engine Pattern — a pattern that treats business rules as first-class, composable objects rather than scattered conditionals. Combined with the Result Pattern for error handling, it transformed how I think about domain validation in C#.

The problem with traditional approaches

The issues I ran into were not unique. Most C# codebases that grow beyond a certain size develop the same symptoms.

On the technical side, validation code gets scattered and duplicated. Test coverage drops because the rules are tangled with business logic that's hard to isolate. Error handling becomes inconsistent — one method throws an ArgumentException, another returns a boolean, a third writes to a log and continues silently. Performance degrades as exception-based control flow accumulates overhead.

On the business process side, there's no single place to look at all the rules governing a particular entity. When regulations change, you're grepping through the codebase hoping you found every spot that needs updating. Rule dependencies become implicit and fragile. New team members have no documentation to fall back on because the rules are encoded in imperative code that only makes sense with full context.

The Rule Engine Pattern addresses all of these by giving each rule a dedicated, testable unit with a consistent interface. Rules become centralized, modular, and independently verifiable. The Result Pattern replaces exception-based error handling with explicit, type-safe return values — no performance penalty, no invisible failure modes, no inconsistent messages.

Core components of the Rule Engine Pattern

The Result Pattern

At the heart of the Rule Engine is the Result Pattern. Instead of throwing exceptions for expected business errors, every rule evaluation returns a Result — either success or failure with structured error details. This matters for three reasons: you eliminate the performance overhead of exception unwinding, you make error paths explicit and predictable, and you gain consistent error messages across the entire application.

Rule types and definition approaches

The Rule Engine supports both object-oriented and functional programming approaches. You can define rules using any of these 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> { }

I strongly recommend readonly record struct for most cases. Because it's a value type, there's no heap allocation. Immutability makes it inherently thread-safe. The record syntax keeps the implementation concise. The readonly modifier enables additional compiler optimizations. And the memory footprint is minimal. In my benchmarks, the difference between value-type rules and class-based rules was substantial — the same pattern I documented in my parameter-passing performance analysis.

Rule interfaces

The Rule Engine provides specialized interfaces for different validation scenarios, and choosing the right one matters.

Simple rules perform a single validation. They're the building blocks — one rule, one check, one result:

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");
}

Linear rules form a chain where each rule points to the next. Evaluation stops at the first failure. This is the chain-of-responsibility pattern applied to validation — useful when later rules depend on earlier ones passing:

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 @");
}

Logical rules combine multiple rules with AND/OR semantics. AND rules require all to pass; OR rules require at least one. This is where compositional power really shines — you can build complex validation trees from simple, testable pieces:

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()
    ];
}

Conditional rules branch based on the result of an evaluation. If the condition passes, one rule executes; if it fails, another does. Think of it as an if/else at the rule composition level:

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

Theory is useful, but real-world examples are what matter. Here are two scenarios I've actually implemented in production systems.

E-Commerce: Order validation

An order must pass several checks before it can be processed. Stock must be available, the payment method must be valid, the shipping address must be complete, and the order amount must exceed a minimum. With AND rules, all of these run and all failures are reported together:

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()
    ];
}

Finance: Credit application

A credit application has a natural sequence: first check the blacklist, then verify the age, then validate income, then check the credit score. Linear rules handle this elegantly, stopping at the first failure because there's no point checking a credit score for someone who's on the blacklist:

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

Over time, I've settled on a set of practices that consistently lead to cleaner, more maintainable rule engines.

Single responsibility. Each rule should check exactly one thing. When a rule starts doing two checks, split it. The composability of the pattern makes this cheap.

Centralized error management. Don't create error objects inside rule methods. Define them in a central location so that error codes and messages stay consistent across the entire codebase:

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();
}

This approach centralizes error codes and messages, prevents duplication, keeps messages consistent, simplifies maintenance, and gives you IntelliSense support throughout the project.

Descriptive error codes. Group error codes by domain and operation — USER.*, PAYMENT.*, ORDER.*. Use meaningful names like NOT_FOUND, INVALID_FORMAT, INSUFFICIENT_FUNDS. Stick to a consistent naming convention across the entire system.

Rich metadata. Don't just say "insufficient income." Include the minimum required and the actual amount in the metadata. This makes debugging, logging, and client-side error rendering dramatically easier.

Testability. Each rule is independently testable by design. Write separate test cases for success, failure, and edge conditions. Don't forget to assert on metadata values — they're part of the contract.

Performance. Use record struct to minimize heap allocation. Use static error definitions to reduce object creation. Avoid string concatenation in error messages — use pre-built error objects instead.

Rule definitions. Prefer readonly record struct unless you have a specific reason not to. Design rule state to be immutable. Manage dependencies through constructor injection. Keep each rule small and focused.

Functional style usage

The Rule Engine also supports a functional API for cases where you don't want to define separate rule classes. This is particularly useful for ad-hoc validations or when rules are simple enough that a full class would be overkill.

AND rules — all must pass:

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 — at least one must pass:

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 — sequential validation that stops at the first failure:

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 — branching based on evaluation:

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

Rule types and function signatures

For reference, here is the complete type system the Rule Engine provides.

OOP Rule Types

Simple rules:

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

Linear rules:

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

OR rules:

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

AND rules:

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

Conditional rules:

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

Functional Approach Signatures

Simple function signatures:

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

Generic result returning functions:

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

This rich type system lets you choose the appropriate rule type for each scenario, mix synchronous and asynchronous operations freely, enhance type safety with generic results, manage resources properly with cancellation token support, and use functional and OOP approaches together in the same codebase.

Conclusion

The Rule Engine Pattern changed how I approach business validation in C#. What used to be scattered if chains buried in service methods became composable, testable, self-documenting rule objects. The Result Pattern eliminated the guesswork around error handling — no more wondering whether a method throws or returns null or silently logs.

If your codebase has business rules spread across multiple layers, if your validation error messages are inconsistent, if testing your domain logic requires setting up the entire application context — this pattern is worth adopting. Start with one domain entity, extract its rules into composable objects, and see how it feels. In my experience, once the pattern is in place, it becomes the obvious way to add new rules.

The project is open source. You can examine the source code, fork it, and contribute.

Send email
Send WhatsApp message