1 Why “Practical OOP” in Modern C#
Modern C# is a different language than it was a decade ago. Between records, pattern matching, primary constructors, and the shift toward functional-style immutability, object-oriented programming (OOP) in .NET no longer means deep inheritance trees or abstract base classes. Today’s practical OOP is leaner, more composable, and more predictable—built for testability and safe evolution rather than inheritance-driven hierarchies.
The goal of this section is simple: to show why these shifts matter, what they replace, and how they make your C# code both easier to reason about and easier to extend.
1.1 The Problem with Traditional Inheritance-First Designs
For decades, OOP education emphasized inheritance as the mechanism for reuse. The result: hierarchies that model behavior through “is-a” relationships that are often misleading in real-world domains.
1.1.1 Tight Coupling and Fragile Hierarchies
Consider a classic example—payment gateways:
public abstract class PaymentGateway
{
public abstract Task ProcessAsync(Order order);
}
public class StripeGateway : PaymentGateway
{
public override Task ProcessAsync(Order order)
=> Console.WriteLine("Processing with Stripe");
}
public class PaypalGateway : PaymentGateway
{
public override Task ProcessAsync(Order order)
=> Console.WriteLine("Processing with PayPal");
}
Seems fine—until you need retries, caching, or metrics. Suddenly you’re adding shared logic in the base class:
public abstract class PaymentGateway
{
protected readonly ILogger Logger;
protected PaymentGateway(ILogger logger) => Logger = logger;
public async Task ProcessAsync(Order order)
{
Logger.LogInformation("Processing...");
await ProcessCoreAsync(order);
}
protected abstract Task ProcessCoreAsync(Order order);
}
Now every derived class is forced into the same logging lifecycle. Want a gateway that doesn’t log? Tough—it’s part of the base. Want a different retry policy? You’ll duplicate code or create yet another intermediate base. This is how inheritance grows uncontrollably.
A single change in the base class can ripple across dozens of types. In large systems, that fragility slows down refactoring and kills confidence in tests.
1.1.2 Mocking Pain and Test Friction
Inheritance-based designs often rely on protected virtual methods and complex partial overrides. That makes testing painful.
Mocking frameworks struggle with deep hierarchies; fakes must reproduce intricate behavior chains. Composition-based designs, by contrast, swap dependencies via interfaces and are trivial to mock or stub.
Incorrect:
var gateway = new TestGateway(); // Subclass needed for testing
Correct:
var gateway = new ResilientPaymentGateway(
new StripeGateway(httpClient),
new RetryPolicy());
Testing becomes a matter of substituting interfaces rather than extending base classes.
1.1.3 Evolution Bottlenecks
Inheritance encodes design decisions in type hierarchies—hard to change later. Once consumers depend on a base class, you can’t easily introduce new constructor parameters, alter virtual methods, or add required state.
Composition keeps those seams explicit. A new behavior becomes a new component, not a breaking change.
1.2 Composition as the Default: Smaller Types, Clearer Seams, Safer Change
Composition—building types from smaller, replaceable parts—has always existed in OOP, but in modern C# it’s the default choice. Instead of creating deep hierarchies, we wire together focused behaviors via interfaces, records, and strategies.
1.2.1 Smaller Types, Clearer Responsibilities
Each type does one job. You replace “inherit and override” with “compose and delegate.”
public interface IPaymentProcessor
{
Task ProcessAsync(Order order);
}
public class LoggingProcessor : IPaymentProcessor
{
private readonly IPaymentProcessor _inner;
private readonly ILogger _logger;
public LoggingProcessor(IPaymentProcessor inner, ILogger logger)
{
_inner = inner;
_logger = logger;
}
public async Task ProcessAsync(Order order)
{
_logger.LogInformation("Processing order {Id}", order.Id);
await _inner.ProcessAsync(order);
}
}
Want to add resilience or metrics? Wrap it again—no base classes required.
1.2.2 Clear Seams for Substitution
Composition makes dependencies visible through constructors. There’s no magic or inheritance coupling; dependencies are contracts, not ancestors.
services.Decorate<IPaymentProcessor, LoggingProcessor>();
services.Decorate<IPaymentProcessor, ResilientProcessor>();
With this model, adding or removing behavior is a DI configuration change, not a rewrite.
1.2.3 Safer Change and Parallel Work
Since components communicate through interfaces, teams can evolve implementations independently. You can replace a rule engine, persistence layer, or API adapter without touching core logic.
1.3 Records and Value Semantics: Fewer Bugs via Immutability and Equality
Records changed how we model data in C#. They encourage value semantics—treating objects as immutable values rather than mutable entities.
1.3.1 Why Value Semantics Matter
Mutable classes invite subtle bugs:
order.Total += discount; // Hidden side effect
Immutable records eliminate this category entirely:
var updatedOrder = order with { Total = order.Total - discount };
Now every “change” produces a new value. This approach scales beautifully for concurrent and functional-style programming.
1.3.2 Built-in Structural Equality
Records generate Equals, GetHashCode, and ToString automatically—based on properties, not reference identity. That’s crucial for testing, deduplication, and domain invariants.
public record Money(decimal Amount, string Currency);
var a = new Money(100, "USD");
var b = new Money(100, "USD");
Console.WriteLine(a == b); // true
1.3.3 Fewer Nulls, More Domain Clarity
With records, you can design value objects that guarantee correctness at construction. Instead of passing around nullable strings or decimals, you model explicit types—Email, Money, OrderId—that cannot exist in invalid states.
1.4 Pattern Matching as Modern Polymorphism
C#’s pattern matching features—especially from versions 9 through 13—offer a new form of polymorphism. Instead of pushing behavior into class hierarchies, you keep data in simple record hierarchies and project behavior outward using switch expressions.
1.4.1 Data-Driven Polymorphism
public abstract record PaymentMethod;
public record CreditCard(string Number) : PaymentMethod;
public record PayPal(string Email) : PaymentMethod;
public string Describe(PaymentMethod method) => method switch
{
CreditCard(var number) => $"Credit Card ending with {number[^4..]}",
PayPal(var email) => $"PayPal ({email})",
_ => "Unknown method"
};
Adding a new CryptoWallet type now triggers a compiler warning until you update the switch. This is exhaustiveness checking, a safer, more explicit polymorphism than relying on virtual dispatch.
1.4.2 When to Use Pattern Matching vs. Strategy
Use pattern matching when:
- Variants are finite and known at compile time.
- Behavior depends only on data shape, not runtime configuration.
Use Strategy (composition) when:
- Variants are open-ended or user-extensible.
- Behavior evolves independently of the data model.
The key difference is closed vs. open hierarchies. Pattern matching works beautifully for sealed record unions; strategies work best for pluggable runtime behaviors.
1.5 What’s New in C# 12/13 That Makes This Easier
Recent C# versions have sharpened the language for composable, functional-style OOP.
1.5.1 Primary Constructors
Primary constructors on classes (not just records) enable concise, immutable designs with invariants checked inline:
public class Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount >= 0 ? amount :
throw new ArgumentOutOfRangeException(nameof(amount));
public string Currency { get; } = currency ?? throw new ArgumentNullException(nameof(currency));
}
1.5.2 Collection Expressions
Collection expressions ([ ... ]) simplify initialization and cloning:
var primes = [2, 3, 5, 7];
var merged = [..existing, 11, 13];
This integrates neatly with record immutability and domain events.
1.5.3 List Patterns and Slice Patterns
You can now match collections structurally:
if (items is [var first, .., var last])
Console.WriteLine($"First: {first}, Last: {last}");
This is incredibly useful for domain validation (e.g., empty order lines, single-element discounts).
1.5.4 Params Collections
C# 13 introduces params ReadOnlySpan<T>—reducing allocations for high-performance, variadic APIs. Ideal for DSL-like configuration builders.
1.5.5 Adoption Path and Gotchas
- Primary constructors: Be cautious mixing with existing dependency injection—constructor parameter naming conflicts can confuse service resolution.
- List patterns: Avoid on hot paths until JIT optimizations mature; may allocate.
- Record structs: Value equality is great, but copying large structs can degrade performance.
2 Foundations: Records, Value Objects, and Invariants
Before diving into composition, we need solid building blocks—immutable types that represent concepts faithfully. This section covers how to choose the right C# type form and encode invariants that keep your domain correct by construction.
2.1 Choosing Between Class, Struct, Record Class, and Record Struct
Choosing the right declaration form impacts semantics, equality, and performance.
| Type | Semantics | Mutability | Equality | Use When |
|---|---|---|---|---|
class | Reference | Mutable | Reference | Entity with identity |
record class | Reference | Immutable (by default) | Structural | Value-like reference |
struct | Value | Mutable | Structural (manual) | Small, perf-critical data |
record struct | Value | Immutable (by default) | Structural | Value object, no identity |
Decision rule:
- Use record class for value semantics without copying overhead (most domain value objects).
- Use record struct for small, frequently created types (e.g.,
Money). - Reserve class for entities that mutate across operations.
Example:
public record struct Money(decimal Amount, string Currency);
public record Email(string Value);
public class Order { public Guid Id { get; init; } }
2.2 Sealed Records for Domain Stability
Inheritance should be explicit. C# records are open for inheritance by default—dangerous in domain models, where value objects must remain closed to extension.
2.2.1 Why Sealing Matters
Consider:
public record Money(decimal Amount, string Currency);
public record DiscountedMoney(decimal Amount, string Currency, decimal Discount) : Money(Amount, Currency);
Now equality breaks subtly—two different logical concepts compare equal by property shape, not intent. Sealing prevents this:
public sealed record Money(decimal Amount, string Currency);
2.2.2 Generated Semantics
A sealed record generates:
- Value-based
EqualsandGetHashCode - Deconstructor
- With-expressions (
money with { Amount = 200 }) - Compiler-friendly immutability
This stability underpins reliable behavior across refactors.
2.3 Modeling Invariants with Value Objects
A value object must never exist in an invalid state. That’s the essence of domain correctness by construction.
2.3.1 Guard Clauses vs. Validation Libraries
Domain invariants should live inside the value object itself, enforced via guard clauses:
public sealed record Email
{
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Email cannot be empty.", nameof(value));
if (!value.Contains('@'))
throw new ArgumentException("Invalid email format.", nameof(value));
Value = value;
}
public override string ToString() => Value;
}
In contrast, validation libraries like FluentValidation belong at input boundaries—API layers or DTOs—where user input must be checked before creating domain objects. Domain code should not know about UI rules or localization.
2.3.2 Strongly-Typed IDs to End Primitive Obsession
Using Guid or string directly for identifiers leads to confusion and invalid cross-assignments. Libraries like StronglyTypedId or Strongly (source generators) fix this elegantly:
[StronglyTypedId]
public partial struct OrderId;
This generates:
public readonly partial struct OrderId : IStronglyTypedId<Guid>
{
public Guid Value { get; }
// equality, ToString, etc.
}
Now, you can’t accidentally assign a CustomerId to an OrderId.
2.4 C# Language Features That Reduce Boilerplate
Modern C# removes ceremony from domain modeling, allowing you to express invariants concisely.
2.4.1 Primary Constructors for Concise, Invariant-Enforcing Types
C# 12 allows primary constructors on regular classes:
public sealed class Money(decimal amount, string currency)
{
public decimal Amount { get; } = amount >= 0 ? amount :
throw new ArgumentOutOfRangeException(nameof(amount));
public string Currency { get; } = string.IsNullOrWhiteSpace(currency)
? throw new ArgumentNullException(nameof(currency))
: currency;
}
This pattern enforces invariants inline and eliminates the need for separate backing fields or verbose constructors.
2.4.2 Collection Expressions and List Patterns
Complex validation often involves lists—C# 12/13 make it expressive:
public sealed record OrderLines(IReadOnlyList<OrderLine> Items)
{
public OrderLines : this(Items is [] ? throw new ArgumentException("Order must have at least one line.") : Items) { }
public bool HasDuplicateProducts() => Items is [var first, .., var last] &&
Items.GroupBy(x => x.ProductId).Any(g => g.Count() > 1);
}
Readable, declarative, and pattern-matchable.
2.5 Example: Building Money, Email, and OrderId as Records
Let’s put this together.
public sealed record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentNullException(nameof(currency));
Amount = amount;
Currency = currency;
}
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency) throw new InvalidOperationException("Currency mismatch.");
return new Money(a.Amount + b.Amount, a.Currency);
}
public override string ToString() => $"{Amount:0.00} {Currency}";
}
public sealed record Email(string Value)
{
init
{
if (string.IsNullOrWhiteSpace(Value) || !Value.Contains('@'))
throw new ArgumentException("Invalid email.");
}
}
[StronglyTypedId]
public partial struct OrderId;
These small, sealed types have no invalid state, clear equality semantics, and integrate smoothly with serialization and ORM mapping.
3 Composition Over Inheritance in Practice
Composition shines when systems grow. Let’s see how to apply it systematically.
3.1 Identifying Seams: Policies, Calculators, Providers, and Adapters
Every non-trivial domain has variation points—areas where business rules or integrations differ. These are the seams for composition.
Common patterns:
- Policies — e.g., pricing, shipping, payment.
- Calculators — tax, discounts, surcharges.
- Providers — external data or configuration.
- Adapters — I/O boundaries, gateways, APIs.
Each becomes an interface with a single responsibility:
public interface IShippingRule
{
decimal Calculate(Order order);
}
3.2 Strategies as First-Class Types
A Strategy encapsulates interchangeable behavior. You can implement it as:
- Object Strategy: a class implementing an interface.
- Functional Strategy: a delegate injected at runtime.
Object Strategy:
public class WeightBasedShipping : IShippingRule
{
public decimal Calculate(Order order) => order.TotalWeight * 0.5m;
}
Functional Strategy:
Func<Order, decimal> shippingRule = o => o.TotalWeight * 0.5m;
Use object strategies for complex, stateful, or DI-managed logic. Use functional strategies for simple, composable rules.
3.3 Wiring Composition: DI + Scrutor Decoration
Instead of hardcoding dependencies, wire them via DI.
services.Scan(scan => scan
.FromAssemblyOf<IShippingRule>()
.AddClasses(classes => classes.AssignableTo<IShippingRule>())
.AsImplementedInterfaces()
.WithScopedLifetime());
To add cross-cutting concerns like caching or logging, decorate the interface:
services.Decorate<IShippingRule, CachingShippingRule>();
services.Decorate<IShippingRule, LoggingShippingRule>();
Now every IShippingRule automatically gains logging and caching without touching its source code.
3.4 Cross-Cutting Concerns via Composition (Polly)
Inheritance-based resilience wrappers (ResilientClientBase) are brittle. Instead, use composition + Polly:
public class ResilientShippingRule : IShippingRule
{
private readonly IShippingRule _inner;
private readonly IAsyncPolicy _policy;
public ResilientShippingRule(IShippingRule inner, IAsyncPolicy policy)
{
_inner = inner;
_policy = policy;
}
public decimal Calculate(Order order)
=> _policy.Execute(() => _inner.Calculate(order));
}
Decorators allow stacking retry, timeout, and circuit breaker policies seamlessly.
3.5 Example: Shipping Cost Calculation
public interface IShippingRule
{
decimal Calculate(Order order);
}
public class WeightBasedShipping : IShippingRule
{
public decimal Calculate(Order order) => order.TotalWeight * 0.5m;
}
public class DistanceBasedShipping : IShippingRule
{
public decimal Calculate(Order order) => order.Distance * 0.2m;
}
public class CachingShippingRule : IShippingRule
{
private readonly IShippingRule _inner;
private readonly IMemoryCache _cache;
public CachingShippingRule(IShippingRule inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public decimal Calculate(Order order)
=> _cache.GetOrCreate(order.Id, _ => _inner.Calculate(order));
}
Composition in action:
var rule = new CachingShippingRule(
new ResilientShippingRule(
new WeightBasedShipping(),
Policy.Handle<Exception>().Retry(3)),
cache);
No base class, no fragile inheritance. Each behavior is a building block, swappable and testable.
4 Modern Polymorphism: Pattern Matching + Strategy
Polymorphism in C# has evolved beyond virtual methods. With pattern matching, sealed hierarchies, and functional constructs, you can express domain variations without excessive abstraction layers. These techniques make behavior explicit, data-driven, and safer to extend—especially when paired with composition.
4.1 Pattern Matching as Data-Driven Polymorphism
Traditional polymorphism hides behavior behind virtual dispatch. Pattern matching reverses that relationship: the data structure drives behavior through declarative switch expressions. This is particularly powerful for closed sets of variants—like transaction types, discounts, or payment statuses—where compile-time exhaustiveness checks prevent missed cases.
Consider a PaymentStatus model:
public abstract record PaymentStatus;
public sealed record Pending(DateTime CreatedAt) : PaymentStatus;
public sealed record Completed(DateTime CompletedAt) : PaymentStatus;
public sealed record Failed(string Reason) : PaymentStatus;
public static string Describe(PaymentStatus status) => status switch
{
Pending(var created) => $"Pending since {created:g}",
Completed(var done) => $"Completed at {done:g}",
Failed(var reason) => $"Failed due to {reason}",
_ => throw new InvalidOperationException("Unknown status")
};
Each case is explicit and checked by the compiler. If you later add a new record like Refunded, the compiler flags every switch expression missing that branch. That’s real safety—not the runtime guesswork of virtual overrides.
4.1.1 Property and Positional Patterns for Expressive Logic
Property and positional patterns make conditions readable and declarative:
if (order is { Total: > 100, IsPriority: true })
Console.WriteLine("Apply VIP discount");
or using positional deconstruction:
var (amount, currency) = new Money(250, "USD");
if (amount is > 0 and < 1000 && currency is "USD")
Console.WriteLine("Standard pricing tier");
4.1.2 List Patterns for Aggregate Rules
C# 12 list patterns let you reason about collections structurally, not procedurally. Imagine a loyalty discount applied only when the first item is premium:
if (cart.Items is [var first, ..] && first.Category == "Premium")
Console.WriteLine("Apply early premium discount");
Declarative and concise, this avoids manual iteration while expressing domain intent clearly.
4.2 When Strategy Beats Pattern Matching (and Vice Versa)
Pattern matching excels for closed sets of variants. But real-world systems often require open-ended extension—new payment gateways, pricing rules, or policies registered by plugins. That’s when the Strategy pattern wins.
| Use Case | Prefer Pattern Matching | Prefer Strategy |
|---|---|---|
| Variants known at compile time | ✅ | ❌ |
| Variants discovered dynamically (via DI or configuration) | ❌ | ✅ |
| Behavior tied to data shape | ✅ | ❌ |
| Behavior injected or configured at runtime | ❌ | ✅ |
| Frequent addition of new variants by external teams | ❌ | ✅ |
Example: Promotions
A small e-commerce system may define a sealed Promotion record hierarchy—perfect for pattern matching. But if the platform lets merchants register new custom promotions via plugins, you’d switch to composition: define an IPromotionStrategy interface and load implementations dynamically.
In other words, pattern matching is for data-driven polymorphism; strategy is for behavior-driven extensibility.
4.3 Domain Example: Promotions and Pricing
Promotions are a natural fit for exploring this hybrid polymorphism—data-driven where bounded, composable where extended.
4.3.1 Using a Sealed Record Hierarchy for Promotion Variants
public abstract record Promotion;
public sealed record PercentageOff(decimal Percent) : Promotion;
public sealed record FixedAmount(decimal Amount) : Promotion;
public sealed record FreeShipping() : Promotion;
A calculation service might use exhaustive pattern matching:
public static Money ApplyPromotion(Promotion promo, Money subtotal) => promo switch
{
PercentageOff(var percent) => subtotal with { Amount = subtotal.Amount * (1 - percent / 100) },
FixedAmount(var amount) => subtotal with { Amount = Math.Max(subtotal.Amount - amount, 0) },
FreeShipping => subtotal,
_ => throw new NotSupportedException($"Unknown promotion type {promo.GetType().Name}")
};
This approach is declarative, testable, and self-documenting. The compiler enforces completeness—no forgotten case logic.
4.3.2 Extending via Composition: Registering New Strategies
When promotion logic grows beyond simple calculations, composition takes over:
public interface IPromotionStrategy
{
bool AppliesTo(Order order);
Money Apply(Order order);
}
public class LoyaltyPromotion : IPromotionStrategy
{
public bool AppliesTo(Order order) => order.Customer.IsLoyal;
public Money Apply(Order order) => order.Total * 0.9m;
}
Now you can register strategies dynamically:
services.Scan(scan => scan
.FromAssemblyOf<IPromotionStrategy>()
.AddClasses(classes => classes.AssignableTo<IPromotionStrategy>())
.AsImplementedInterfaces()
.WithScopedLifetime());
At runtime, your service composes them:
public class PromotionEngine
{
private readonly IEnumerable<IPromotionStrategy> _strategies;
public PromotionEngine(IEnumerable<IPromotionStrategy> strategies) => _strategies = strategies;
public Money ApplyBest(Order order)
=> _strategies
.Where(s => s.AppliesTo(order))
.Select(s => s.Apply(order))
.OrderBy(m => m.Amount)
.FirstOrDefault(order.Total);
}
Pattern matching gives you clarity within sealed domains; strategies give you extensibility across evolving ones.
4.4 Handling Result Shapes Without Exceptions
Imperative C# often uses exceptions for flow control—an anti-pattern in domain logic. Modern OOP in C# embraces Result or Union types to express outcome variations safely.
Libraries like FluentResults or OneOf integrate cleanly with pattern matching:
public static Result<Money> ApplyDiscount(Order order)
{
if (order.Total <= 0) return Result.Fail("Invalid order total");
return Result.Ok(order.Total * 0.9m);
}
Consumer code becomes predictable:
var result = ApplyDiscount(order);
var message = result switch
{
{ IsSuccess: true, Value: var value } => $"New total: {value}",
{ IsFailed: true } => $"Discount failed: {result.Errors.First().Message}",
_ => "Unexpected result"
};
Or with OneOf:
public OneOf<Success, ValidationError, NotFound> Process(Order order) =>
order switch
{
{ Total: <= 0 } => new ValidationError("Invalid total"),
{ } when order.Customer == null => new NotFound("Customer not found"),
_ => new Success()
};
This eliminates exception-driven control flow while preserving clear intent.
5 Domain Services, Factories, and Aggregates (without God Objects)
As domains scale, the challenge shifts from syntax to structure. Practical OOP in C# means modeling behavior-rich systems without “God services” or “anemic models.” Each type must own its responsibility—value objects for rules, entities for state, services for orchestration.
5.1 Entity vs. Value Object vs. Domain Service
Entity
- Has identity and lifecycle.
- Mutable, aggregate root of consistency boundaries.
public class Order
{
public OrderId Id { get; init; }
private readonly List<OrderLine> _lines = [];
public IReadOnlyCollection<OrderLine> Lines => _lines;
public void AddLine(Product product, int quantity)
=> _lines.Add(new OrderLine(product, quantity));
}
Value Object
- Immutable, equality by structure.
- Enforces invariants (e.g.,
Money,Email).
Domain Service
- Stateless, encapsulates behavior spanning multiple aggregates.
public class PricingService
{
private readonly IEnumerable<IPromotionStrategy> _promotions;
public PricingService(IEnumerable<IPromotionStrategy> promotions) => _promotions = promotions;
public Money CalculateTotal(Order order)
=> _promotions
.Where(p => p.AppliesTo(order))
.Aggregate(order.Subtotal, (current, promo) => promo.Apply(order));
}
Clear boundaries reduce cognitive load and improve testability.
5.2 Factories for Invariant-Rich Creation
Constructors are for enforcing invariants; factories are for orchestrating creation that spans multiple objects.
5.2.1 Static Factory
public static class OrderFactory
{
public static Order Create(CustomerId customerId, IEnumerable<OrderLine> lines)
{
if (!lines.Any()) throw new ArgumentException("Empty order");
return new Order(customerId, lines.ToList());
}
}
5.2.2 Dedicated Factory Type
When creation involves services (repositories, configuration, policies), use a factory type:
public interface IOrderFactory
{
Order Create(CustomerId customerId, IEnumerable<OrderLine> lines);
}
public class OrderFactory : IOrderFactory
{
private readonly IInventoryPolicy _inventory;
public OrderFactory(IInventoryPolicy inventory) => _inventory = inventory;
public Order Create(CustomerId customerId, IEnumerable<OrderLine> lines)
{
foreach (var line in lines)
_inventory.Reserve(line.ProductId, line.Quantity);
return new Order(customerId, lines.ToList());
}
}
Factories encapsulate orchestration while preserving aggregate invariants.
5.3 Aggregate Boundaries and Composition
Aggregates are clusters of entities and value objects with a single entry point—its root. The goal: local reasoning. A single transaction modifies exactly one aggregate.
public class Order
{
private readonly List<OrderLine> _lines = [];
public IReadOnlyCollection<OrderLine> Lines => _lines;
public Money Total => _lines.Sum(l => l.LineTotal);
public void AddLine(Product product, int quantity)
{
if (_lines.Any(l => l.ProductId == product.Id))
throw new InvalidOperationException("Duplicate line item.");
_lines.Add(new OrderLine(product, quantity));
}
}
Composition reinforces these boundaries: rather than exposing raw collections, the aggregate provides explicit methods that maintain invariants.
5.4 Orchestrating Workflows with Domain Services
Large workflows—checkout, subscription billing, refund handling—span multiple aggregates. Domain services orchestrate these flows without centralizing business rules. Each step delegates to responsible aggregates.
public class CheckoutService
{
private readonly IPaymentPolicy _payment;
private readonly IInventoryPolicy _inventory;
public CheckoutService(IPaymentPolicy payment, IInventoryPolicy inventory)
{
_payment = payment;
_inventory = inventory;
}
public async Task<Result> CheckoutAsync(Order order)
{
if (!_inventory.CanFulfill(order)) return Result.Fail("Insufficient inventory");
var paymentResult = await _payment.ChargeAsync(order);
return paymentResult.IsSuccess ? Result.Ok() : paymentResult;
}
}
This model avoids “God services” by delegating validation and state changes to specialized policies and aggregates.
5.5 Example: Checkout Workflow
Let’s integrate the pieces into a cohesive, composable flow.
5.5.1 Order Aggregate
public class Order
{
public OrderId Id { get; init; } = OrderId.New();
public IReadOnlyList<OrderLine> Lines { get; init; } = [];
public Money Total => Lines.Aggregate(new Money(0, "USD"), (sum, l) => sum + l.LineTotal);
public bool IsPaid { get; private set; }
public void MarkPaid() => IsPaid = true;
}
5.5.2 Swappable PaymentPolicy and InventoryPolicy
public interface IPaymentPolicy
{
Task<Result> ChargeAsync(Order order);
}
public interface IInventoryPolicy
{
bool CanFulfill(Order order);
}
public class StripePaymentPolicy : IPaymentPolicy
{
public Task<Result> ChargeAsync(Order order)
=> Task.FromResult(Result.Ok().WithSuccess("Charged via Stripe"));
}
public class BasicInventoryPolicy : IInventoryPolicy
{
public bool CanFulfill(Order order) => order.Lines.All(l => l.Quantity < 10);
}
5.5.3 Orchestrating with CheckoutService
public class CheckoutService
{
private readonly IPaymentPolicy _payment;
private readonly IInventoryPolicy _inventory;
public CheckoutService(IPaymentPolicy payment, IInventoryPolicy inventory)
{
_payment = payment;
_inventory = inventory;
}
public async Task<Result> ExecuteAsync(Order order)
{
if (!_inventory.CanFulfill(order))
return Result.Fail("Inventory insufficient");
var paymentResult = await _payment.ChargeAsync(order);
if (paymentResult.IsFailed)
return paymentResult;
order.MarkPaid();
return Result.Ok().WithSuccess("Order complete");
}
}
This workflow is resilient and composable: add retry or logging via Polly and Scrutor decorators—no inheritance or code duplication.
6 Working Edges: I/O Boundaries, Mapping, and Validation
Practical OOP isn’t only about internal purity—it’s about safe boundaries. Input/output layers (API, persistence, messaging) should never leak invalid data or DTOs into your domain model. The key is anti-corruption: mapping, validation, and translation at the edges.
6.1 Keeping DTOs Out of the Core
Your domain model should speak in domain types—Order, Money, Email—not in flat, nullable DTOs. That’s what mapping tools are for.
6.1.1 Mapster vs. AutoMapper and Source Generation
Mapster has become the preferred choice in modern .NET because of its source generator support—compile-time generation, zero reflection, and minimal allocations.
TypeAdapterConfig<OrderDto, Order>.NewConfig()
.Map(dest => dest.Id, src => new OrderId(src.Id))
.Map(dest => dest.Lines, src => src.Lines.Adapt<List<OrderLine>>());
Contrast with AutoMapper, which is mature but runtime-based and slower in hot paths. For teams under commercial compliance, note AutoMapper’s recent licensing changes—Mapster remains MIT.
Alternatively, you can handcraft mappings for maximum control, especially when invariants or validation logic must be enforced before construction.
6.2 Strategically Placing Validation
Validation belongs at the edges. Domain invariants belong inside the model; user input validation belongs outside it.
FluentValidation is ideal for request/DTO validation:
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleForEach(x => x.Lines).SetValidator(new OrderLineValidator());
}
}
Domain-side, you enforce invariants directly:
public sealed record Email(string Value)
{
init
{
if (!Value.Contains('@'))
throw new ArgumentException("Invalid email", nameof(Value));
}
}
Keep these responsibilities distinct. Don’t inject validation frameworks into your core domain assembly—they’re an edge concern.
6.3 Example: API Layer Mapping and Validation Pipeline
Putting it together:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly CheckoutService _checkout;
private readonly IValidator<CreateOrderRequest> _validator;
public OrdersController(CheckoutService checkout, IValidator<CreateOrderRequest> validator)
{
_checkout = checkout;
_validator = validator;
}
[HttpPost]
public async Task<IActionResult> Create(CreateOrderRequest request)
{
var validation = await _validator.ValidateAsync(request);
if (!validation.IsValid)
return BadRequest(validation.Errors);
var order = request.Adapt<Order>(); // Mapster handles transformation
var result = await _checkout.ExecuteAsync(order);
return result.IsSuccess ? Ok(result.Successes) : BadRequest(result.Errors);
}
}
Here, DTOs are validated before mapping, then transformed into invariant-rich domain models. The controller only coordinates; domain logic remains pure.
7 End-to-End Example: A Real-World “Subscriptions and Billing” Slice
To bring together all the principles discussed so far—composition, pattern matching, records, and functional semantics—let’s walk through a cohesive real-world domain: Subscriptions and Billing. We’ll model plans, subscriptions, billing cycles, payment methods, and strategies that handle proration, discounts, and resilience.
This example mirrors a realistic SaaS system where correctness, extensibility, and observability are non-negotiable.
7.1 Domain Sketch: Plans, Subscription, BillingCycle, PaymentMethod
The domain starts with clear conceptual boundaries.
- Plan: defines price, interval, and available features.
- Subscription: connects a customer to a plan over time, tracking billing and renewal.
- BillingCycle: determines when charges occur.
- PaymentMethod: defines how payments are processed.
We’ll model these as records and value objects, sealed where appropriate for safety.
public sealed record Plan(string Name, Money MonthlyPrice, IReadOnlyList<string> Features);
public sealed record BillingCycle(DateOnly StartDate, DateOnly EndDate)
{
public int Days => EndDate.DayNumber - StartDate.DayNumber;
public bool Contains(DateOnly date) => date >= StartDate && date <= EndDate;
}
public abstract record PaymentMethod;
public sealed record CreditCard(string Last4, string Brand) : PaymentMethod;
public sealed record PayPal(string Email) : PaymentMethod;
public sealed record CryptoWallet(string Address) : PaymentMethod;
public sealed record Subscription(
Guid Id,
Plan Plan,
BillingCycle Cycle,
PaymentMethod Method,
DateOnly CreatedOn)
{
public bool IsActive => Cycle.Contains(DateOnly.FromDateTime(DateTime.UtcNow));
}
These types are immutable, concise, and enforce basic correctness by design. For example, a BillingCycle can’t be constructed without valid start and end dates, and equality is structural, making tests trivial.
7.2 Composition Map
Our composition map organizes behavior around clear interfaces—pricing, payment, and notifications. Instead of one monolithic billing service, we’ll combine strategies through composition and DI.
7.2.1 Pricing Strategies (IPricingStrategy)
Pricing can vary by plan, duration, or promotion. Rather than hardcoding conditions, we define pluggable strategies:
public interface IPricingStrategy
{
Money Calculate(Subscription subscription, DateOnly renewalDate);
}
Concrete Strategies
public sealed class StandardPricing : IPricingStrategy
{
public Money Calculate(Subscription sub, DateOnly date) => sub.Plan.MonthlyPrice;
}
public sealed class ProratedPricing : IPricingStrategy
{
public Money Calculate(Subscription sub, DateOnly date)
{
var daysUsed = (date.DayNumber - sub.Cycle.StartDate.DayNumber);
var totalDays = sub.Cycle.Days;
return new Money(sub.Plan.MonthlyPrice.Amount * daysUsed / totalDays, sub.Plan.MonthlyPrice.Currency);
}
}
public sealed class DiscountPricing(decimal percentage) : IPricingStrategy
{
public Money Calculate(Subscription sub, DateOnly date)
=> sub.Plan.MonthlyPrice with { Amount = sub.Plan.MonthlyPrice.Amount * (1 - percentage / 100) };
}
These can be combined or decorated—e.g., adding caching or logging—without touching their implementation.
7.2.2 Payment Provider Adapters (IPaymentGateway) with Polly
To abstract payment handling, we define an adapter interface:
public interface IPaymentGateway
{
Task<Result> ChargeAsync(Guid subscriptionId, Money amount, PaymentMethod method);
}
Then we provide implementations:
public class StripeGateway : IPaymentGateway
{
public Task<Result> ChargeAsync(Guid id, Money amount, PaymentMethod method)
=> Task.FromResult(Result.Ok().WithSuccess($"Charged {amount} via Stripe"));
}
Resilience is added via decorators and Polly policies, not inheritance:
public class ResilientPaymentGateway : IPaymentGateway
{
private readonly IPaymentGateway _inner;
private readonly IAsyncPolicy _policy;
public ResilientPaymentGateway(IPaymentGateway inner)
{
_inner = inner;
_policy = Policy.Handle<Exception>().WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(attempt));
}
public async Task<Result> ChargeAsync(Guid id, Money amount, PaymentMethod method)
=> await _policy.ExecuteAsync(() => _inner.ChargeAsync(id, amount, method));
}
7.2.3 Notification Strategies and Retry/Backoff
After successful billing, customers need receipts and alerts. Notifications vary by channel.
public interface INotificationChannel
{
Task NotifyAsync(string recipient, string message);
}
public class EmailNotification : INotificationChannel
{
public Task NotifyAsync(string recipient, string message)
=> Console.Out.WriteLineAsync($"Email to {recipient}: {message}");
}
public class SlackNotification : INotificationChannel
{
public Task NotifyAsync(string recipient, string message)
=> Console.Out.WriteLineAsync($"Slack message to {recipient}: {message}");
}
Decorate with retry/backoff via Polly:
public class ResilientNotifier : INotificationChannel
{
private readonly INotificationChannel _inner;
private readonly IAsyncPolicy _policy;
public ResilientNotifier(INotificationChannel inner)
{
_inner = inner;
_policy = Policy.Handle<Exception>().WaitAndRetryAsync(2, i => TimeSpan.FromMilliseconds(200 * i));
}
public async Task NotifyAsync(string recipient, string message)
=> await _policy.ExecuteAsync(() => _inner.NotifyAsync(recipient, message));
}
7.3 Creation Paths and Invariants
Factories coordinate domain creation safely while enforcing invariants.
public interface ISubscriptionFactory
{
Subscription Create(Plan plan, PaymentMethod method, DateOnly start);
}
public class SubscriptionFactory : ISubscriptionFactory
{
public Subscription Create(Plan plan, PaymentMethod method, DateOnly start)
{
var end = start.AddMonths(1);
Guard.Against.Null(plan);
Guard.Against.Null(method);
return new Subscription(Guid.NewGuid(), plan, new BillingCycle(start, end), method, DateOnly.FromDateTime(DateTime.UtcNow));
}
}
Every Subscription is valid at birth. The factory’s use of guard clauses (via Ardalis.GuardClauses) ensures consistent state.
7.4 Polymorphism via Pattern Matching
7.4.1 Switch over PaymentMethod Sealed Records
Pattern matching replaces virtual dispatch for sealed hierarchies:
public static string Describe(PaymentMethod method) => method switch
{
CreditCard(var last4, var brand) => $"{brand} ending in {last4}",
PayPal(var email) => $"PayPal account {email}",
CryptoWallet(var address) => $"Crypto wallet {address[..6]}...",
_ => throw new InvalidOperationException("Unknown payment method")
};
Adding a new payment method forces compiler warnings in all switches—automatic exhaustiveness.
7.4.2 List Patterns for Line Item Validation
Subscriptions often involve line items (plan add-ons). We can express business rules declaratively:
public static bool HasInvalidAddOns(IReadOnlyList<Plan> plans)
=> plans is [] or [_, .., { Name: "LegacyPlan" }];
This reads as: invalid if no add-ons or ends with a legacy plan—clear intent without loops.
7.5 Application Wiring
7.5.1 DI Registration with Scrutor
Composition scales with Scrutor. It scans and decorates automatically:
services.Scan(scan => scan
.FromAssemblyOf<IPricingStrategy>()
.AddClasses(c => c.AssignableTo<IPricingStrategy>())
.AsImplementedInterfaces()
.WithScopedLifetime());
services.Decorate<IPaymentGateway, ResilientPaymentGateway>();
services.Decorate<INotificationChannel, ResilientNotifier>();
This allows drop-in composition. Need observability? Add:
services.Decorate<IPaymentGateway, LoggingPaymentGateway>();
without modifying existing code.
7.5.2 Validation Pipeline + Domain Guards
FluentValidation validates DTOs at boundaries, while domain invariants are enforced internally.
public class SubscriptionRequestValidator : AbstractValidator<CreateSubscriptionRequest>
{
public SubscriptionRequestValidator()
{
RuleFor(x => x.PlanName).NotEmpty();
RuleFor(x => x.PaymentMethod).NotNull();
}
}
Guards ensure runtime safety:
Guard.Against.NegativeOrZero(price.Amount, nameof(price.Amount));
7.5.3 Mapping Boundaries with Mapster
Use Mapster’s source generation for DTO → Domain mapping:
TypeAdapterConfig<CreateSubscriptionRequest, Subscription>.NewConfig()
.Map(dest => dest.Plan, src => new Plan(src.PlanName, new Money(src.Price, "USD"), src.Features))
.Map(dest => dest.Method, src => src.MethodType switch
{
"CreditCard" => new CreditCard(src.Last4, src.Brand),
"PayPal" => new PayPal(src.Email),
_ => throw new NotSupportedException()
});
No domain pollution; mapping and validation remain at the edge.
7.6 Testing Strategy
7.6.1 Unit Tests on Value Objects
Value objects are pure and easy to test:
[Fact]
public void Money_Equality_Works()
{
var m1 = new Money(100, "USD");
var m2 = new Money(100, "USD");
Assert.Equal(m1, m2);
}
Immutability ensures deterministic tests.
7.6.2 Contract Tests for Strategies and Adapters
Each strategy can be tested independently:
[Fact]
public void DiscountPricing_Applies_Percentage()
{
var plan = new Plan("Pro", new Money(100, "USD"), []);
var sub = new Subscription(Guid.NewGuid(), plan, new BillingCycle(DateOnly.FromDateTime(DateTime.Today), DateOnly.FromDateTime(DateTime.Today.AddMonths(1))), new PayPal("a@b.com"), DateOnly.FromDateTime(DateTime.Today));
var pricing = new DiscountPricing(10);
var result = pricing.Calculate(sub, DateOnly.FromDateTime(DateTime.Today));
Assert.Equal(90, result.Amount);
}
Adapters like gateways are tested via contract fixtures, ensuring all implementations conform to the same behavioral contract.
7.6.3 Resilience Policy Tests
You can test Polly behavior deterministically using controlled failures:
[Fact]
public async Task ResilientPaymentGateway_Retries_On_Exception()
{
var inner = Substitute.For<IPaymentGateway>();
inner.ChargeAsync(default, default, default).ThrowsAsync(new TimeoutException());
var gateway = new ResilientPaymentGateway(inner);
await gateway.ChargeAsync(Guid.NewGuid(), new Money(10, "USD"), new PayPal("x@y.com"));
await inner.Received(3).ChargeAsync(Arg.Any<Guid>(), Arg.Any<Money>(), Arg.Any<PaymentMethod>());
}
7.7 Operational Concerns: Logging, Metrics, and Feature Toggles
Observability is essential in production-grade systems. Use decorators to keep cross-cutting logic out of domain code.
public class LoggingPaymentGateway : IPaymentGateway
{
private readonly IPaymentGateway _inner;
private readonly ILogger<LoggingPaymentGateway> _logger;
public LoggingPaymentGateway(IPaymentGateway inner, ILogger<LoggingPaymentGateway> logger)
{
_inner = inner;
_logger = logger;
}
public async Task<Result> ChargeAsync(Guid id, Money amount, PaymentMethod method)
{
_logger.LogInformation("Charging subscription {Id} with {Amount}", id, amount);
var result = await _inner.ChargeAsync(id, amount, method);
_logger.LogInformation("Charge result: {Result}", result.IsSuccess);
return result;
}
}
For metrics, integrate with tools like OpenTelemetry via decorators—record retries, failures, and durations. Feature toggles (via libraries like FeatureManagement or LaunchDarkly) should control strategy activation rather than conditionals in core logic.
Composition ensures toggles swap behaviors rather than branching inside methods.
8 Anti-Patterns, Trade-offs, and a Checklist for Testable, Decoupled OOP
No design approach is perfect. Practical OOP is about trade-offs—knowing when to seal, when to compose, when to pattern match, and when to resist over-abstraction.
8.1 Anemic Domain Model
An anemic model holds data but no behavior:
public class Order { public decimal Total; }
public class OrderService { public void ApplyDiscount(Order o) => o.Total *= 0.9m; }
This splits logic from data, scattering invariants. Fix it by pushing logic back into value-rich types:
public sealed record Money(decimal Amount, string Currency)
{
public Money ApplyDiscount(decimal percent) => this with { Amount = Amount * (1 - percent / 100) };
}
Behavior now travels with data, restoring encapsulation.
8.2 “God Services” and Mega-Orchestrators
When a service knows “too much,” cohesion collapses. Refactor by extracting strategies or pattern matches for each decision point.
Example: a “BillingService” that handles pricing, charging, emailing, and renewal. Break it down:
- Pricing →
IPricingStrategy - Charging →
IPaymentGateway - Notification →
INotificationChannel
Composition and DI wiring replace procedural sprawl.
8.3 Inheritance Smells to Replace with Composition
Template Method → Strategy
Instead of subclassing a base with overridable steps:
Incorrect:
public abstract class ReportGenerator
{
public void Generate() { FetchData(); Format(); Export(); }
protected abstract void FetchData();
protected abstract void Format();
protected abstract void Export();
}
Correct:
public class ReportPipeline(IFetcher f, IFormatter fmt, IExporter e)
{
public void Run() { f.Fetch(); fmt.Format(); e.Export(); }
}
Each step is a composable interface—swappable, testable, extendable.
Base Entity Traps
Avoid shared BaseEntity classes with timestamp and ID fields; they blur boundaries. Use small, explicit components (like Auditable mix-ins or decorators).
8.4 Records Gone Wrong
Immutability has costs. In high-frequency updates (e.g., financial ticks), record copying becomes expensive. Use record struct or mutable types selectively.
Rule of thumb:
- record struct: small, short-lived, high-performance.
- record class: identity-less, persistent across scopes.
- class: long-lived entity with behavior.
Mixing value semantics into performance-critical loops can create hidden GC pressure—profile before defaulting to records everywhere.
8.5 Libraries in Context
These tools strengthen practical OOP when used intentionally:
- Ardalis.GuardClauses — inline invariant enforcement without clutter.
- FluentValidation — for DTO validation, not domain logic.
- Scrutor — auto-registration and decoration for DI.
- Polly — resilience (Retry, Timeout, CircuitBreaker, Hedging).
- OneOf / FluentResults — pattern-match-friendly success/failure models.
- StronglyTypedId / Strongly — eliminates primitive obsession.
- Mapster — fast, generated mappers for clean anti-corruption layers.
Avoid over-instrumentation; adopt these when they reduce complexity, not when they add another dependency graph to maintain.
8.6 The “Practical OOP” Checklist
A practical, printable summary to review during design or code review.
8.6.1 Modeling
- Are invariants captured in value objects and enforced at construction?
- Is every inheritance decision justified by necessity, not convenience?
- Are records sealed by default unless extensibility is deliberate?
8.6.2 Behavior & Polymorphism
- Is polymorphism expressed via Strategy or pattern matching instead of deep inheritance?
- Do pattern-matched hierarchies have exhaustive
switchexpressions?
8.6.3 Boundaries
- Are DTO mapping and validation kept outside the domain core?
- Are resilience and cross-cutting concerns added via composition (decorators, Polly)?
8.6.4 Testability
- Do most tests focus on pure value semantics?
- Are adapters/strategies tested via contract fixtures?
- Are resilience policies testable with deterministic backoff?
8.6.5 Operability
- Are policies observable (metrics, logs)?
- Are configuration changes and feature toggles runtime-driven, not code-driven?