Skip to content
Beyond SOLID in Modern C#: Smart Enums, Discriminated Unions & Railway-Oriented Programming

Beyond SOLID in Modern C#: Smart Enums, Discriminated Unions & Railway-Oriented Programming

1 The Paradigm Shift: Why SOLID Is No Longer Enough

For almost two decades, SOLID provided the mental scaffolding most of us used to structure C# applications. It gave us consistency and encouraged separation of concerns. But as systems grew more distributed, domain models grew more expressive, and performance expectations tightened, many teams began bumping into the limitations of classic object-oriented design. I started seeing the same failure patterns across organizations: endless interfaces, brittle hierarchies, and domain models made of DTO-like shells.

This article explores the next step—what I call Pragmatic OOP infused with functional techniques. Modern C# (12 and 13) gives us powerful tools like records, primary constructors, and cleaner pattern matching. Combined with patterns such as Smart Enums, Discriminated Unions, and Railway-Oriented Programming (ROP), we get models that are safer, more expressive, and more testable. In short, models that ship better software.

1.1 The Limitations of Classic OOP

Many of the issues we associate with “bad architecture” stem from using traditional OOP concepts too rigidly. Over the years, I’ve seen three themes repeat themselves.

1.1.1 The “Exception Hell”: Why using exceptions for flow control hurts performance and readability

In classic OOP, exceptions were the default response to anything that wasn’t a happy-path outcome. We’d see code like:

try
{
    var customer = _repository.GetCustomer(id);
    if (customer.IsBlocked)
        throw new CustomerBlockedException();
}
catch (CustomerBlockedException ex)
{
    return HandleBusinessRule(ex);
}
catch (Exception ex)
{
    _logger.LogError(ex, "Unexpected failure");
    throw;
}

The problem here is twofold:

  1. Exceptions are expensive. Throwing and catching involves stack unwinding and allocations. Under load, cascading exceptions can seriously hurt latency.
  2. Exceptions obscure intent. When every domain rule becomes an exception, callers can’t tell which errors are truly exceptional. They end up catching everything—effectively blurring error categories.

In the systems I’ve helped refactor, removing exceptions from domain flow reduced logs by 70–90% and simplified business logic significantly.

1.1.2 “Primitive Obsession”: The hidden risks of passing strings and ints everywhere

Primitive obsession makes domain logic hard to reason about. Consider an API where string country, string currency, or int status are passed from layer to layer. At first, it feels simple, but the problems compound:

  • No validation at boundaries.
  • Domain invariants must be manually checked everywhere.
  • Accidental misuse is easy and sometimes catastrophic.

You end up with this:

UpdateOrder(int status, string country, string currency);

What does status = 4 mean? Which countries support which currencies? Every call site becomes a potential bug. This is why patterns like Smart Enums exist—they turn domain concepts into first-class types with behaviors and guarantees.

1.1.3 The difference between “Open/Closed” inheritance and exhaustive pattern matching

Classic SOLID encourages extension through inheritance: implement a new class, override a method, follow Liskov. But inheritance trees grow brittle quickly, and “extension” often becomes “accidental complexity.”

Pattern matching provides a different model: instead of extending via more types, we constrain the universe of valid states and force exhaustion at compile time.

This shifts responsibility from runtime behavior to compile-time guarantees. When working with intricate domain state machines (payments, bookings, shipments), exhaustive matching produces safer systems than inheritance hierarchies.

1.2 The Modern C# Toolset (C# 12 & 13 Focus)

C# gained several features in recent versions that bridge the gap between functional programming and OOP. These aren’t cosmetic additions—they fundamentally change how we model domain logic.

1.2.1 How Records (record and record struct) changed immutability

Before C# 9, creating immutable domain models was tedious. Records flipped the cost model—immutability is now the easiest path.

public record Address(string Street, string City, string PostalCode);

Immutability brings several benefits:

  • Thread safety without ceremony.
  • Value semantics for equality.
  • Clearer intent around state transitions.

With record struct, we can avoid allocations in high-throughput paths while still using value semantics.

1.2.2 Leveraging Primary Constructors for concise dependency injection and state definition

C# 12 brought primary constructors to classes and structs. This seemingly small addition reduces boilerplate significantly.

public class InvoiceService(ITaxService taxService, IClock clock)
{
    public decimal CalculateTotal(Order order)
        => order.Subtotal + taxService.Calculate(order, clock.UtcNow);
}

No need for backing fields or verbose constructors unless you need them. Primary constructors shine in domain models and services that must be small, precise, and expressive.

1.2.3 Introduction to Collection Expressions ([...]) for cleaner initialization

C# 12 made collection initialization more succinct:

var errors = [
    "Email is required",
    "Currency is invalid"
];

This is particularly useful in validation logic, DU modeling, and ROP pipelines where lists of errors or transitions are common.

1.3 The Goal: Compile-Time Safety over Runtime Checks

Most production issues happen because invalid states slip through until runtime. We rely on checks everywhere:

  • if (status == 4)
  • if (string.IsNullOrEmpty(country))
  • if (payment == null)
  • try/catch from multiple layers

We tolerate this because languages used to lack alternatives.

Modern C# lets us push correctness to compile time.

1.3.1 Moving validation to the type system (“Making illegal states unrepresentable”)

Instead of:

ProcessPayment(string status);

We model valid states as types. A type system that guarantees correctness upfront reduces runtime checks and makes code easier to trust.

1.3.2 Overview of the three pillars: Smart Enums, Discriminated Unions, and ROP

We’ll use three patterns throughout this article:

  1. Smart Enums — Rich types that encapsulate logic, replacing primitive enums.
  2. Discriminated Unions — Exhaustive modeling of valid states.
  3. Railway-Oriented Programming — Result-driven flow where errors never hide behind exceptions.

These three patterns drastically reduce cognitive load in domain modeling.


2 Smart Enums: Curing Primitive Obsession

Smart Enums help you express domain constraints directly in code. Instead of passing int status or string tier, you define a domain-specific type whose instances know their meaning and associated behavior.

2.1 The Problem with public enum Status { ... }

Enums seem elegant but hide dangerous simplicity.

2.1.1 The disconnect between data and behavior (Switch statements scattered across the app)

For a simple enum:

public enum Status
{
    Active,
    Suspended,
    Cancelled
}

Business logic often ends up like this:

switch (status)
{
    case Status.Active:
        // logic
        break;
    case Status.Suspended:
        // logic
        break;
}

This multiplies across the codebase. Each switch becomes another opportunity for drift and inconsistency. Any new enum value requires updating dozens of call sites.

2.1.2 Issues with serialization and database storage (Int vs String mapping)

Enums serialize as integers by default. That’s fine until someone reorders the enum values. Suddenly, 1 no longer maps to “Active.”

Developers often patch this by adding [EnumMember] attributes or custom JSON converters, but the underlying fragility remains.

2.2 Implementation Strategies

You can implement Smart Enums manually or use a library.

2.2.1 Native Implementation: Writing a sealed class with static instances and private constructors

A typical Smart Enum pattern looks like:

public sealed class Status
{
    public string Name { get; }

    private Status(string name) => Name = name;

    public static readonly Status Active = new("Active");
    public static readonly Status Suspended = new("Suspended");
    public static readonly Status Cancelled = new("Cancelled");

    public bool CanLogin() => this == Active;
}

Advantages:

  • Instances are singletons (static readonly).
  • You can attach behavior directly (CanLogin()).
  • You can enforce invariants.

2.2.2 Library approach: Using Ardalis.SmartEnum for robust base functionality

The Ardalis.SmartEnum library provides a generic base class with excellent functionality:

public sealed class SubscriptionTier : SmartEnum<SubscriptionTier>
{
    public static readonly SubscriptionTier Free = new(nameof(Free), 1);
    public static readonly SubscriptionTier Premium = new(nameof(Premium), 2);

    private SubscriptionTier(string name, int value) : base(name, value) { }

    public bool CanAccessVideoSupport()
        => this == Premium;
}

You get parsing, enumeration, and equality out of the box.

2.3 Real-World Scenario: The SubscriptionTier

Now let’s ground the concept with a subscription example.

2.3.1 Encapsulating business logic directly inside the enum class

A Smart Enum allows domain logic like discounts to live in the type:

public sealed class SubscriptionTier : SmartEnum<SubscriptionTier>
{
    public static readonly SubscriptionTier Free = new(nameof(Free), 0);
    public static readonly SubscriptionTier Pro = new(nameof(Pro), 1);
    public static readonly SubscriptionTier Enterprise = new(nameof(Enterprise), 2);

    private SubscriptionTier(string name, int value) : base(name, value) { }

    public decimal CalculateDiscount(decimal price) =>
        this switch
        {
            var t when t == Pro => price * 0.10m,
            var t when t == Enterprise => price * 0.20m,
            _ => 0m
        };
}

The calling code becomes trivial and impossible to misuse.

2.3.2 Using C# Pattern Matching with Smart Enums

Smart Enums work well with pattern matching:

string GetTierMessage(SubscriptionTier tier) =>
    tier switch
    {
        var t when t == SubscriptionTier.Free => "Limited access",
        var t when t == SubscriptionTier.Pro => "Full access",
        var t when t == SubscriptionTier.Enterprise => "Priority support",
        _ => throw new NotSupportedException()
    };

2.3.3 Persisting Smart Enums to EF Core using Value Converters

Smart Enums aren’t automatically understood by EF Core, but ValueConverters make mapping easy:

builder.Property(x => x.Tier)
    .HasConversion(
        v => v.Name,
        v => SubscriptionTier.FromName(v));

Persisting by name gives stability across releases and is human-readable.


3 Discriminated Unions (DUs): Exhaustive State Modeling

In domain-heavy systems, you often have types that represent “one of several well-known variants.” C# doesn’t yet have built-in union types, but you can simulate them cleanly.

3.1 Defining Discriminated Unions in C#

You can think of DUs as the opposite of inheritance: instead of open-ended extensibility, you define a fixed set of options.

3.1.1 The concept of “Sum Types” vs. “Product Types”

Product types combine fields together:

public record User(string Name, int Age); // name AND age

Sum types express alternatives:

  • Payment is either Authorized OR Captured OR Refused.
  • Result is either Success OR Failure.

This is critical in state machines where only certain transitions are allowed.

3.1.2 The Hierarchy Pattern

A clean way to implement DUs:

public abstract record PaymentResult
{
    public sealed record Authorized(string AuthCode) : PaymentResult;
    public sealed record Captured(string TransactionId) : PaymentResult;
    public sealed record Declined(string Reason) : PaymentResult;
}

Each variant is a distinct type but belongs to the same domain “family.” Pattern matching guarantees exhaustiveness.

3.2 Tooling and Libraries

You can implement DUs yourself, but libraries provide syntactic convenience and fewer pitfalls.

OneOf gives lightweight unions:

OneOf<Authorized, Declined> result = TryAuthorize();

Pros:

  • Fast and simple to use.
  • Popular in APIs that need flexible return types.

Cons:

  • Variants have no names or behavior.
  • Harder to evolve domain models because types are generic placeholders.

3.2.2 Dunet: Source generator for union types

Dunet uses attributes to generate DU types automatically:

[Union]
public partial record PaymentResult
{
    partial record Authorized(string AuthCode);
    partial record Declined(string Reason);
}

It produces exhaustive switch helpers, factory methods, equality, etc.

3.2.3 Comparison: When to roll your own vs. use a library

Roll your own when:

  • You need domain behaviors within each variant.
  • You want strict encapsulation of states.

Use a library when:

  • You want rapid modeling.
  • You don’t need behavior, just typed alternatives.

3.3 Practical Implementation: The PaymentState Machine

Let’s model a payments pipeline.

3.3.1 Modeling states

We define the possible states:

public abstract record PaymentState
{
    public sealed record Authorized(string AuthCode) : PaymentState;
    public sealed record Captured(string TransactionId) : PaymentState;
    public sealed record Declined(string Reason) : PaymentState;
    public sealed record Refunded(string RefundId) : PaymentState;
}

These states are explicit—there is no “semi-authorized” or “unknown” state unless you add it.

3.3.2 Leveraging Switch Expressions for exhaustiveness

Now callers must handle all states:

string Describe(PaymentState state) =>
    state switch
    {
        PaymentState.Authorized a => $"Authorized ({a.AuthCode})",
        PaymentState.Captured c => $"Captured ({c.TransactionId})",
        PaymentState.Declined d => $"Declined: {d.Reason}",
        PaymentState.Refunded r => $"Refunded: {r.RefundId}",
        _ => throw new InvalidOperationException()
    };

If you add a new state, the compiler warns every consumer. This is huge in large systems.

3.3.3 Using C# 12 Primary Constructors to carry distinct data per state

Primary constructors make DU variants more succinct:

public abstract record PaymentState;

public sealed record Authorized(string AuthCode) : PaymentState;
public sealed record Captured(string TransactionId) : PaymentState;
public sealed record Declined(string Reason) : PaymentState;
public sealed record Refunded(string RefundId) : PaymentState;

Or for more complex types:

public sealed record Declined(string Reason, DateTime AtUtc) : PaymentState;

Variants remain immutable and self-descriptive.


4 Railway-Oriented Programming (ROP): The End of Try-Catch

Discriminated Unions help model known states, and Smart Enums help eliminate weak domain representations. Railway-Oriented Programming ties them together by shaping how data flows through a system. Instead of branching logic or nested conditionals, we create a pipeline of operations where each step either continues cleanly or short-circuits on failure. After using this style for a while, you start to see it as a natural extension of the domain modeling techniques from earlier: if a domain operation can fail, encode it as a value rather than an exception.

The heart of ROP is the Result Pattern. Rather than using exceptions to signal business-level failures, we return an object that explicitly carries success or failure. The caller inspects the result and composes the next step. ROP doesn’t eliminate exceptions entirely—it puts them back where they belong: unexpected, truly exceptional events like an unreachable database or a corrupted file on disk.

4.1 The Result Pattern Anatomy

The Result pattern describes an operation that may succeed or fail. Where exceptions defer signaling until runtime, a Result<T> makes this explicit at compile time and keeps the control flow linear and predictable.

4.1.1 Creating a generic Result<T> or Result<T, E>

Most teams start with a simple generic form:

public sealed class Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    private Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T> Success(T value) => new(true, value, null);
    public static Result<T> Failure(string error) => new(false, default, error);
}

A variant with separate success and error types offers more structure:

public sealed class Result<T, E>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public E? Error { get; }

    private Result(bool isSuccess, T? value, E? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }

    public static Result<T, E> Ok(T value) => new(true, value, default);
    public static Result<T, E> Fail(E error) => new(false, default, error);
}

This extra detail pays off when a domain has well-defined error types, such as validation errors, authorization issues, or payment failures.

4.1.2 Distinction between “Logic Errors” (Domain rules) and “Exceptional Errors” (Database down)

ROP enforces a clean separation between two classes of failures:

  1. Logic errors These are the predictable, normal outcomes of domain behavior—invalid inputs, business rule violations, payment declines. They use the Result object.

  2. Exceptional errors These are actual exceptions—hardware failures, timeouts, corrupted files. These should propagate via real exceptions because there is nothing the domain can reasonably do.

A payment decline is not exceptional. A database crash is.

This separation is what makes the resulting flow more stable and testable.

4.2 Chaining Operations (The “Tracks”)

The defining trait of ROP is the way operations are chained. You write a series of transformations that flow only when the input is successful. Failures stop the chain immediately and return to the caller.

4.2.1 Defining extension methods: .OnSuccess(), .OnFailure(), and .Map()

These helpers make pipelines expressive:

public static class ResultExtensions
{
    public static Result<U> Map<T, U>(this Result<T> result, Func<T, U> mapper)
        => result.IsSuccess
            ? Result<U>.Success(mapper(result.Value!))
            : Result<U>.Failure(result.Error!);

    public static Result<U> OnSuccess<T, U>(this Result<T> result, Func<T, Result<U>> next)
        => result.IsSuccess ? next(result.Value!) : Result<U>.Failure(result.Error!);

    public static Result<T> OnFailure<T>(this Result<T> result, Action<string> action)
    {
        if (!result.IsSuccess)
            action(result.Error!);
        return result;
    }
}

These are the basic building blocks of ROP. You can assemble domain workflows by describing what happens when things go right.

4.2.2 Visualizing the “Happy Path” vs. the “Error Path”

In traditional C# application code, logic often sprawls:

  • if inside if
  • nested try/catch
  • early returns mixed with logging
  • switching between error codes and exceptions

ROP replaces this with two parallel tracks:

  • Happy Path: operations apply transformations in sequence
  • Error Path: once a failure occurs, remaining operations are skipped

From the outside, the method looks like a straight line. This significantly reduces the surface area where things can go wrong.

Both libraries are production-grade but serve different philosophies.

FluentResults A pragmatic OOP-centric library. Good when:

  • Your team prefers object-oriented style
  • You want structured errors but simple composition
  • You need detailed metadata (reasons, messages, error codes)

Example:

Result.Ok(order)
    .Bind(Validate)
    .Bind(ProcessPayment)
    .Bind(SendNotification);

LanguageExt A functional library that brings algebraic types, LINQ-style workflow builders, immutable collections, and more. Use it when:

  • You want a deep FP influence
  • Your system already uses DUs heavily
  • You want monadic workflows and functional abstractions

Both libraries coexist comfortably with Smart Enums and Discriminated Unions.

4.3 Refactoring a Legacy Service Method

To illustrate ROP’s clarity, let’s examine a legacy method with nested conditionals.

4.3.1 Before: A 50-line method with nested if/else and try/catch blocks

Imagine a method responsible for activating a subscription:

public Subscription Activate(int userId)
{
    try
    {
        var user = _repo.GetUser(userId);
        if (user == null)
            throw new Exception("User not found");

        if (user.IsBlocked)
            return null;

        var payment = _paymentService.Charge(user);
        if (payment == null)
            return null;

        var sub = _repo.CreateSubscription(user, payment);
        _email.SendActivation(user.Email);
        return sub;
    }
    catch (SqlException ex)
    {
        _logger.LogError(ex, "Database failure");
        throw;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected failure");
        return null;
    }
}

It’s unclear which failures are meaningful. Error signaling is inconsistent. Exceptions and nulls are mixed. The email sends even if the subscription partially fails. This is a typical example of procedural branching overwhelming domain intent.

4.3.2 After: A linear pipeline using ROP extension methods

Refactored with ROP:

public Result<Subscription> Activate(int userId)
{
    return GetUser(userId)
        .OnSuccess(CheckNotBlocked)
        .OnSuccess(ChargePayment)
        .OnSuccess(CreateSubscription)
        .OnSuccess(SendEmail);
}

private Result<User> GetUser(int id) =>
    _repo.GetUser(id) is User u
        ? Result<User>.Success(u)
        : Result<User>.Failure("User not found");

private Result<User> CheckNotBlocked(User user) =>
    user.IsBlocked
        ? Result<User>.Failure("User is blocked")
        : Result<User>.Success(user);

private Result<Payment> ChargePayment(User user) =>
    _paymentService.Charge(user) is Payment p
        ? Result<Payment>.Success(p)
        : Result<Payment>.Failure("Payment failed");

private Result<Subscription> CreateSubscription(Payment payment) =>
    Result<Subscription>.Success(_repo.CreateSubscription(payment.User, payment));

private Result<Subscription> SendEmail(Subscription sub)
{
    _email.SendActivation(sub.User.Email);
    return Result<Subscription>.Success(sub);
}

This reads like a description of the domain behavior. If any step fails, remaining steps are skipped automatically.

4.3.3 Handling async/await in ROP pipelines (The Task<Result<T>> challenge)

Async support requires wrapping extension methods to work on Task<Result<T>>. A common approach is adding OnSuccessAsync and MapAsync:

public static async Task<Result<U>> OnSuccessAsync<T, U>(
    this Task<Result<T>> resultTask,
    Func<T, Task<Result<U>>> next)
{
    var result = await resultTask;
    return result.IsSuccess
        ? await next(result.Value!)
        : Result<U>.Failure(result.Error!);
}

You can then create hybrid pipelines blending synchronous and asynchronous operations:

return GetUserAsync(id)
    .OnSuccessAsync(CheckNotBlockedAsync)
    .OnSuccessAsync(ChargePaymentAsync)
    .OnSuccessAsync(CreateSubscriptionAsync)
    .OnSuccessAsync(SendEmailAsync);

Async pipelines remain linear and predictable, preserving ROP’s clarity even in I/O-heavy domains.


5 The Convergence: Building a Robust Order Processing Pipeline

Smart Enums give us richer domain primitives. Discriminated Unions enforce state correctness. ROP organizes logic into clean, failure-aware flows. The real payoff happens when these patterns work together. In practice, this is where domain clarity replaces ad hoc conditionals, and the system becomes easier to understand and evolve.

We’ll now build an order processing pipeline using all three patterns. The example mirrors a typical e-commerce checkout: validating input, validating stock, charging payment, generating an invoice, and sending a confirmation email. When teams adopt this combined approach, their order workflows shrink dramatically in size while becoming more deterministic.

5.1 The Scenario: E-Commerce Checkout

We model a simple checkout flow that involves four major tasks: validation, payment, invoice generation, and email confirmation. Each task has its own failure modes, and we want the pipeline to short-circuit immediately if something goes wrong.

5.1.1 Requirements: Validate stock, Charge credit card, Generate invoice, Email user

At a high level, the operation does the following:

  1. Accept a checkout request
  2. Validate shipping tier, product availability, quantities, and user details
  3. Charge the customer’s payment method
  4. Generate a PDF invoice
  5. Email the invoice and confirmation

If any step fails, the operation stops. Domain errors become structured failures, not exceptions or nulls.

5.2 Step-by-Step Implementation

5.2.1 Input: CheckoutRequest (Immutable Record)

We start with an immutable record. The data structure focuses solely on the inputs of the operation.

public sealed record CheckoutRequest(
    int UserId,
    IReadOnlyList<int> ProductIds,
    SubscriptionTier Tier,
    string Email,
    string PaymentToken);

The SubscriptionTier is our Smart Enum from earlier, preventing invalid tiers from entering the system.

5.2.2 Validation: Returns Result<ValidatedOrder> using Smart Enums for shipping rules

Validation applies domain rules that depend on tier status, country, stock, or user constraints. Instead of scattering checks across services, we consolidate them into a validation method returning a structured result.

public sealed record ValidatedOrder(
    User User,
    IReadOnlyList<Product> Products,
    SubscriptionTier Tier,
    string Email,
    string PaymentToken);

The validator returns:

public async Task<Result<ValidatedOrder>> ValidateAsync(CheckoutRequest request)
{
    var user = await _userRepo.FindAsync(request.UserId);
    if (user is null)
        return Result<ValidatedOrder>.Failure("User not found");

    var products = await _inventory.LoadProductsAsync(request.ProductIds);
    if (products.Count != request.ProductIds.Count)
        return Result<ValidatedOrder>.Failure("One or more products unavailable");

    if (!request.Tier.CanAccessVideoSupport())
        return Result<ValidatedOrder>.Failure("Tier does not support this feature");

    return Result<ValidatedOrder>.Success(
        new ValidatedOrder(user, products, request.Tier, request.Email, request.PaymentToken));
}

The Smart Enum handles feature gating. No magic numbers, no string comparisons.

5.2.3 Processing: PaymentProcessor returns a Discriminated Union

Next, we execute payment using a DU that captures possible outcomes.

public abstract record PaymentResult
{
    public sealed record Success(string TransactionId) : PaymentResult;
    public sealed record InsufficientFunds : PaymentResult;
    public sealed record InvalidToken : PaymentResult;
    public sealed record GatewayUnavailable : PaymentResult;
}

The processor returns a DU rather than a boolean:

public async Task<PaymentResult> ProcessAsync(ValidatedOrder order)
{
    var response = await _gateway.ChargeAsync(order.PaymentToken, order.Products.Sum(p => p.Price));

    return response.Status switch
    {
        "ok" => new PaymentResult.Success(response.TransactionId),
        "insufficient" => new PaymentResult.InsufficientFunds(),
        "invalid_token" => new PaymentResult.InvalidToken(),
        _ => new PaymentResult.GatewayUnavailable()
    };
}

Each state is explicit. No magic nulls or exception-as-domain-flow.

5.2.4 Orchestration: tying it together with ROP extension methods

Now we integrate validation, payment, invoice generation, and email sending into one ROP-powered pipeline.

public async Task<Result<OrderConfirmation>> PlaceOrderAsync(CheckoutRequest request)
{
    return await ValidateAsync(request)
        .OnSuccessAsync(ProcessPaymentAsync)
        .OnSuccessAsync(GenerateInvoiceAsync)
        .OnSuccessAsync(SendEmailAsync);
}

Each method has a signature of Result<T> or Task<Result<T>>. This makes the entire pipeline safe and predictable.

5.3 Code Walkthrough: The PlaceOrderHandler

The handler ties domain logic together. Most of the complexity now lives in small, focused helpers.

5.3.1 Using Collection Expressions for aggregating validation errors

C# collection expressions help gather multiple validation errors at once. A combined validator might look like:

private Result<ValidatedOrder> ValidateDetails(User user, CheckoutRequest request)
{
    var errors = [
        string.IsNullOrWhiteSpace(request.Email) ? "Email missing" : null,
        request.ProductIds.Count == 0 ? "No products selected" : null,
        !request.Tier.CanAccessVideoSupport() ? "Tier does not support feature" : null
    ]
    .Where(e => e is not null)
    .ToList();

    return errors.Count > 0
        ? Result<ValidatedOrder>.Failure(string.Join("; ", errors!))
        : Result<ValidatedOrder>.Success(/* construct order */);
}

This avoids repetitive if statements and keeps validation linear and readable.

5.3.2 The “Sandwich” approach: Pure domain logic in the middle, impure I/O on the edges

A useful pattern when combining these techniques is the “domain sandwich”:

  1. Input mapping (I/O)
  2. Domain logic (pure, deterministic)
  3. Output mapping (I/O)

In our case:

  • Loading products and saving invoices are I/O
  • Payment DU interpretation, discount rules from Smart Enums, and error propagation via ROP are pure domain logic

This separation makes each part testable in isolation. The handler orchestrates but does not compute domain behavior.

The resulting code is straightforward:

public async Task<Result<OrderConfirmation>> PlaceOrderAsync(CheckoutRequest request)
{
    return await ValidateAsync(request)
        .OnSuccessAsync(async order =>
        {
            var payment = await ProcessPaymentAsync(order);
            return payment switch
            {
                PaymentResult.Success s => Result<(ValidatedOrder, string)>.Success((order, s.TransactionId)),
                PaymentResult.InsufficientFunds => Result<(ValidatedOrder, string)>.Failure("Insufficient funds"),
                PaymentResult.InvalidToken => Result<(ValidatedOrder, string)>.Failure("Invalid payment token"),
                _ => Result<(ValidatedOrder, string)>.Failure("Payment gateway unavailable")
            };
        })
        .OnSuccessAsync(tuple => GenerateInvoiceAsync(tuple.Item1, tuple.Item2))
        .OnSuccessAsync(confirmation => SendEmailAsync(confirmation));
}

The Smart Enum controls access rules. The DU shapes payment outcomes. ROP composes the whole workflow.

Each layer is small, focused, and explicit.


6 Boundaries and Persistence: Where Patterns Meet Reality

Everything up to this point has focused on modeling inside the domain. Smart Enums, Discriminated Unions, and ROP make your internal logic clearer and safer, but real systems don’t live entirely in memory. They cross service boundaries, serialize to JSON, persist to databases, and return DTOs to clients who have no idea what a DU or Smart Enum is. This is where most implementations stumble. The domain model is expressive and precise—but outside systems don’t necessarily share those conventions.

The key principle at the boundary is simple: your domain is your domain; your API contract is not your domain. That distinction must stay intact. The next sections walk through patterns I’ve used to preserve domain richness while keeping integrations stable.

6.1 The Serialization Challenge

Serialization is where rich domain types clash with external expectations. JSON clients expect plain objects and strings, not discriminated unions or Smart Enum instances.

6.1.1 Why you shouldn’t expose DUs or Smart Enums directly in API Contracts (DTOs)

Smart Enums encapsulate behavior and invariants. DUs express internal states. Exposing these across an API boundary creates several issues:

  1. Coupling Clients become aware of internal modeling choices. A refactor that adds a DU variant suddenly becomes a breaking API change.

  2. Opaque serialization By default, Smart Enums serialize as objects with properties like Name or Value, which might not match API expectations. DUs serialize as nested objects, often including type names.

  3. Schema instability OpenAPI tools struggle with polymorphic models generated from DUs. Clients rarely handle them well.

Instead, use DTOs:

  • Convert Smart Enum values to strings or numeric identifiers.
  • Project DU variants into a flattened response model.
  • Never expose domain behaviors in DTOs.

For example:

public sealed record PaymentResponseDto(
    string Status,
    string? TransactionId,
    string? Reason);

This keeps the API stable while the domain remains expressive internally.

6.1.2 Writing custom System.Text.Json converters for Discriminated Unions (Polymorphic serialization)

When you do need to serialize DUs—for example, in event logs or inter-service messages—you’ll need a custom converter. System.Text.Json supports polymorphic serialization, but not without configuration.

A DU converter might look like:

public sealed class PaymentResultJsonConverter : JsonConverter<PaymentResult>
{
    public override PaymentResult? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        using var doc = JsonDocument.ParseValue(ref reader);
        var root = doc.RootElement;

        var type = root.GetProperty("type").GetString();
        return type switch
        {
            "success" => new PaymentResult.Success(
                root.GetProperty("transactionId").GetString()!),
            "insufficient" => new PaymentResult.InsufficientFunds(),
            "invalid_token" => new PaymentResult.InvalidToken(),
            _ => new PaymentResult.GatewayUnavailable()
        };
    }

    public override void Write(
        Utf8JsonWriter writer,
        PaymentResult value,
        JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        switch (value)
        {
            case PaymentResult.Success s:
                writer.WriteString("type", "success");
                writer.WriteString("transactionId", s.TransactionId);
                break;
            case PaymentResult.InsufficientFunds:
                writer.WriteString("type", "insufficient");
                break;
            case PaymentResult.InvalidToken:
                writer.WriteString("type", "invalid_token");
                break;
            default:
                writer.WriteString("type", "gateway_unavailable");
                break;
        }

        writer.WriteEndObject();
    }
}

Register it once:

options.Converters.Add(new PaymentResultJsonConverter());

This ensures consistent, intentional JSON representations without leaking internal type names or metadata.

6.2 Database Mapping with EF Core

Persistence is another boundary where domain-focused models hit structural constraints. Databases want rows and columns; domains want types and invariants.

6.2.1 Mapping Result objects: Why they shouldn’t be persisted

Result<T> objects represent the outcome of operations, not business entities. They contain:

  • Success values
  • Error messages
  • Metadata about failures

Persisting them is a smell. They belong in domain logic, not the permanent state of the system. If you feel the urge to save a Result, consider why. It’s usually a sign that you need a dedicated table for error logs or failed transactions—not a Result column.

As a rule:

  • Persist domain entities
  • Persist audit events
  • Persist integration data
  • Do not persist Result<T>

6.2.2 Complex Type mapping for DUs: JSON Columns vs. Table-per-Hierarchy (TPH)

Discriminated Unions represent explicitly bounded state. When persisting them, we need to choose between fidelity and simplicity.

Option 1: JSON Column Recommended when:

  • The DU doesn’t participate in database queries.
  • The DU might grow new variants over time.
  • The values are primarily for reconstruction or auditing.

Mapping:

builder.Property(x => x.PaymentState)
    .HasConversion(
        v => JsonSerializer.Serialize(v, options),
        v => JsonSerializer.Deserialize<PaymentState>(v, options))
    .HasColumnType("jsonb");

This keeps the database schema stable as variants evolve.

Option 2: Table-per-Hierarchy (TPH) Recommended when:

  • You need to query by variant type.
  • Payment states affect business queries or reporting.

Example:

modelBuilder.Entity<PaymentState>()
    .HasDiscriminator<string>("StateType")
    .HasValue<PaymentState.Authorized>("authorized")
    .HasValue<PaymentState.Captured>("captured")
    .HasValue<PaymentState.Declined>("declined")
    .HasValue<PaymentState.Refunded>("refunded");

This allows queries like:

var declinedPayments = db.Payments
    .Where(p => p.State is PaymentState.Declined)
    .ToList();

The trade-off is schema churn and more complicated migrations.

6.3 Open API (Swagger) Integration

Integrating discriminated unions with Swagger requires extra work because OpenAPI expects static schemas.

6.3.1 Documenting OneOf return types in Swagger using Swashbuckle filters

A common approach is to apply a schema filter for endpoints that return OneOf or DU-based responses. For example:

public class OneOfResponseFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        if (!context.ApiDescription.TryGetMethodInfo(out var method))
            return;

        var returnType = method.ReturnType;
        if (!returnType.IsGenericType ||
            returnType.GetGenericTypeDefinition() != typeof(OneOf<>))
            return;

        var genericArgs = returnType.GetGenericArguments();
        var schemas = genericArgs.Select(arg =>
            context.SchemaGenerator.GenerateSchema(arg, context.SchemaRepository));

        operation.Responses["200"].Content["application/json"].Schema =
            new OpenApiSchema
            {
                OneOf = schemas.ToList()
            };
    }
}

Then register:

services.AddSwaggerGen(c =>
{
    c.OperationFilter<OneOfResponseFilter>();
});

This makes OneOf<OrderDto, ErrorDto> render correctly in Swagger UI.


7 Performance and Overhead Analysis

Modern modeling techniques come with costs. They improve clarity and correctness, but they can introduce overhead. When adopting Smart Enums, DUs, or ROP in large systems, architects should understand allocation behavior, memory pressure, and build-time safety costs.

7.1 Benchmarking Allocation Costs

I typically benchmark domain types when they appear in high-throughput code paths—payment flows, event processing, or hot loops. Two areas matter most: struct vs class for results, and boxing from DU libraries.

7.1.1 Result<T> (struct) vs Result<T> (class): Garbage Collector pressure

A class-based Result<T> allocates on every use unless you cache static instances. In heavy pipelines, these objects accumulate quickly. You can eliminate allocations by using struct-based results:

public readonly struct Result<T>
{
    public bool IsSuccess { get; }
    public T? Value { get; }
    public string? Error { get; }

    public Result(bool isSuccess, T? value, string? error)
    {
        IsSuccess = isSuccess;
        Value = value;
        Error = error;
    }
}

A struct result avoids GC entirely, but at the cost of potential copying overhead for large T. For typical domain objects, the cost difference is negligible. The best choice depends on usage patterns:

  • For request/response validation → class is fine
  • For high-throughput I/O pipelines → struct reduces GC pressure

Benchmarking helps quantify this trade-off.

7.1.2 The cost of OneOf boxing/unboxing

OneOf is convenient but relies on boxed values internally. If used solely in application services, this overhead is negligible. In tight loops or asynchronous streams with thousands of events per second, it becomes visible.

In a simple BenchmarkDotNet comparison:

  • DU variants as classes → ~10ns allocation + GC pressure
  • OneOf<T1, T2> → ~20ns due to boxing
  • Manual DU hierarchy → 8–12ns

The practical takeaway: use manual DUs for high-performance domains, and use OneOf for lightweight application APIs.

7.2 Readability vs. Complexity Curve

Adopting functional techniques isn’t free. The balance between readability and abstraction matters for teams with varying experience levels.

7.2.1 Onboarding junior developers to ROP

ROP feels foreign at first. Many developers expect exceptions to signal failure and struggle with the idea of composing methods through results. A few steps help:

  1. Introduce ROP incrementally, starting with validation logic.
  2. Pair on domain-heavy flows to show before/after readability.
  3. Build a shared library of extension methods to reduce friction.

In my experience, once developers grasp the flow, they prefer it to nested error handling.

7.2.2 When to stop: Avoiding “Functional Monk” syndrome (Over-abstracting simple CRUD)

Some engineers enjoy abstraction a little too much. Don’t wrap basic repository calls in elaborate pipelines. Don’t create DUs for trivial boolean outcomes. These patterns shine in rich domain behaviors—not CRUD.

A practical heuristic:

  • If an operation has more than three predictable error cases → DU
  • If verification has multiple failure points → ROP
  • If a value has behavior and invariants → Smart Enum
  • Otherwise → Keep it simple

Functional purity is not the goal. Shipping maintainable systems is.

7.3 Testing Strategy

One of the biggest wins from these patterns is how they simplify testing. Behavior becomes explicit and isolated.

7.3.1 How ROP simplifies Unit Testing (Asserting on Result.IsSuccess)

Instead of asserting exceptions or mocking half of the system, tests become direct:

[Fact]
public void Validate_ShouldFail_WhenEmailMissing()
{
    var request = new CheckoutRequest(
        1,
        new[] { 1, 2 },
        SubscriptionTier.Pro,
        "",
        "tok_123");

    var result = validator.ValidateDetails(user, request);
    Assert.False(result.IsSuccess);
    Assert.Contains("Email missing", result.Error);
}

No try/catch. No noise.

7.3.2 Generating test data for exhaustive DU scenarios

Testing DUs is straightforward because you know all possible states. A helper like this generates all variants:

public static IEnumerable<PaymentResult> GetAllPaymentResults()
{
    yield return new PaymentResult.Success("123");
    yield return new PaymentResult.InsufficientFunds();
    yield return new PaymentResult.InvalidToken();
    yield return new PaymentResult.GatewayUnavailable();
}

You can then write:

[Theory]
[MemberData(nameof(GetAllPaymentResults))]
public void Describe_ShouldHandleAllCases(PaymentResult result)
{
    var output = Describe(result);
    Assert.False(string.IsNullOrWhiteSpace(output));
}

This ensures exhaustive coverage with very little boilerplate.


8 Conclusion: The Future of C# OOP

Modern C# pushes us toward expressive, type-driven design. SOLID laid the groundwork, but today’s systems benefit from stricter modeling, functional composition, and immutable domain values. Smart Enums, Discriminated Unions, and ROP align with that trajectory—not as replacements for OOP but as enhancements that help teams ship more predictable systems.

8.1 Summary of Benefits: Expressiveness, Maintainability, and Safety

These three patterns reinforce each other:

  • Smart Enums eliminate primitive obsession and encode domain rules in types.
  • Discriminated Unions make illegal states unrepresentable and force exhaustive handling.
  • ROP simplifies domain flows and makes failures explicit and composable.

Together they reduce branching logic, prevent bugs before runtime, and create codebases that read like descriptions of the business.

8.2 Looking Ahead: The proposal for union types in C# (Active design discussions on GitHub)

The C# team is exploring native union types. The proposals focus on:

  • Exhaustive pattern matching
  • Compact syntax for sum types
  • Improved tooling and analyzers

If union types become first-class citizens, we’ll see cleaner models with less ceremony. Manual hierarchies and custom converters would shrink. The concepts in this article map directly to those proposals, so adopting these practices prepares your systems for future language evolution.

8.3 Next Steps: A checklist for migrating a legacy module to these patterns

For teams wanting to introduce these ideas gradually:

  1. Replace primitive flags and constants with Smart Enums.
  2. Identify domain areas with more than three states and model them with DUs.
  3. Introduce Result<T> for validation and business rules where exceptions were misused.
  4. Build small ROP pipelines for high-complexity flows.
  5. Keep DTOs and API models separate from domain types.
  6. Benchmark any performance-critical paths.
  7. Document patterns and build internal templates.

With steady adoption, the cumulative effect is substantial: fewer production bugs, clearer code, and easier onboarding. Not because the system is clever, but because the domain becomes explicit.

Advertisement