Domain Modeling with Structures and Discriminated Unions in VB.NET

Part 3 of a three-part series on functional programming in VB.NET. Nominal types for IDs, structures for values, and discriminated unions for closed sets of states.
Version v1792077372
Updated

Domain Modeling with Structures and Discriminated Unions in VB.NET

Part 3 of a three-part series on functional programming in VB.NET. Part 1 covered the patterns built into the language. Part 2 covered monads and bind chains. These notes cover how to use those patterns together to model a domain: nominal types for IDs, structures for values, and discriminated unions for values that are exactly one of several things.

last updated: 2026-07-20 · part 3 of 3


Where this fits

Part 1 covered what is in the box: LINQ, extension methods, lambdas, generic constraints, iterator functions. Part 2 covered the small monad library I built on top of OneOf (Maybe, Result, ApiResult, bind chains, Tee, BindIf).

These notes are about using those tools at the architecture level. The goal: types that mean what they say, operations that cannot be applied to the wrong things, unions for the cases where a value is one of several things.

The problem with raw primitives for domain types

Most VB.NET domain types start with primitives:

Public Class Order
    Public Property Id As Integer
    Public Property CustomerId As Integer
    Public Property Status As String  ' "pending", "shipped", "delivered"
    Public Property Total As Decimal
    Public Property UpdatedAt As Date?
End Class

This compiles. It runs. It works. And it lies about everything.

Status is a String, but it is actually an enum. UpdatedAt is a Date?, but “null” really means “never updated,” which is a domain concept. CustomerId is an Integer, but what stops someone from passing an order ID by mistake?

The class does not prevent any of those mistakes. The type system has nothing to say. Every check happens at runtime, in scattered If blocks, often forgotten.

I can do better, without inventing anything new. The mechanisms are Structure (the VB.NET value type), Class with ValueOf inheritance, and Class with OneOfBase inheritance. All three are real, all three are in production use in our codebase.


Nominal typing: SupplierID is not EmailID even when both are Integer

The single biggest source of bugs in VB.NET codebases I have worked on is primitive obsession. Every ID is an Integer, every money amount is a Decimal, every email is a String. The compiler cannot tell them apart.

The fix is a single-line Class declaration. Here is a real example from our supplier module:

Public Class SupplierID
    Inherits ValueOf(Of Integer, SupplierID)
End Class

That is it. SupplierID is now its own type. You can store an Integer underneath, but you cannot accidentally pass a SupplierID where an EmailID is expected, or vice versa.

The ValueOf NuGet package does the work. It wraps a primitive with a tag type and provides From(value) for construction and .Value for extraction. You get the safety of nominal typing with the syntax of a primitive.

A real example from the codebase

Our supplier module defines ten of these nominal types in about twenty lines:

Public Class SupplierID
    Inherits ValueOf(Of Integer, SupplierID)
End Class

Public Class EMailID
    Inherits ValueOf(Of Integer, EMailID)
End Class

Public Class PhoneID
    Inherits ValueOf(Of Integer, PhoneID)
End Class

Public Class AddressID
    Inherits ValueOf(Of Integer, AddressID)
End Class

Public Class SupplierEmailLinkID
    Inherits ValueOf(Of Integer, SupplierEmailLinkID)
End Class

Public Class SupplierPhoneLinkID
    Inherits ValueOf(Of Integer, SupplierPhoneLinkID)
End Class

Public Class SupplierAddressLinkID
    Inherits ValueOf(Of Integer, SupplierAddressLinkID)
End Class

Each one is two lines. The compiler now enforces that I cannot pass an AddressID where a SupplierID is expected, even though both wrap Integer.

Compound nominal types with VB.NET tuples

Sometimes the ID is not a single primitive. It is a combination. Real example from our email module:

Public Class UnverifiedEmailAddress
    Inherits ValueOf(Of EmailAddress, UnverifiedEmailAddress)
End Class

Public Class VerifiedEmailAddress
    Inherits ValueOf(Of (EmailAddress as EmailAddress, Verified as Date), VerifiedEmailAddress)
End Class

VerifiedEmailAddress wraps a VB.NET tuple: the email itself plus the date it was verified. Construction looks like:

VerifiedEmailAddress.From((emailAddress, verificationDate))

The pattern lets you make compound types type-safe without writing a class for each one. The compiler enforces that any code handling a VerifiedEmailAddress knows there is a verification date.

When to use ValueOf

Use it for:

Skip it for internal locals and one-off variables where the type does not escape the function. Do not wrap an Integer just because you can.


Structures: values that mean what they say

Structure is VB.NET’s value type. It is stack-allocated (when small), passed by value, and gets structural equality by default. It is the closest thing VB.NET has to F#’s records.

A real example from our supplier module. Structure Supplier defines the shape of a supplier with all its contact methods:

Public Structure Supplier
    Property ID As SupplierID
    Property Name As String
    Property WebSite As String
    Property Addressess As Address()
    Property Phones As Phone()
    Property Emails As Email()
End Structure

Public Structure Address
    Property ID As AddressID
    Property SupplierID As SupplierID
    Property LinkID As SupplierAddressLinkID
    Property Address As String
    Property City As String
    Property State As String
    Property PostalCode As String
    Property Country As String
    Property Note As String
    Property IsPrimary As Boolean
End Structure

Public Structure Phone
    Property ID As PhoneID
    Property SupplierID As SupplierID
    Property LinkID As SupplierPhoneLinkID
    Property PhoneNumber As String
    Property IsPrimary As Boolean
    Property Note As String
End Structure

Public Structure Email
    Property ID As EMailID
    Property SupplierID As SupplierID
    Property LinkID As SupplierEmailLinkID
    Property Email As String
    Property IsPrimary As Boolean
    Property Note As String
End Structure

Every field uses one of the nominal types from the previous section. I cannot accidentally put an AddressID in the Email.ID field. I cannot put a PhoneID in the Email.SupplierID field. The compiler stops me.

How we use these structures

The supplier module builds these structures with object-initializer syntax. Here is a real call from GetAllSuppliers:

Return sql.Supplier _
    .Where(Function(s) suppliersWithParts.Contains(s.SupplierID)) _
    .Where(Function(s) Not mfrNames.Contains(s.SupplierName)) _
    .ToArray _
    .Select(Function(s) New Supplier With {
                .ID = SupplierID.From(s.SupplierID),
                .Name = s.SupplierName,
                .WebSite = ParseURL(s?.WebSite),
                .Phones = s.SupplierPhoneLink?.Select(Function(p) New Phone With {
                    .PhoneNumber = p.Phone.PhoneNumber,
                    .ID = PhoneID.From(p.PhoneID),
                    .IsPrimary = p.PrimaryPhone,
                    .LinkID = SupplierPhoneLinkID.From(p.SupplierPhoneLinkID),
                    .Note = p.PhoneNote,
                    .SupplierID = SupplierID.From(s.SupplierID)
                }).ToArray,
                .Emails = s.SupplierEmailLink?.Select(Function(e) New Email With {
                    .Email = e.EMail.EMail1,
                    .ID = EMailID.From(e.EMailID),
                    .IsPrimary = e.PrimaryEmail,
                    .LinkID = SupplierEmailLinkID.From(e.SupplierEmailLinkID),
                    .Note = e.EmailNote,
                    .SupplierID = SupplierID.From(s.SupplierID)
                }).ToArray,
                .Addressess = s.SupplierAddressLink?.Select(Function(a) New Address With {
                    .Address = a?.Address?.Address1,
                    .City = a?.Address?.City,
                    .State = a?.Address?.State,
                    .PostalCode = a?.Address?.PostalCode,
                    .Country = a?.Address?.Country,
                    .ID = AddressID.From(a?.AddressID),
                    .IsPrimary = a?.PrimaryAddress,
                    .LinkID = SupplierAddressLinkID.From(a.SupplierAddressLinkID),
                    .Note = a?.AddressNote,
                    .SupplierID = SupplierID.From(s.SupplierID)
                }).ToArray
            })

Notice what is happening here. We are piping a SQL query result through a series of LINQ operations, then using With { ... } to build the value types. The whole pipeline reads top to bottom:

  1. Filter to suppliers with parts.
  2. Exclude specific manufacturers.
  3. Project each row into a Supplier with its full contact info.

Each property assignment goes through ID.From(...) to wrap the raw Integer in its nominal type. The compiler enforces the types all the way through.

Structures vs classes

Structure and Class both work for domain types. The differences:

Property Structure Class
Memory Stack-allocated (when small) Heap-allocated
Passing By value (copy) By reference
Equality Structural (same fields = equal) Reference (same instance = equal)
Inheritance Cannot inherit from another structure Can inherit from a base class
Can have Nothing Yes (a structure can be Nothing) Yes
Default value All fields default-initialized All fields default-initialized

For pure value types (a Supplier is just data), Structure is the right choice. For things with identity or behavior beyond data, Class.

When structures are the wrong choice

If your type genuinely mutates over its lifetime (a form’s controls, a long-lived session object, a database connection), Structure works against you because the data gets copied on every assignment. Use a Class for stateful things. Structures are for values, not for stateful things.


Discriminated unions for “exactly one of these”

Some values are exactly one of several named cases. A shipping order has states. An email contact is either verified or not. A tree node is one of several kinds.

The standard advice for VB.NET is to model this with inheritance and an abstract base class. That works, but it costs you:

The OneOf package gives you a cleaner alternative. You write a class that inherits from OneOfBase(Of T1, T2, ..., T8) and you get a value that is exactly one of those types. Real example from our email module:

Public Class T
    Inherits OneOfBase(Of UnverifiedEmailAddress, VerifiedEmailAddress)

    Sub New(input As OneOf(Of UnverifiedEmailAddress, VerifiedEmailAddress))
        MyBase.New(input)
    End Sub
End Class

T is now a value that is either an UnverifiedEmailAddress or a VerifiedEmailAddress. Nothing else. The compiler enforces this. You cannot create a T that holds a String or an Integer or any other type.

Pattern matching against the union

OneOfBase gives you Match and Switch. They take one lambda per case. Here is a real example from our email module’s Verify function:

Function Verify(emailContactInfo As T) As Result(Of VerifiedEmailAddress, Exception)
    Return emailContactInfo.Match(
        Function(unVerifiedAddress)
            Try
                Dim checkAddressFormat As New MailAddress(unVerifiedAddress.Value)
                Return Result(Of VerifiedEmailAddress, Exception) _
                    .Success(Verified(emailContactInfo, Date.Now))
            Catch ex As Exception
                Return Result(Of VerifiedEmailAddress, Exception) _
                    .Fail(ex)
            End Try
        End Function,
        Function(verifiedAddress) Result(Of VerifiedEmailAddress, Exception) _
                    .Lift(verifiedAddress))
End Function

Two cases, two lambdas. The first lambda handles UnverifiedEmailAddress. It tries to parse the address, succeeds (returning the verified form) or fails (returning the exception wrapped in Result.Fail). The second lambda handles VerifiedEmailAddress. It is already verified, so just lift it.

The compiler warns if you forget a case. You cannot accidentally treat a VerifiedEmailAddress as if you just unverified it.

Side effects with Switch

Switch is Match’s younger sibling. It takes Subs instead of Functions and returns nothing. It is for side effects only. Real example:

Sub SendVerficationEmail(emailContactInfo As T)
    emailContactInfo.Switch(
        Sub(unVerfiedState) Console.WriteLine("sending e-mail"),
        AddressOf DoNothing)
End Sub

Sub SendPassWordReset(emailContactInfo As T)
    emailContactInfo.Switch(
        AddressOf DoNothing,
        Sub(verifiedEmail) Console.WriteLine($"sending password reset to: {verifiedEmail.Value.EmailAddress.Value}"))
End Sub

Two side effects, dispatched on the union’s case. The unverified case sends a verification email. The verified case does nothing. The verified case sends a password reset. The unverified case does nothing.

This is essentially Tee from Part 2. The value flows through unchanged, but the side effect fires only for the relevant case.

The full email pipeline

Here is the full pipeline that ties the email module together. This is real code from the codebase:

Function NewEmailAddressPipeline(email As String) As Result(Of VerifiedEmailAddress, Exception)
    Return email.Map(AddressOf Create).Map(AddressOf Verify)
End Function

Three steps:

  1. The string flows in.
  2. Create turns it into a T (an unverified email address).
  3. Verify matches against the union. It either verifies the address (returning a VerifiedEmailAddress wrapped in Result.Success) or fails (returning the exception wrapped in Result.Fail).

The return type is Result(Of VerifiedEmailAddress, Exception). The caller gets either a verified email or an explanation of what went wrong. The pipeline itself never throws.

When I use discriminated unions

What the pattern has been good for in our codebase:

Two cases I do not bother with: two-case booleans. Boolean is fine. And when one case dominates, a Result(Of T) with an Error wrapper is simpler than a union.

More than two cases

OneOf supports up to eight cases per union. Our codebase uses two for emails, but the same pattern works for state machines:

Public Class RoutingState
    Inherits OneOfBase(Of Draft, Approved, Active, Obsolete)
End Class

' Each case carries different data:
Public Structure Draft
    Property CreatedBy As UserID
    Property CreatedAt As Date
End Structure

Public Structure Approved
    Property ApprovedBy As UserID
    Property ApprovedAt As Date
End Structure

Public Structure Active
    Property ActivatedBy As UserID
    Property ActivatedAt As Date
End Structure

Public Structure Obsolete
    Property ObsoletedBy As UserID
    Property ObsoletedAt As Date
    Property Reason As String
End Structure

The state has four cases. Each carries the metadata that case needs. A Match against RoutingState handles all four, and the compiler warns if I forget one.


Type classes revisited: cross-cutting behavior

In Part 1, we used generic constraints as type classes. Here, that pattern earns its keep at the domain layer, but our codebase mostly uses Interface inheritance for it.

The same pattern works. A function like:

Public Function IsExpired(Of T As IExpirable)(item As T) As Boolean
    Return item.ExpiresAt < Date.Now
End Function

…will work for any type that implements IExpirable. Our Structure types can implement interfaces just like Class types:

Public Structure Certificate
    Implements IExpirable
    Property Number As String
    Property IssuedTo As String
    Property ExpiresAt As Date

    Public ReadOnly Property ExpiresAtValue As Date Implements IExpirable.ExpiresAt
        Get
            Return ExpiresAt
        End Get
    End Property
End Structure

The interface method has to be implemented explicitly when the property name differs, but the principle holds. Generic constraints let you write one function that works on any type that satisfies the contract.

For duck typing (when you do not control the type), use Func(Of)-based extension methods from Part 1. Same pattern, parameterized.


Putting it together: a small domain

Let us wire the three patterns together: nominal types, structures, and discriminated unions, into a small but realistic domain.

' Nominal ID types — prevent ID confusion
Public Class SupplierID
    Inherits ValueOf(Of Integer, SupplierID)
End Class

Public Class EmailID
    Inherits ValueOf(Of Integer, EmailID)
End Class

Public Class AddressID
    Inherits ValueOf(Of Integer, AddressID)
End Class

' Discriminated union — email is exactly one of two states
Public Class EmailContactInfo
    Inherits OneOfBase(Of UnverifiedEmailAddress, VerifiedEmailAddress)
End Class

Public Class UnverifiedEmailAddress
    Inherits ValueOf(Of EmailAddress, UnverifiedEmailAddress)
End Class

Public Class VerifiedEmailAddress
    Inherits ValueOf(Of (EmailAddress as EmailAddress, Verified as Date), VerifiedEmailAddress)
End Class

' Structure for the actual email
Public Structure EmailAddress
    Inherits ValueOf(Of String, EmailAddress)
End Class

' Value types for contact methods
Public Structure Email
    Property ID As EmailID
    Property EmailAddress As EmailAddress
    Property IsPrimary As Boolean
    Property Note As String
End Structure

' Union for state machine
Public Class RoutingState
    Inherits OneOfBase(Of Draft, Approved, Active, Obsolete)
End Class

Public Structure Draft
    Property CreatedAt As Date
End Structure

Public Structure Approved
    Property ApprovedAt As Date
End Structure

Public Structure Active
    Property ActivatedAt As Date
End Structure

Public Structure Obsolete
    Property ObsoletedAt As Date
    Property Reason As String
End Structure

What you get:

Property How it is enforced
SupplierID and EmailID are not interchangeable ValueOf
Email.ID cannot be a SupplierID Structural typing
EmailContactInfo is exactly one of two cases OneOfBase
RoutingState is exactly one of four cases OneOfBase
Adding a new state requires touching every Match Compiler warning
Compound values (verified email + date) are type-safe ValueOf with tuple

The compiler is doing real work. It is preventing entire categories of bug that would otherwise surface as runtime exceptions.


What I lean on

Symptom Pattern
Integer IDs that get passed around confused Class : Inherits ValueOf(Of Integer, MyId)
Compound IDs or values (verified + date) Class : Inherits ValueOf(Of Tuple, MyType)
Multiple related values that travel together Structure with Property fields
Value with closed set of states Class : Inherits OneOfBase(Of ...)
Function with chained If Is Nothing Then Else Maybe from Part 2
Function with nested Try/Catch returning errors Result / ApiResult from Part 2
Operation that needs branching on a value Match against a union
Behavior across unrelated types Generic constraint (type class)

The patterns compose. A structure can contain nominal types. A union can wrap structures. A function can take a generic-constrained parameter and return an ApiResult. The patterns do not compete. They stack.


What this still is not

Even with all three posts’ worth of patterns, VB.NET is not F#.

If I could switch to F#, I would. If I could greenfield in F#, I would. These three posts are what I do when I cannot.


Files in this series

The codebase this came from is a production WinForms VB.NET inventory-management application. The patterns are in active use there.


References

NuGet packages — OneOf for discriminated unions, ValueOf for nominal typing.

VB.NET features used across the series: Structure for value types, Class with Inherits ValueOf(...) for nominal types, Class with Inherits OneOfBase(...) for discriminated unions, generic constraints as type classes, extension methods, VB.NET tuples for compound values, Iterator/Yield, LINQ, lambdas, AddressOf.

Reply to this! (Via Email)

Home • Blog • 爪闩ㄒ尺工乂 • ©Copyright 2026 •