Business rules in C# belong with the domain state they protect. Use guard clauses for simple preconditions, rule objects for reusable policies, results for expected failures, value objects for structural constraints, and factories for creation rules. These approaches work together so every supported state change preserves the model's invariants.
Where Do Business Rules Live?
Every codebase enforces business rules somewhere. The problem is that "somewhere" is usually everywhere: controllers, application services, stored procedures, and the occasional JavaScript check on the front end.
When the same rule lives in three places, the three copies eventually disagree.
The fix is to make rules first-class citizens of your domain model. Five implementation patterns cover most cases, from simple guard clauses to rule objects and result-based validation.
If you want the conceptual background on what invariants are and why the domain model is the right place to enforce them, I wrote about that in what invariants are. This article is about the implementation patterns.
A few rules we'll use as running examples:
- An order must have at least one line item when confirmed
- A bank account balance cannot go below zero
- A meeting cannot have more attendees than its capacity
- A discount percentage must be between 0 and 100
Option 1: Guard Clauses
The simplest approach: check the rule, throw if it's broken.
The examples are alternatives for the same domain, not classes to paste into one project together.
They assume application-defined AggregateRoot, DomainException, domain events, and the Result types from the linked Result-pattern article:
public class Order : AggregateRoot
{
public void Confirm()
{
if (_lineItems.Count == 0)
throw new DomainException("Cannot confirm an empty order.");
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be confirmed.");
Status = OrderStatus.Confirmed;
}
}
Guard clauses are perfect for simple rules with one or two checks.
The downside shows up as rules multiply. The checks are anonymous if-statements scattered across methods. You can test those checks through the entity's public methods, but extracting a reusable policy becomes useful when the same condition appears in several operations.
Option 2: Business Rule Objects
Encapsulate each rule as an object with a name, a message, and a single responsibility:
public interface IBusinessRule
{
string Message { get; }
bool IsBroken();
}
public class OrderMustHaveLineItemsRule : IBusinessRule
{
private readonly IReadOnlyCollection<LineItem> _lineItems;
public OrderMustHaveLineItemsRule(
IReadOnlyCollection<LineItem> lineItems)
{
_lineItems = lineItems;
}
public string Message => "Order must have at least one line item.";
public bool IsBroken() => _lineItems.Count == 0;
}
public class OrderMustBeInDraftStatusRule : IBusinessRule
{
private readonly OrderStatus _status;
public OrderMustBeInDraftStatusRule(OrderStatus status)
{
_status = status;
}
public string Message => "Order must be in draft status.";
public bool IsBroken() => _status != OrderStatus.Draft;
}
Add a CheckRule method to the base entity:
public abstract class Entity
{
protected static void CheckRule(IBusinessRule rule)
{
if (rule.IsBroken())
{
throw new BusinessRuleValidationException(rule);
}
}
}
public sealed class BusinessRuleValidationException : Exception
{
public BusinessRuleValidationException(IBusinessRule rule)
: base(rule.Message) { }
}
Use it in the aggregate root:
public class Order : AggregateRoot
{
public void Confirm()
{
CheckRule(new OrderMustBeInDraftStatusRule(Status));
CheckRule(new OrderMustHaveLineItemsRule(_lineItems));
Status = OrderStatus.Confirmed;
RaiseDomainEvent(new OrderConfirmedDomainEvent(Id));
}
}
Now every rule has a name that appears in the code, in test names, and in exception messages.
The Confirm method reads like a list of preconditions.
Rule objects also unit test beautifully:
[Fact]
public void Rule_is_broken_when_order_has_no_line_items()
{
var rule = new OrderMustHaveLineItemsRule(Array.Empty<LineItem>());
Assert.True(rule.IsBroken());
}
The test supplies only the state that the rule needs.
Option 3: Result-Based Validation
Returning a result makes an expected failure visible in the method's contract. For example, a user might try to confirm an empty order.
For expected failures, return a result instead. This is the result pattern:
public class Order : AggregateRoot
{
public Result Confirm()
{
if (Status != OrderStatus.Draft)
return Result.Failure(OrderErrors.NotDraft);
if (_lineItems.Count == 0)
return Result.Failure(OrderErrors.NoLineItems);
Status = OrderStatus.Confirmed;
RaiseDomainEvent(new OrderConfirmedDomainEvent(Id));
return Result.Success();
}
}
public static class OrderErrors
{
public static readonly Error NotDraft =
new("Order.NotDraft", "Order is not in draft status.");
public static readonly Error NoLineItems =
new("Order.NoLineItems", "Order has no line items.");
public static Error NotFound(Guid id) =>
new("Order.NotFound", $"Order '{id}' was not found.");
}
The handler decides how to surface the error:
public async Task<Result<Guid>> Handle(
ConfirmOrderCommand command, CancellationToken ct)
{
var order = await _orderRepository.GetByIdAsync(command.OrderId, ct);
if (order is null)
return Result.Failure<Guid>(OrderErrors.NotFound(command.OrderId));
var result = order.Confirm();
if (result.IsFailure)
return Result.Failure<Guid>(result.Error);
await _unitOfWork.SaveChangesAsync(ct);
return order.Id;
}
Each error has a code (Order.NotDraft) that the application can map to an API response.
The return type advertises failure, but C# still allows a caller to ignore it.
Option 4: Value Objects for Structural Rules
Some rules aren't about operations at all. They're about what values are allowed to exist. Value objects enforce those constraints at the type level:
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public static Money Create(decimal amount, string currency = "USD")
{
if (amount < 0)
throw new DomainException("Money amount cannot be negative.");
if (string.IsNullOrWhiteSpace(currency))
throw new DomainException("Currency is required.");
return new Money(amount, currency.Trim().ToUpperInvariant());
}
public static Money Zero(string currency = "USD") =>
Create(0, currency);
}
public sealed record EmailAddress
{
public string Value { get; }
private EmailAddress(string value) => Value = value;
public static EmailAddress Create(string email)
{
if (string.IsNullOrWhiteSpace(email))
throw new DomainException("Email is required.");
var trimmed = email.Trim();
if (trimmed.Length > 254 ||
!System.Net.Mail.MailAddress.TryCreate(trimmed, out var address) ||
address.Address != trimmed)
throw new DomainException("Invalid email format.");
return new EmailAddress(trimmed);
}
}
Money.Zero delegates to Create, so it cannot bypass currency validation.
This example permits any nonblank currency label; a domain that requires supported ISO currency codes should validate against its allowed set.
Non-negative money is a rule for this model, not a universal rule for accounting amounts.
MailAddress.TryCreate checks address syntax, not ownership or deliverability.
The factory preserves casing and rejects display-name forms; an email verification flow remains a separate concern.
These factories establish local constraints, while callers still handle null references and operation-specific rules.
Value objects also handle rules that span multiple properties:
public sealed record DateRange
{
public DateTime Start { get; }
public DateTime End { get; }
private DateRange(DateTime start, DateTime end)
{
Start = start;
End = end;
}
public static DateRange Create(DateTime start, DateTime end)
{
if (end <= start)
throw new DomainException("End date must be after start date.");
return new DateRange(start, end);
}
public bool Overlaps(DateRange other) =>
Start < other.End && other.Start < End;
}
There's no way to construct a DateRange that ends before it starts.
The factory enforces the interval rule in one place.
The overlap check treats ranges as half-open intervals, so touching endpoints do not overlap.
That constructor discipline is the foundation of an always-valid domain model.
Option 5: Factory Methods for Creation Rules
Entities should be born valid. A factory method enforces creation rules and combines naturally with result-based methods afterward:
public class Meeting : AggregateRoot
{
private readonly List<Attendee> _attendees = [];
private Meeting() { }
public string Title { get; private set; } = string.Empty;
public int Capacity { get; private set; }
public MeetingStatus Status { get; private set; }
public static Meeting Create(string title, int capacity)
{
if (string.IsNullOrWhiteSpace(title))
throw new DomainException("Meeting title is required.");
if (capacity < 2)
throw new DomainException("Meeting must allow at least 2 attendees.");
if (capacity > 100)
throw new DomainException("Meeting cannot exceed 100 attendees.");
return new Meeting
{
Id = Guid.NewGuid(),
Title = title.Trim(),
Capacity = capacity,
Status = MeetingStatus.Scheduled
};
}
public Result AddAttendee(Guid userId, string name)
{
if (userId == Guid.Empty || string.IsNullOrWhiteSpace(name))
return Result.Failure(new Error(
"Meeting.InvalidAttendee", "Attendee ID and name are required."));
if (_attendees.Count >= Capacity)
return Result.Failure(
MeetingErrors.AtCapacity(Capacity));
if (_attendees.Any(a => a.UserId == userId))
return Result.Failure(
MeetingErrors.AlreadyAttending);
if (Status != MeetingStatus.Scheduled)
return Result.Failure(
MeetingErrors.NotScheduled);
_attendees.Add(new Attendee(userId, name));
return Result.Success();
}
}
The private constructor means the factory method is the only way in.
There is no code path that produces a Meeting with a blank title or a capacity of one.
Business Rules vs Input Validation
Don't confuse business rules with input validation. They answer different questions:
- Input validation asks: is this data well-formed? Required fields, formats, lengths, ranges. It belongs at the API boundary, typically with FluentValidation.
- Business rules ask: is this operation allowed right now? They often depend on current state (order status, remaining capacity) and belong in the domain model.
"Email must not be empty" can be both a request check and an EmailAddress invariant.
"A cancelled customer cannot place orders" depends on customer state and belongs in a domain policy.
Validate requests early for useful error messages, but let the domain enforce its own structural and state-dependent rules. Some checks intentionally appear at both boundaries because background jobs and message consumers may skip the HTTP pipeline. Cross-aggregate checks also need a consistency policy; an in-memory rule does not prevent another request from changing the database before you save.
Choosing the Right Approach
Here's how I decide:
- Guard clauses: simple rules with one or two checks, or violations that indicate a programming bug
- Business rule objects: complex rules that deserve their own tests, or rules reused across multiple operations
- Result pattern: operations where failure is an expected outcome the caller must handle
- Value objects: structural constraints on individual values or small groups of related values
- Factory methods: rules that must hold at creation time
These compose. In a typical aggregate I use value objects for structural integrity, a factory method for creation, result-based methods for state transitions, and guard clauses inside private helpers.
Summary
Business rules deserve better than scattered if-statements:
- Never let an entity exist in an invalid state - validate in constructors and factory methods
- Value objects centralize structural constraints - supported money amounts, email syntax, and valid date ranges
- Rule objects make rules explicit, named, and testable
- Return Results for operations that can fail - don't throw exceptions for expected scenarios
- The domain model enforces the rules - not the application service, not the controller
Use database concurrency checks and constraints alongside domain validation when concurrent operations can violate a rule.
Thanks for reading.
And stay awesome!
Frequently Asked Questions
What is the business rules pattern in C#?
It is a way of encapsulating each business rule as its own object with an IsBroken method and a message. The entity checks rules through a CheckRule helper, which makes rules explicit, testable, and reusable instead of scattering if-statements across methods.
Should business rules throw exceptions or return results?
Return a Result for an expected failure the caller should handle, such as confirming an empty order. Use an exception when the API contract treats a violation as a programming error. Missing boundary validation alone does not determine that choice.
What is the difference between input validation and business rules?
Input validation checks the shape of incoming data (required fields, formats, ranges) and belongs at the API boundary. Business rules express domain decisions that can depend on current state, and they belong inside the domain model.
Where should business rules live in Clean Architecture?
In the domain layer, inside entities, aggregates, and value objects. Application services orchestrate use cases but should not contain the rules themselves.
How do you unit test business rules?
Rule objects are plain classes, so you construct them with the relevant state and assert on IsBroken. Rules implemented in entities are tested by driving the entity through its public methods and asserting on the returned result or thrown exception.



