Domain Modeling with Structures and Discriminated Unions in VB.NETPart 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 | |||
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
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.
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.
SupplierID is not EmailID even when
both are IntegerThe 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.
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.
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.
ValueOfUse 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.
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.
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:
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.
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.
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.
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.
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.
SwitchSwitch 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.
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:
Create turns it into a T (an unverified
email address).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.
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.
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.
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.
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.
| 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.
Even with all three posts’ worth of patterns, VB.NET is not F#.
Match does not cover all cases, but it does not fail
the build. I write tests that enumerate every case.OneOf does the work at runtime via type checks. F# does it
at compile time. The behavior is similar. The safety is not.record class for immutable reference types with structural
equality. VB.NET does not have that. I can fake it with
Structure or with a Class of
ReadOnly Property declarations, but it is more boilerplate
than C#.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.
vbnet-functional-already-using.md.Maybe, Result, ApiResult over
OneOf. Bind chains, Tee, BindIf.
vbnet-monads-bind-chains.md.ValueOf, structures for values, discriminated unions for
closed sets of states.The codebase this came from is a production WinForms VB.NET inventory-management application. The patterns are in active use there.
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.
| Home • Blog • 爪闩ㄒ尺工乂 • ©Copyright 2026 • |