Using Algebraic Data Types in .NET Framework / .NET — Designing States and Results with Types
· Go Komura · .NET, .NET Framework, C#, F#, Algebraic Data Types, Discriminated Union, Domain Modeling, Legacy Asset Reuse
1. The First Thing to Understand
When you write .NET business applications, you see return values and states like this all the time.
public class CreateUserResult
{
public bool IsSuccess { get; set; }
public User User { get; set; }
public string ErrorCode { get; set; }
public string ErrorMessage { get; set; }
}
At first glance it looks easy to understand, but this type admits many states that should never exist.
For example, you can construct values like these:
IsSuccess == truebutUser == nullIsSuccess == truebutErrorCodeis setIsSuccess == falsebutUseris setErrorCode == "DuplicateEmail"butErrorMessage == null- A new error code was added, but the calling code was never updated to handle it
Types like this may be convenient at first, but as the codebase grows they place an increasing burden on readers and maintainers.
The idea we want to apply here is algebraic data types.
The name “algebraic data type” sounds a bit formal, but in practical terms it is easiest to think of it like this:
Express the fact that “this value has a predetermined set of possible shapes” with types — not with comments or naming conventions.
For example, you can express that the result of creating a user is exactly one of the following:
CreateUserResult =
Created(User)
or DuplicateEmail(email)
or WeakPassword(reason)
or SystemFailure(message)
On success, there is a User.
On a duplicate email, there is an email.
On a weak password, there is a reason.
On a system error, there is a message.
Each case carries only the data it needs.
Success and failure can never be true at the same time.
A “successful but no User” state simply cannot be constructed.
In .NET, you can implement this idea with discriminated unions in F#, and in C# with sealed class hierarchies, record hierarchies, libraries like OneOf, or the upcoming C# union types.
This article walks through how to use algebraic data types on both .NET Framework and current .NET, along with the practical benefits and caveats.
All of the code in this article is published on GitHub as a buildable, runnable sample set (a library, demos showcasing each implementation pattern, and unit tests verifying Match exhaustiveness, state transitions, and DTO conversion).
dotnet-algebraic-data-types - komurasoft-blog-samples (GitHub)
2. What Is an Algebraic Data Type?
Algebraic data types are commonly abbreviated as ADTs.
Roughly speaking, an ADT is a combination of two kinds of types:
- Product types: a type that holds both A and B
- Sum types: a type that is either A or B
.NET classes, structs, and records are most often used as “product types.”
public sealed class Address
{
public string PostalCode { get; }
public string Prefecture { get; }
public string City { get; }
public string Street { get; }
public Address(string postalCode, string prefecture, string city, string street)
{
PostalCode = postalCode;
Prefecture = prefecture;
City = city;
Street = street;
}
}
Semantically, this means:
Address = PostalCode and Prefecture and City and Street
A sum type, on the other hand, is “exactly one of these.”
PaymentResult =
Succeeded(receiptNo)
or InsufficientFunds(shortage)
or Rejected(reason)
or NetworkFailure(message)
Semantically:
PaymentResult = Succeeded or InsufficientFunds or Rejected or NetworkFailure
Expressing this “or” as a type is the part of algebraic data types that you use most often in real-world work.
In F#, you can write this naturally as a language feature.
type PaymentResult =
| Succeeded of receiptNo: string
| InsufficientFunds of shortage: decimal
| Rejected of reason: string
| NetworkFailure of message: string
For a long time, C# had no standard equivalent of F#’s discriminated unions. So in C#, we have expressed them with class hierarchies and libraries.
That said, the idea itself works perfectly well in C#, too.
What matters is not using a particular syntax, but this single point:
“Make invalid states impossible to construct in the first place.”
3. Why bool and enum Alone Are Not Enough
For small pieces of logic, bool or enum may look sufficient.
For example, a return value like this:
public enum PaymentStatus
{
Succeeded,
InsufficientFunds,
Rejected,
NetworkFailure
}
public sealed class PaymentResponse
{
public PaymentStatus Status { get; set; }
public string ReceiptNo { get; set; }
public decimal? Shortage { get; set; }
public string Reason { get; set; }
public string Message { get; set; }
}
But in this shape, the relationship between Status and each property is not expressed in the type system.
ReceiptNo is required only when Status == Succeeded.
Shortage is required only when Status == InsufficientFunds.
Reason is required only when Status == Rejected.
Message is required only when Status == NetworkFailure.
These rules live outside the code.
They depend on comments, specification documents, tests, implicit agreements, and the implementer’s memory.
As a result, defensive code like this proliferates:
if (response.Status == PaymentStatus.Succeeded)
{
if (string.IsNullOrEmpty(response.ReceiptNo))
{
throw new InvalidOperationException("ReceiptNo is required.");
}
return response.ReceiptNo;
}
Defensive code like this has its place, but much of it could have been prevented by “type design” in the first place.
If you express this as an algebraic data type, each case carries only the data it needs.
Succeeded carries receiptNo
InsufficientFunds carries shortage
Rejected carries reason
NetworkFailure carries message
With this design, you cannot construct a Succeeded value without a receiptNo.
In other words, instead of working hard to check states after the fact, you make invalid states unconstructible from the start.
4. An Implementation That Works on .NET Framework: A Sealed Class Hierarchy
For existing systems including .NET Framework, the easiest pattern to adopt is an abstract base class + nested sealed classes + a Match method.
It is friendly even to older C# versions and requires no special runtime features.
As an example, let’s model the result of creating a user.
public abstract class CreateUserResult
{
private CreateUserResult()
{
}
public sealed class Created : CreateUserResult
{
internal Created(User user)
{
if (user == null) throw new ArgumentNullException(nameof(user));
User = user;
}
public User User { get; }
}
public sealed class DuplicateEmail : CreateUserResult
{
internal DuplicateEmail(string email)
{
if (email == null) throw new ArgumentNullException(nameof(email));
Email = email;
}
public string Email { get; }
}
public sealed class WeakPassword : CreateUserResult
{
internal WeakPassword(string reason)
{
if (reason == null) throw new ArgumentNullException(nameof(reason));
Reason = reason;
}
public string Reason { get; }
}
public sealed class SystemFailure : CreateUserResult
{
internal SystemFailure(string message)
{
if (message == null) throw new ArgumentNullException(nameof(message));
Message = message;
}
public string Message { get; }
}
public static CreateUserResult Ok(User user)
=> new Created(user);
public static CreateUserResult EmailAlreadyUsed(string email)
=> new DuplicateEmail(email);
public static CreateUserResult PasswordIsWeak(string reason)
=> new WeakPassword(reason);
public static CreateUserResult Failed(string message)
=> new SystemFailure(message);
public T Match<T>(
Func<Created, T> created,
Func<DuplicateEmail, T> duplicateEmail,
Func<WeakPassword, T> weakPassword,
Func<SystemFailure, T> systemFailure)
{
if (created == null) throw new ArgumentNullException(nameof(created));
if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
if (weakPassword == null) throw new ArgumentNullException(nameof(weakPassword));
if (systemFailure == null) throw new ArgumentNullException(nameof(systemFailure));
var c = this as Created;
if (c != null) return created(c);
var d = this as DuplicateEmail;
if (d != null) return duplicateEmail(d);
var w = this as WeakPassword;
if (w != null) return weakPassword(w);
var f = this as SystemFailure;
if (f != null) return systemFailure(f);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
The calling code looks like this:
CreateUserResult result = service.CreateUser(command);
string message = result.Match(
created => "User created: " + created.User.Id,
duplicate => "This email address is already in use: " + duplicate.Email,
weak => "The password is too weak: " + weak.Reason,
failure => "Failed to create the user: " + failure.Message);
The advantage of this form is that it works on .NET Framework and on current .NET alike.
Created, DuplicateEmail, WeakPassword, and SystemFailure are all CreateUserResult, but each carries different data.
Only Created has a User.
Only DuplicateEmail has an Email.
Only WeakPassword has a Reason.
Only SystemFailure has a Message.
A value that represents success and failure at the same time cannot be constructed.
Moreover, if callers go through Match, you can force them to handle every case.
Say you add a new case, TemporaryBlocked:
public sealed class TemporaryBlocked : CreateUserResult
{
internal TemporaryBlocked(DateTimeOffset until)
{
Until = until;
}
public DateTimeOffset Until { get; }
}
You then also add a Func<TemporaryBlocked, T> parameter to the Match method.
Every existing result.Match(...) call now fails to compile. This is a good error: it lets you discover, at compile time, that “a new case was added but a caller has not handled it yet.”
5. Use a private Constructor to Close the Set of Cases
When modeling sum types in C#, it is important to keep the set of cases as closed as possible.
If you make the base class constructor protected, you leave room for external inheritance.
public abstract class PaymentResult
{
protected PaymentResult()
{
}
}
In this form, another assembly or another part of the codebase can create a type like this:
public sealed class UnknownPaymentResult : PaymentResult
{
}
Now the set of PaymentResult cases is no longer closed.
You want to say “this type is one of Succeeded / InsufficientFunds / Rejected / NetworkFailure,” but other cases can creep in.
A practical countermeasure that works even on .NET Framework is to make the base class constructor private and define the case types as nested types of the base class.
public abstract class PaymentResult
{
private PaymentResult()
{
}
public sealed class Succeeded : PaymentResult
{
internal Succeeded(string receiptNo)
{
ReceiptNo = receiptNo;
}
public string ReceiptNo { get; }
}
public sealed class InsufficientFunds : PaymentResult
{
internal InsufficientFunds(decimal shortage)
{
Shortage = shortage;
}
public decimal Shortage { get; }
}
public static PaymentResult Success(string receiptNo)
=> new Succeeded(receiptNo);
public static PaymentResult Insufficient(decimal shortage)
=> new InsufficientFunds(shortage);
}
Nested types can access the private members of their enclosing type.
Therefore, only the nested case types are able to inherit from PaymentResult.
With this pattern, you can get something close to a “closed set of cases” in C#, too.
That said, the C# compiler does not give you the full exhaustiveness checking that F# does.
So when you use this pattern in C#, it is best to avoid scattering switch statements everywhere and instead concentrate the handling in the Match method.
6. On Current .NET, a record Hierarchy Is More Concise
If you can assume .NET 5 or later, C# record types let you write data-centric case types much more briefly.
public abstract record CreateUserResult
{
private CreateUserResult()
{
}
public sealed record Created(User User) : CreateUserResult;
public sealed record DuplicateEmail(string Email) : CreateUserResult;
public sealed record WeakPassword(string Reason) : CreateUserResult;
public sealed record SystemFailure(string Message) : CreateUserResult;
}
Callers can use pattern matching and switch expressions.
static string ToMessage(CreateUserResult result)
{
return result switch
{
CreateUserResult.Created { User: var user }
=> $"User created: {user.Id}",
CreateUserResult.DuplicateEmail { Email: var email }
=> $"This email address is already in use: {email}",
CreateUserResult.WeakPassword { Reason: var reason }
=> $"The password is too weak: {reason}",
CreateUserResult.SystemFailure { Message: var message }
=> $"Failed to create the user: {message}",
_ => throw new InvalidOperationException("Unknown result.")
};
}
This style is idiomatic C# and very readable.
There are caveats, though.
Record hierarchies are great for reducing the boilerplate of value comparison and display. However, it is safer not to assume they close the set of cases as strictly as the “plain class + private constructor + nested sealed cases” pattern shown in the previous chapter.
In particular, with non-sealed record classes, record-specific generated members such as the copy constructor come into play. If you absolutely must prevent external derivation or need a strictly closed set of cases, the class hierarchy from the previous chapter, F# discriminated unions, or a proven union / source generator library are the more solid choices.
Also, adding _ to this switch expression makes it look like you can accept unknown derived types.
But if your design treats the set of cases as closed, _ is a branch that “should never be reached.”
In C#, within the older stable versions, you cannot expect exhaustiveness checking as strict as F#’s discriminated unions. So when using record hierarchies in C#, it is safer to lean on one of these two approaches:
- Provide a
Matchmethod to force callers to handle every case - Localize
switchusage instead of scattering it around
For example, you can add Match to the record hierarchy as well.
public abstract record CreateUserResult
{
private CreateUserResult()
{
}
public sealed record Created(User User) : CreateUserResult;
public sealed record DuplicateEmail(string Email) : CreateUserResult;
public sealed record WeakPassword(string Reason) : CreateUserResult;
public sealed record SystemFailure(string Message) : CreateUserResult;
public T Match<T>(
Func<Created, T> created,
Func<DuplicateEmail, T> duplicateEmail,
Func<WeakPassword, T> weakPassword,
Func<SystemFailure, T> systemFailure)
{
return this switch
{
Created x => created(x),
DuplicateEmail x => duplicateEmail(x),
WeakPassword x => weakPassword(x),
SystemFailure x => systemFailure(x),
_ => throw new InvalidOperationException("Unknown result.")
};
}
}
With this in place, callers always handle every case consciously.
var message = result.Match(
created => $"Created: {created.User.Id}",
duplicate => $"Duplicate: {duplicate.Email}",
weak => $"The password is weak: {weak.Reason}",
failure => $"Failed: {failure.Message}");
The benefit of records is less boilerplate around value comparison, display, and copying. However, in shared libraries that also target .NET Framework, plain classes are sometimes easier to work with than forcing records or init-only properties.
Prioritize “locking the states you want to express into types” over “using new syntax.”
7. Using F# Discriminated Unions
The language that handles algebraic data types most naturally on .NET is F#.
F# provides discriminated unions as a language feature.
type CreateUserResult =
| Created of user: User
| DuplicateEmail of email: string
| WeakPassword of reason: string
| SystemFailure of message: string
The calling side is natural as well.
let toMessage result =
match result with
| Created user -> $"User created: {user.Id}"
| DuplicateEmail email -> $"This email address is already in use: {email}"
| WeakPassword reason -> $"The password is too weak: {reason}"
| SystemFailure message -> $"Failed to create the user: {message}"
The great thing about F# is that case enumeration and pattern matching are integrated into the language.
When you add a case, missing handling in match expressions is easy to find.
Types like Option<'T>, which express whether a value exists or not, are also natural discriminated unions.
let tryFindUser id : User option =
// Some user if found, None if not found
failwith "sample"
By returning an option instead of null, the possibility of “not existing” shows up in the type.
F# discriminated unions compile to ordinary .NET types, so you can use them in F# projects targeting .NET Framework as well as current .NET.
However, consuming F# discriminated unions directly from C# is sometimes not as natural as working with them inside F#.
So a realistic division of labor looks like this:
- Use F# discriminated unions aggressively in F#-internal domain logic
- For public APIs frequently called from C#, convert to DTOs or class hierarchies that are comfortable in C#
- At the boundaries, map to whatever representation JSON or the database requires
If you can separate “strong types inside the domain, convenient types at the external boundary,” mixing F# and C# becomes much more manageable.
8. Using a Library Like OneOf
If you want a lightweight way to express sum types in C#, libraries like OneOf are also an option.
For example, you can express a return value like this:
using OneOf;
public sealed class DuplicateEmail
{
public DuplicateEmail(string email)
{
Email = email;
}
public string Email { get; }
}
public sealed class WeakPassword
{
public WeakPassword(string reason)
{
Reason = reason;
}
public string Reason { get; }
}
public OneOf<User, DuplicateEmail, WeakPassword> CreateUser(CreateUserCommand command)
{
if (EmailExists(command.Email))
{
return new DuplicateEmail(command.Email);
}
if (!IsStrongPassword(command.Password))
{
return new WeakPassword("Use at least 12 characters.");
}
return CreateUserCore(command);
}
The caller handles it with Match.
var result = service.CreateUser(command);
var message = result.Match(
user => $"Created: {user.Id}",
duplicate => $"Duplicate: {duplicate.Email}",
weak => $"The password is weak: {weak.Reason}");
OneOf<User, DuplicateEmail, WeakPassword> means “this value is exactly one of User, DuplicateEmail, or WeakPassword.”
The benefit of this approach is that you get convenient, localized return types without creating a dedicated base class.
It is especially well suited to expressing return values like these in application service or use case layers:
User creation result = User or DuplicateEmail or WeakPassword
Product retrieval result = Product or NotFound or AccessDenied
Payment result = Receipt or InsufficientFunds or PaymentRejected
There are caveats, however.
If you expose types like OneOf<A, B, C> directly in public APIs, the domain-level names can fade away.
For example, the following two look structurally identical if you only read the type arguments:
OneOf<User, NotFound, AccessDenied> GetUser(...)
OneOf<Order, NotFound, AccessDenied> GetOrder(...)
Convenient in small scopes — but if you want the domain meaning to be explicit, a dedicated type reads better.
public abstract class GetUserResult
{
// Found / NotFound / AccessDenied
}
Rules of thumb for choosing:
- For localized return values,
OneOfis convenient - For concepts that recur throughout the domain, create a dedicated type
- If public API stability matters, use a named result type
Note that OneOf supports a wide range of targets including .NET Framework and .NET Standard, which makes it an easy option to introduce into existing .NET Framework assets.
9. Using Source Generator-Based Libraries
On current .NET, there are also libraries that generate discriminated-union-style types using Source Generators.
For example, some generate Switch, Map, validation, and serialization integration code just from an attribute.
Conceptually it looks like this:
[Union]
public partial record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record Failure(string Error) : Result<T>;
}
Such libraries reduce the boilerplate of hand-written Match and Switch code.
Some also pair with Analyzers to warn about missing case handling.
However, when using them in existing systems that include .NET Framework, check the following:
- Do the target TFMs support .NET Framework?
- Is the SDK / Visual Studio / MSBuild environment needed for Source Generators in place?
- Does the CI environment produce the same generated output?
- Can you debug the generated code?
- Does the JSON / DB / OpenAPI integration at the application boundary behave as expected?
In particular, on older .NET Framework projects, packages that assume Source Generators may not work as-is.
If you need strong .NET Framework support, starting with hand-written class hierarchies or OneOf is the safer route.
10. About C# 15 union Types
As of June 2026, C# 15 union types have arrived as a preview feature.
In the direction the preview is taking, you can declare that “this type is exactly one of the specified types.”
public record class Cat(string Name);
public record class Dog(string Name);
public record class Bird(string Name);
public union Pet(Cat, Dog, Bird);
Callers handle each case with pattern matching.
static string Describe(Pet pet)
{
return pet switch
{
Cat cat => $"Cat: {cat.Name}",
Dog dog => $"Dog: {dog.Name}",
Bird bird => $"Bird: {bird.Name}",
Pet { Value: null } => "Unknown pet"
};
}
Once this feature stabilizes, C# will be able to handle “closed sets of types” and “exhaustive pattern matching” much more naturally.
If the generated type in the preview is a struct, values whose internal Value is null — such as default(Pet) — can also be passed around.
Public methods that accept union values need to handle such default values defensively.
That said, preview features should be evaluated carefully before they go into production code.
The language specification, IDE support, runtime helper types, Analyzers, and serializer integration may all change before the official release.
So for practical work today, this positioning is realistic:
- C# unions are worth trying for greenfield validation and technical research
- For production code with long-term maintenance, use the stable options: F# DUs, class / record hierarchies, OneOf, Source Generators
- Organize your return values and states as “exactly one of these” types now, so migrating to C# unions later is easy
In other words, you do not need to wait for C# unions to start designing with ADTs today.
In fact, organizing your Result, Option, state types, and domain event types now will make a future migration to the language feature much smoother.
11. The Option Type: Expressing “Absent” Instead of null
A flagship example of algebraic data types is Option<T>.
Option<T> represents one of the following:
Some(value)
None
In C# we often use null to mean “absent,” but null has the problem of being invisible in the type.
User user = repository.FindById(id);
// The caller has to remember whether user can be null
Console.WriteLine(user.Name);
With Option<User>, the possibility of “not found” appears in the type.
Here is a simple implementation that works even on .NET Framework.
public abstract class Option<T>
{
private Option()
{
}
public sealed class Some : Option<T>
{
internal Some(T value)
{
Value = value;
}
public T Value { get; }
}
public sealed class None : Option<T>
{
internal None()
{
}
}
private static readonly None NoneValue = new None();
public static Option<T> Of(T value)
{
if (object.Equals(value, null))
{
return NoneValue;
}
return new Some(value);
}
public static Option<T> Empty()
{
return NoneValue;
}
public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
{
if (some == null) throw new ArgumentNullException(nameof(some));
if (none == null) throw new ArgumentNullException(nameof(none));
var s = this as Some;
if (s != null) return some(s.Value);
return none();
}
}
The calling code looks like this:
Option<User> user = repository.FindById(id);
string displayName = user.Match(
some: u => u.Name,
none: () => "Guest");
You do not need to eliminate null completely.
Existing .NET APIs, databases, and JSON all produce null.
But inside domain logic, Option<T> often makes the intent clearer than null does.
It is particularly well suited to methods like these:
Option<User> TryFindUser(UserId id);
Option<Customer> FindCustomerByEmail(Email email);
Option<Discount> GetApplicableDiscount(Order order);
The point is to express the “possibility of absence” not just by prefixing the method name with Try, but in the return type itself.
12. The Result Type: Returning Expected Failures as Types
Another frequently used type is Result<TSuccess, TError>.
It represents one of the following:
Success(value)
Failure(error)
Exceptions are well suited to unexpected failures, or failures you do not want flowing through normal control flow. On the other hand, failures that routinely occur in the business domain are often more readable when returned as types.
For example, in a login flow, these failures are all expected:
- The user does not exist
- The password is wrong
- The account is locked
- Multi-factor authentication is required
If you express these with exceptions alone, callers end up writing business branching inside catch blocks.
try
{
var session = auth.Login(userName, password);
return Ok(session);
}
catch (InvalidPasswordException)
{
return Unauthorized();
}
catch (AccountLockedException)
{
return Forbid();
}
It works, but business-level branching tends to get buried in exception handling.
Expressed in ADT style, it looks like this:
public abstract class LoginResult
{
private LoginResult()
{
}
public sealed class Succeeded : LoginResult
{
internal Succeeded(Session session)
{
Session = session;
}
public Session Session { get; }
}
public sealed class InvalidPassword : LoginResult
{
internal InvalidPassword()
{
}
}
public sealed class AccountLocked : LoginResult
{
internal AccountLocked(DateTimeOffset until)
{
Until = until;
}
public DateTimeOffset Until { get; }
}
public sealed class MfaRequired : LoginResult
{
internal MfaRequired(string challengeId)
{
ChallengeId = challengeId;
}
public string ChallengeId { get; }
}
public static LoginResult Success(Session session)
=> new Succeeded(session);
public static LoginResult WrongPassword()
=> new InvalidPassword();
public static LoginResult Locked(DateTimeOffset until)
=> new AccountLocked(until);
public static LoginResult RequireMfa(string challengeId)
=> new MfaRequired(challengeId);
public T Match<T>(
Func<Succeeded, T> succeeded,
Func<InvalidPassword, T> invalidPassword,
Func<AccountLocked, T> accountLocked,
Func<MfaRequired, T> mfaRequired)
{
if (succeeded == null) throw new ArgumentNullException(nameof(succeeded));
if (invalidPassword == null) throw new ArgumentNullException(nameof(invalidPassword));
if (accountLocked == null) throw new ArgumentNullException(nameof(accountLocked));
if (mfaRequired == null) throw new ArgumentNullException(nameof(mfaRequired));
var s = this as Succeeded;
if (s != null) return succeeded(s);
var i = this as InvalidPassword;
if (i != null) return invalidPassword(i);
var l = this as AccountLocked;
if (l != null) return accountLocked(l);
var m = this as MfaRequired;
if (m != null) return mfaRequired(m);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
In this form, the caller implements while looking at “the possible outcomes of the login flow.”
var result = auth.Login(userName, password);
return result.Match(
succeeded => Ok(succeeded.Session),
invalidPassword => Unauthorized(),
accountLocked => StatusCode(423),
mfaRequired => Accepted(new { mfaRequired.ChallengeId }));
The point is not to abandon exceptions.
Divide the roles: expected business branching goes through Result; unexpected anomalies go through exceptions.
That alone substantially improves the clarity of your application service and API layers.
13. Expressing State Transitions with Types
ADTs are good not only for return values but also for representing state.
Consider order states, for example.
public enum OrderStatus
{
Draft,
Submitted,
Paid,
Shipped,
Cancelled
}
With an enum alone, it is hard to express the data each state needs.
Draftneeds a creatorSubmittedneeds a submission timestampPaidneeds a payment numberShippedneeds a tracking numberCancelledneeds a cancellation reason
If you try to express these with OrderStatus plus separate properties, the nullable properties multiply again.
public sealed class Order
{
public OrderStatus Status { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public string PaymentNo { get; set; }
public string TrackingNo { get; set; }
public string CancelReason { get; set; }
}
With this design, you can create a state where Status == Draft but TrackingNo is set.
In ADT style, the state itself becomes a type.
public abstract class OrderState
{
private OrderState()
{
}
public sealed class Draft : OrderState
{
internal Draft(UserId createdBy)
{
CreatedBy = createdBy;
}
public UserId CreatedBy { get; }
}
public sealed class Submitted : OrderState
{
internal Submitted(DateTimeOffset submittedAt)
{
SubmittedAt = submittedAt;
}
public DateTimeOffset SubmittedAt { get; }
}
public sealed class Paid : OrderState
{
internal Paid(string paymentNo)
{
PaymentNo = paymentNo;
}
public string PaymentNo { get; }
}
public sealed class Shipped : OrderState
{
internal Shipped(string trackingNo)
{
TrackingNo = trackingNo;
}
public string TrackingNo { get; }
}
public sealed class Cancelled : OrderState
{
internal Cancelled(string reason)
{
Reason = reason;
}
public string Reason { get; }
}
}
The order holds an OrderState.
public sealed class Order
{
public OrderId Id { get; }
public OrderState State { get; private set; }
public Order(OrderId id, UserId createdBy)
{
Id = id;
State = new OrderState.Draft(createdBy);
}
}
State transitions are then confined to methods.
public void Submit(IClock clock)
{
if (!(State is OrderState.Draft))
{
throw new InvalidOperationException("Only orders in the draft state can be submitted.");
}
State = new OrderState.Submitted(clock.Now);
}
public void MarkAsPaid(string paymentNo)
{
if (!(State is OrderState.Submitted))
{
throw new InvalidOperationException("Only submitted orders can be marked as paid.");
}
State = new OrderState.Paid(paymentNo);
}
In this form, per-state data and the state transition rules become easy to read.
Of course, when persisting, you may still split the state into an OrderStatus column and auxiliary columns.
Even then, treat it as OrderState inside the domain and convert at the database boundary.
Representation in the DB
status = "Paid"
payment_no = "PAY-001"
Representation inside the domain
OrderState.Paid("PAY-001")
There is no need to weaken your domain model to fit the DB schema.
14. Convert to DTOs at API Boundaries
ADT-style types are extremely useful inside the domain.
At JSON APIs, databases, message queues, OpenAPI, and external integrations, however, some care is needed.
Say you serialize this ADT directly to JSON:
public abstract record PaymentResult
{
public sealed record Succeeded(string ReceiptNo) : PaymentResult;
public sealed record Rejected(string Reason) : PaymentResult;
public sealed record NetworkFailure(string Message) : PaymentResult;
}
As JSON, you might want a shape like this:
{
"type": "succeeded",
"receiptNo": "R-001"
}
And for a failure:
{
"type": "rejected",
"reason": "card_expired"
}
This type is a discriminator on the JSON side.
The domain ADT and the JSON representation are similar, but they are not the same thing.
Therefore, converting to DTOs at the external boundary is the safer design.
public sealed class PaymentResultDto
{
public string Type { get; set; }
public string ReceiptNo { get; set; }
public string Reason { get; set; }
public string Message { get; set; }
}
A conversion routine creates the DTO per ADT case.
public static PaymentResultDto ToDto(PaymentResult result)
{
return result switch
{
PaymentResult.Succeeded x => new PaymentResultDto
{
Type = "succeeded",
ReceiptNo = x.ReceiptNo
},
PaymentResult.Rejected x => new PaymentResultDto
{
Type = "rejected",
Reason = x.Reason
},
PaymentResult.NetworkFailure x => new PaymentResultDto
{
Type = "network_failure",
Message = x.Message
},
_ => throw new InvalidOperationException("Unknown payment result.")
};
}
Of course, you can also use System.Text.Json polymorphic serialization or custom converters.
But for APIs maintained over the long term, it is usually safer not to couple the JSON shape tightly to the internal structure of your domain types.
The recommended separation:
Inside the domain
PaymentResult.Succeeded
PaymentResult.Rejected
PaymentResult.NetworkFailure
At the API boundary
PaymentResultDto
type: "succeeded" | "rejected" | "network_failure"
Let the domain types focus on expressing the business; keep the external representation stable through DTOs.
With this separation, you can keep improving the domain internals while preserving API compatibility.
15. Benefit 1: Invalid States Become Hard to Construct
The biggest benefit of ADTs is that invalid states become hard to construct.
For example, a type like this makes invalid combinations easy to create:
public sealed class Reservation
{
public bool IsCancelled { get; set; }
public DateTimeOffset? CancelledAt { get; set; }
public string CancelReason { get; set; }
public DateTimeOffset? ConfirmedAt { get; set; }
}
This type allows states like:
- Not cancelled, yet
CancelledAtis set - Cancelled, yet there is no
CancelReason - Cancelled, yet
ConfirmedAtis set - A confirmation timestamp exists before confirmation
In ADT style, you can split the data each state needs.
public abstract class ReservationState
{
private ReservationState()
{
}
public sealed class Requested : ReservationState
{
internal Requested(DateTimeOffset requestedAt)
{
RequestedAt = requestedAt;
}
public DateTimeOffset RequestedAt { get; }
}
public sealed class Confirmed : ReservationState
{
internal Confirmed(DateTimeOffset confirmedAt)
{
ConfirmedAt = confirmedAt;
}
public DateTimeOffset ConfirmedAt { get; }
}
public sealed class Cancelled : ReservationState
{
internal Cancelled(DateTimeOffset cancelledAt, string reason)
{
CancelledAt = cancelledAt;
Reason = reason;
}
public DateTimeOffset CancelledAt { get; }
public string Reason { get; }
}
}
Now only the cancelled state carries the cancellation timestamp and reason.
Instead of checking for invalid combinations after the fact, you eliminate them at design time.
This matters a great deal for testing, too.
As bool and nullable properties multiply, the number of combinations explodes.
With ADTs, the cases to test are organized into “the defined cases.”
16. Benefit 2: Callers Are Made Aware of Unhandled Cases
ADTs show the caller “what cases this value can take.”
For example, looking at the following return type, the caller knows they must handle Found, NotFound, and Forbidden.
public abstract class GetDocumentResult
{
private GetDocumentResult()
{
}
public sealed class Found : GetDocumentResult
{
internal Found(Document document)
{
Document = document;
}
public Document Document { get; }
}
public sealed class NotFound : GetDocumentResult
{
internal NotFound(DocumentId id)
{
Id = id;
}
public DocumentId Id { get; }
}
public sealed class Forbidden : GetDocumentResult
{
internal Forbidden(UserId userId)
{
UserId = userId;
}
public UserId UserId { get; }
}
public static GetDocumentResult DocumentFound(Document document)
=> new Found(document);
public static GetDocumentResult DocumentNotFound(DocumentId id)
=> new NotFound(id);
public static GetDocumentResult AccessForbidden(UserId userId)
=> new Forbidden(userId);
public T Match<T>(
Func<Found, T> found,
Func<NotFound, T> notFound,
Func<Forbidden, T> forbidden)
{
if (found == null) throw new ArgumentNullException(nameof(found));
if (notFound == null) throw new ArgumentNullException(nameof(notFound));
if (forbidden == null) throw new ArgumentNullException(nameof(forbidden));
var f = this as Found;
if (f != null) return found(f);
var n = this as NotFound;
if (n != null) return notFound(n);
var d = this as Forbidden;
if (d != null) return forbidden(d);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
If you just return null, you cannot tell whether the document “does not exist,” “is forbidden,” or “failed to load.”
With exceptions alone, it is hard to tell which exceptions are expected at the business level.
Expressed as GetDocumentResult, the method signature becomes the specification.
GetDocumentResult GetDocument(UserId userId, DocumentId documentId);
This method does not merely return a document.
It carries an API contract: it returns one of “found,” “not found,” or “forbidden.”
And with Match, missing handling becomes easy to notice.
return result.Match(
found => Ok(found.Document),
notFound => NotFound(),
forbidden => Forbid());
When you add a new case and the Match parameter list grows, callers that have not been updated surface at compile time.
This pays off enormously in long-term maintenance.
17. Benefit 3: Domain Vocabulary Stays in the Code
If you express states with only bool, int, string, and null, the business meaning disappears from the code.
return false;
What does this false mean?
- Not found
- Invalid input
- Not authorized
- An external service was down
- Already processed
Without context, the caller cannot tell.
With ADTs, business vocabulary survives as types.
return GetDocumentResult.DocumentNotFound(documentId);
return GetDocumentResult.AccessForbidden(userId);
return SubmitOrderResult.AlreadySubmitted(orderId);
return SubmitOrderResult.CreditLimitExceeded(limit);
The difference is significant.
In code reviews, in logs, and in tests, the domain vocabulary becomes visible.
Test names become natural, too.
[Fact]
public void Resubmitting_a_submitted_order_returns_AlreadySubmitted()
{
var result = service.Submit(orderId);
Assert.IsType<SubmitOrderResult.AlreadySubmitted>(result);
}
This is not just an implementation technique — it is a way to preserve the business specification in code.
18. Benefit 4: Less Overuse of Exceptions
.NET exceptions are powerful.
But when even routine business branching is expressed as exceptions, the flow of the logic can become hard to follow.
Consider stock reservation, for example.
Being out of stock is not a system anomaly. It is a perfectly normal business outcome.
public abstract class ReserveStockResult
{
private ReserveStockResult()
{
}
public sealed class Reserved : ReserveStockResult
{
internal Reserved(ReservationId reservationId)
{
ReservationId = reservationId;
}
public ReservationId ReservationId { get; }
}
public sealed class OutOfStock : ReserveStockResult
{
internal OutOfStock(Sku sku, int requested, int available)
{
Sku = sku;
Requested = requested;
Available = available;
}
public Sku Sku { get; }
public int Requested { get; }
public int Available { get; }
}
public T Match<T>(
Func<Reserved, T> reserved,
Func<OutOfStock, T> outOfStock)
{
if (reserved == null) throw new ArgumentNullException(nameof(reserved));
if (outOfStock == null) throw new ArgumentNullException(nameof(outOfStock));
var r = this as Reserved;
if (r != null) return reserved(r);
var o = this as OutOfStock;
if (o != null) return outOfStock(o);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Expressed this way, being out of stock becomes a normal outcome called OutOfStock.
var result = stock.Reserve(sku, quantity);
return result.Match(
reserved => Ok(reserved.ReservationId),
outOfStock => Conflict(new
{
sku = outOfStock.Sku.Value,
requested = outOfStock.Requested,
available = outOfStock.Available
}));
Meanwhile, things like a dropped DB connection, a corrupted configuration file, or an unexpected inconsistency are fine as exceptions.
As a rule of thumb, this division works well in practice:
Things callers should handle as normal branching
=> return Result / ADT
Things normal processing cannot recover from
=> throw an exception
With this division, you avoid try-catch becoming a stand-in for business branching.
19. Benefit 5: Tests Become Easier to Write
With ADTs, the cases under test become explicit.
Suppose you have this result type:
SubmitOrderResult =
Submitted(orderId)
or AlreadySubmitted(orderId)
or InvalidOrder(reason)
or CreditLimitExceeded(limit)
Tests then naturally split by case:
A valid order returns Submitted
An already submitted order returns AlreadySubmitted
An invalid order returns InvalidOrder
Exceeding the credit limit returns CreditLimitExceeded
When state is expressed as combinations of nullable properties, the test code also has to understand “which combinations are valid.”
With ADTs, the cases themselves become the test perspectives.
Test data also becomes easier to create.
var result = SubmitOrderResult.CreditLimitExceeded(limit);
This single line creates data that means “credit limit exceeded.”
That is far clearer in intent than assembling a plausible object out of Status, ErrorCode, Message, and Limit.
20. An Adoption Strategy for .NET Framework
When introducing ADT-style design into an existing .NET Framework system, avoid sudden, sweeping changes.
We recommend starting with return values. Look for spots like these in the existing code:
bool TryXxx(...)methods that now need a failure reason- Methods returning
nullwhere there are multiple reasons for “not found” - An
enum Statuswith a growing pile of nullable auxiliary properties - Business branching expressed via exceptions
- String comparisons against
ErrorCodespreading through the codebase
These are the places where ADT conversion pays off quickly.
Next, create a dedicated result type.
public abstract class RegisterMemberResult
{
private RegisterMemberResult()
{
}
public sealed class Registered : RegisterMemberResult
{
internal Registered(MemberId memberId)
{
MemberId = memberId;
}
public MemberId MemberId { get; }
}
public sealed class DuplicateEmail : RegisterMemberResult
{
internal DuplicateEmail(string email)
{
Email = email;
}
public string Email { get; }
}
public sealed class InvalidInvitationCode : RegisterMemberResult
{
internal InvalidInvitationCode(string code)
{
Code = code;
}
public string Code { get; }
}
public T Match<T>(
Func<Registered, T> registered,
Func<DuplicateEmail, T> duplicateEmail,
Func<InvalidInvitationCode, T> invalidInvitationCode)
{
if (registered == null) throw new ArgumentNullException(nameof(registered));
if (duplicateEmail == null) throw new ArgumentNullException(nameof(duplicateEmail));
if (invalidInvitationCode == null) throw new ArgumentNullException(nameof(invalidInvitationCode));
var r = this as Registered;
if (r != null) return registered(r);
var d = this as DuplicateEmail;
if (d != null) return duplicateEmail(d);
var i = this as InvalidInvitationCode;
if (i != null) return invalidInvitationCode(i);
throw new InvalidOperationException("Unknown result type: " + GetType().FullName);
}
}
Then, at the existing API boundaries, convert immediately to DTOs or the legacy format.
var result = service.Register(command);
return result.Match(
registered => new RegisterMemberResponse
{
Success = true,
MemberId = registered.MemberId.Value
},
duplicate => new RegisterMemberResponse
{
Success = false,
ErrorCode = "DuplicateEmail",
ErrorMessage = duplicate.Email + " is already in use."
},
invalidCode => new RegisterMemberResponse
{
Success = false,
ErrorCode = "InvalidInvitationCode",
ErrorMessage = "The invitation code is invalid."
});
You can strengthen the internal logic first, without changing the external interface right away.
This is critically important in existing systems.
What external APIs and screens require
Keep the existing response format
Internal domain logic
Handle safely with ADT-style types
Converting at the boundary alone already cleans up a lot of internal branching.
21. Sharing via a .NET Standard Library
For libraries used from both .NET Framework and current .NET, .NET Standard is an option.
If broad compatibility matters, .NET Standard 2.0 is the realistic candidate.
For example, you place your domain model and result types in a library structured like this:
MyApp.Domain
TargetFramework: netstandard2.0
MyApp.LegacyWeb
TargetFramework: net472
References MyApp.Domain
MyApp.Api
TargetFramework: net8.0
References MyApp.Domain
With this structure, the old .NET Framework application and the new .NET application can easily share the same domain types.
However, when targeting .NET Standard 2.0, avoid depending too heavily on new C# / .NET APIs.
For example, these design choices are often best avoided in shared libraries:
- Relying heavily on
recordorinit - Using .NET 6+ APIs directly
- Publicly exposing code that assumes Source Generators
- Putting ASP.NET Core-specific types into the domain layer
In shared libraries, centering on plain classes, value objects, and ADT-style result types keeps them usable for the long haul.
public abstract class PaymentResult
{
private PaymentResult()
{
}
// Expressed as a plain class that works comfortably on both .NET Framework and .NET
}
In application layers dedicated to new .NET, feel free to use records and switch expressions.
Shared domain layer
Plain types readable even in old environments
New application layer
Leverage records / pattern matching / minimal APIs, etc.
This separation makes it easier to balance existing assets and new development.
22. How Far Should You Go with ADTs?
ADTs are useful, but not everything should be an ADT.
They suit values whose set of cases is essentially closed in business terms. For example:
- Operation results
- Input validation results
- Order states
- Payment results
- Authentication results
- Results of external service calls
- Domain events
- Kinds of commands
- Screen states
Conversely, some things require caution:
- Things whose kinds grow externally via plugins
- Things whose kinds grow through user definition
- Things that grow as DB master data during operation
- Framework integration types designed for inheritance-based extension
- Simple CRUD DTOs
If the design lets cases grow from outside, interfaces or ordinary inheritance hierarchies suit better than a closed ADT.
For example, if report output formats grow via plugins, this design is the natural one:
public interface IReportExporter
{
string FormatName { get; }
void Export(Report report, Stream output);
}
In this case, locking it into a closed sum type like PdfExporter | ExcelExporter | CsvExporter makes external extension difficult.
ADTs are a design that excels in a “closed world.”
Is the set truly closed in business terms? Could it grow externally in the future?
Discerning that is what matters.
23. Choosing Between enum and ADT
enum is not bad.
enum suits cases that carry no additional data — where a simple label is enough. For example:
public enum Gender
{
Unknown,
Male,
Female,
Other
}
Or something like log levels:
public enum LogLevel
{
Trace,
Debug,
Information,
Warning,
Error,
Critical
}
On the other hand, if each case needs different data, consider an ADT-style type.
PaymentStatus enum
Succeeded
Rejected
Failed
PaymentResult ADT
Succeeded(receiptNo)
Rejected(reason)
Failed(message)
The criterion is simple:
Knowing the case alone is enough
=> enum
Each case carries different data
=> ADT
Each case has different behavior or constraints
=> ADT or class hierarchy
When enum + a cluster of nullable properties starts growing, that is the signal to convert to an ADT.
24. Choosing Between bool and ADT
bool is not bad either.
If the meaning is genuinely complete with just yes / no, bool is enough.
bool IsEnabled { get; }
bool IsDeleted { get; }
But when there are multiple failure reasons, bool becomes weak.
bool TryCreateUser(CreateUserCommand command);
This method gives no reason when it fails.
You can compensate with out parameters:
bool TryCreateUser(CreateUserCommand command, out User user, out string errorCode);
But it gets progressively messier.
In this case, a result type reads better.
CreateUserResult CreateUser(CreateUserCommand command);
The caller can then handle not just success and failure, but the kind of failure, as a type.
return result.Match(
created => Ok(created.User),
duplicate => Conflict(),
weak => BadRequest(),
failure => StatusCode(500));
The criteria:
Truly binary, no extra information needed
=> bool
Binary, but a success value or failure reason is needed
=> Result
Three or more outcomes, or each case carries different data
=> ADT
25. How ADTs Differ from Inheritance
When you build ADT-style types in C#, they look much like ordinary inheritance.
public abstract class PaymentResult
{
}
public sealed class Succeeded : PaymentResult
{
}
public sealed class Rejected : PaymentResult
{
}
But the purpose differs slightly.
Conventional object-oriented inheritance is most often used to swap behavior.
public abstract class Shape
{
public abstract double Area();
}
public sealed class Circle : Shape
{
public override double Area() => ...;
}
ADT-style inheritance, by contrast, is used to express “the possible shapes of the data.”
public abstract class PaymentResult
{
public sealed class Succeeded : PaymentResult
{
public string ReceiptNo { get; }
}
public sealed class Rejected : PaymentResult
{
public string Reason { get; }
}
}
Neither is “the correct one.”
If you want the logic to live on each case, conventional polymorphism fits.
public abstract class Notification
{
public abstract void Send();
}
If you want the caller to branch while seeing all the cases, ADT + pattern matching / Match fits.
return notification.Match(
email => SendEmail(email),
sms => SendSms(sms),
push => SendPush(push));
In business applications, a clean division is: ADTs for return values and states, interfaces for swappable behavior.
26. Don’t Scatter Pattern Matching Everywhere
Once you start using ADTs, you will be tempted to write switch or Match everywhere.
But if the same branching is duplicated in many places, every new case multiplies the edit sites.
Say PaymentResult is being switched in many places:
API response conversion
Log output
Screen message generation
Metrics recording
Audit log generation
Adding a case means fixing every one of those switches.
Sometimes that is unavoidable, but consolidating the branching responsibilities makes maintenance easier.
public static class PaymentResultMapper
{
public static PaymentResultDto ToDto(PaymentResult result)
{
return result.Match(
succeeded => ...,
rejected => ...,
failure => ...);
}
public static string ToLogMessage(PaymentResult result)
{
return result.Match(
succeeded => ...,
rejected => ...,
failure => ...);
}
}
Sometimes it is also better to put the behavior on the cases themselves instead of branching.
public abstract class PaymentResult
{
public abstract bool IsSuccess { get; }
}
But if you load too much behavior onto the cases, the domain type starts learning about API and UI concerns.
Logic like the following usually should not go directly into domain types:
- Conversion to HTTP status codes
- Conversion to JSON DTOs
- Display messages for screens
- Log formatting
- OpenAPI representations
Domain types express business meaning. Boundary conversion lives in Mappers.
Keeping this separation in mind makes ADTs easy to maintain over the long term.
27. Naming
For ADT-style types, names matter.
Generic names like Result, Error, and Response alone dilute the meaning.
Common names include:
CreateUserResult
RegisterMemberResult
SubmitOrderResult
ReserveStockResult
PaymentResult
LoginResult
GetDocumentResult
OrderState
ReservationState
Lean case names toward business vocabulary:
Created
DuplicateEmail
WeakPassword
SystemFailure
AlreadySubmitted
CreditLimitExceeded
OutOfStock
MfaRequired
AccountLocked
With names like Error1, Error2, or just Failed, callers struggle to understand the meaning.
Likewise, make the data carried by each case a business-level type wherever possible.
public sealed class CreditLimitExceeded : SubmitOrderResult
{
public Money Limit { get; }
public Money RequestedAmount { get; }
}
Raw decimal and string work, but combining ADTs with value objects like Money, Email, UserId, and OrderId makes the intent even clearer.
ADTs and value objects pair well.
Value objects
Express the meaning and constraints of a single value
ADTs
Express the multiple possible shapes
Combining the two makes it easy to lock business rules into types.
28. Watch Out for Versioning
Because ADTs make the set of cases explicit, adding a case affects callers.
This is both a benefit and a caveat.
For internal code, a compile error on case addition is something to welcome: it finds the unhandled spots for you.
For types shipped externally as NuGet packages or public APIs, however, adding a case can amount to a breaking change.
Suppose a library consumer wrote exhaustive handling like this:
var text = result.Match(
success => ...,
validationError => ...,
permissionDenied => ...);
If the library adds a RateLimited case and changes the Match signature, the consumer’s code fails to compile.
That is safe, but in terms of public API compatibility, it has impact.
For public libraries, think about it this way:
- If you accept case additions, bump the version and treat it as a breaking change
- If you want external consumers to be able to write
default-style handling, use a design other than a closed ADT - Be strict in the internal domain; use DTOs and versioned contracts for external APIs
Inside a business application, the compile error on case addition is a gift.
In public APIs, compatibility design must be considered alongside it.
29. About Performance
ADT-style design sometimes trades extra objects for expressiveness.
When using class hierarchies on .NET Framework, an object is allocated per case.
return PaymentResult.Success(receiptNo);
In typical business applications, this is rarely a significant problem.
But take care in places like these:
- Low-level code on hot, high-frequency paths
- Stream processing handling huge volumes of events
- Games and real-time processing
- Code where allocations must be minimized aggressively
- Storing huge numbers of ADTs in enormous collections
When performance matters, there are several options:
- Use a struct-based Result type
- Consider F# struct discriminated unions
- Suppress allocations with Source Generators
- Use enum + dedicated fields on the hot path and convert to ADTs at the boundary
- Measure before optimizing
There is no need to over-optimize from the start.
In most business systems, the design clarity ADTs bring is worth far more than the slight object allocation cost.
But where performance requirements are strict, design and measurement should go hand in hand.
30. A Refactoring Example for Existing Code
Finally, let’s walk through converting a typical piece of existing code to ADT style.
The original code:
public bool TryReserveStock(string sku, int quantity, out string errorCode)
{
errorCode = null;
var stock = stockRepository.Find(sku);
if (stock == null)
{
errorCode = "SKU_NOT_FOUND";
return false;
}
if (stock.Available < quantity)
{
errorCode = "OUT_OF_STOCK";
return false;
}
stock.Reserve(quantity);
return true;
}
Here, the failure reason is expressed as a string.
Callers have to compare strings.
string errorCode;
if (!service.TryReserveStock(sku, quantity, out errorCode))
{
if (errorCode == "SKU_NOT_FOUND")
{
...
}
else if (errorCode == "OUT_OF_STOCK")
{
...
}
}
We convert this to a result type.
public abstract class ReserveStockResult
{
private ReserveStockResult()
{
}
public sealed class Reserved : ReserveStockResult
{
internal Reserved(ReservationId reservationId)
{
ReservationId = reservationId;
}
public ReservationId ReservationId { get; }
}
public sealed class SkuNotFound : ReserveStockResult
{
internal SkuNotFound(Sku sku)
{
Sku = sku;
}
public Sku Sku { get; }
}
public sealed class OutOfStock : ReserveStockResult
{
internal OutOfStock(Sku sku, int requested, int available)
{
Sku = sku;
Requested = requested;
Available = available;
}
public Sku Sku { get; }
public int Requested { get; }
public int Available { get; }
}
public static ReserveStockResult Success(ReservationId reservationId)
=> new Reserved(reservationId);
public static ReserveStockResult NotFound(Sku sku)
=> new SkuNotFound(sku);
public static ReserveStockResult NotEnough(Sku sku, int requested, int available)
=> new OutOfStock(sku, requested, available);
public T Match<T>(
Func<Reserved, T> reserved,
Func<SkuNotFound, T> skuNotFound,
Func<OutOfStock, T> outOfStock)
{
var r = this as Reserved;
if (r != null) return reserved(r);
var n = this as SkuNotFound;
if (n != null) return skuNotFound(n);
var o = this as OutOfStock;
if (o != null) return outOfStock(o);
throw new InvalidOperationException("Unknown stock reservation result.") ;
}
}
The service method becomes:
public ReserveStockResult ReserveStock(Sku sku, int quantity)
{
var stock = stockRepository.Find(sku);
if (stock == null)
{
return ReserveStockResult.NotFound(sku);
}
if (stock.Available < quantity)
{
return ReserveStockResult.NotEnough(sku, quantity, stock.Available);
}
var reservationId = stock.Reserve(quantity);
return ReserveStockResult.Success(reservationId);
}
Callers can stop comparing strings.
var result = service.ReserveStock(sku, quantity);
return result.Match(
reserved => Ok(new { reserved.ReservationId }),
notFound => NotFound(new { sku = notFound.Sku.Value }),
outOfStock => Conflict(new
{
sku = outOfStock.Sku.Value,
requested = outOfStock.Requested,
available = outOfStock.Available
}));
The key point of this refactoring is that you can move the internal meaning into types without changing external behavior.
First strengthen the return value.
Then shift callers to Match.
Finally whittle down the string error codes and nullable auxiliary properties.
In this order, you can introduce it incrementally even in existing systems.
31. Adoption Checklist
When creating ADT-style types, run through these checks:
Does the type represent "exactly one of these"?
Is the set of cases closed in business terms?
Does each case need different data?
Has bool / enum / null / string error code already lost the meaning?
Do you want callers to consciously handle every case?
Does it affect public API compatibility?
Is there a conversion policy for JSON / DB / screen DTOs?
If .NET Framework is also a target, are plain classes sufficient?
If targeting current .NET only, are records or Source Generators worth using?
And you can choose the implementation strategy like this:
F# projects
Use F# discriminated unions
C# on .NET Framework
abstract class + private constructor + nested sealed classes + Match
C# on .NET 5 or later
abstract record + sealed record cases + pattern matching
Localized return values
A library like OneOf
Reducing boilerplate on current .NET
Source Generator-based libraries
Future-facing evaluation
C# 15 union preview
Whichever you choose, the goal is the same:
Rules once protected by comments, protected by types.
That is the single biggest reason to use ADTs.
32. Summary
Algebraic data types are not exclusive to functional languages.
In C# on .NET Framework, abstract classes and sealed classes are entirely practical.
In C# on current .NET, records and pattern matching make it more concise.
In F#, discriminated unions are a first-class language feature.
With libraries, even C# can handle OneOf and Result with ease.
What matters is the design mindset, not the syntax.
Revisit what you have been expressing with bool, null, enum + nullable properties, and string ErrorCode, and ask:
Which one of which cases is this value?
What data does each case need?
What data must not exist outside that case?
What do you want to force every caller to handle?
Build types that answer these questions, and invalid states shrink, branching becomes clearer, and business vocabulary remains in the code.
In existing systems, we recommend starting with return values.
Take the spots where TryXxx, null, ErrorCode, and exception-driven business branching have been accumulating, and try replacing them with a dedicated result type.
That alone changes the readability and safety of the code dramatically.
References
- The complete sample code for this article (library, demos, unit tests) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-algebraic-data-types
- Discriminated Unions - F# | Microsoft Learn
- Pattern matching overview - C# | Microsoft Learn
- switch expression - C# reference | Microsoft Learn
- Records - C# reference | Microsoft Learn
- .NET Standard - .NET | Microsoft Learn
- Explore union types in C# 15 - .NET Blog
- Unions - C# feature specifications | Microsoft Learn
- OneOf - NuGet
- Thinktecture.Runtime.Extensions - NuGet
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
What Is a PDB (Program Database)? — Understanding Debug Information, Symbols, and Source Link
What a PDB (Program Database) is, what it does and does not contain, and how it relates to Debug / Release, Portable PDBs, Source Link, s...
What Is Roslyn? Reading, Fixing, and Generating C# Code from the Compiler's Point of View
An overview of Roslyn (the .NET Compiler Platform): Syntax Trees, SemanticModel, Workspaces, Analyzers, Source Generators, and where they...
Windows App Outsourcing and Contract Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or contract development, here is how to sort out existing software modification, device inte...
Handling Windows Impersonation Tokens Correctly — Borrowing Privileges per Thread and Reverting Safely
A practical guide to Windows impersonation tokens — access tokens, primary tokens, thread tokens, impersonation levels, RevertToSelf, and...
The Misconception That TCP Lets You Receive in the Same Units You Send — Designing Reception Around a Byte Stream
Assuming TCP delivers data in the same units as Send or Write leads to fragmentation, coalescing, garbled text, and broken protocols. Thi...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links