Design Patterns in Visual Basic
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example is a complete program that
starts with Option Strict On, was built with warnings treated as errors and was run in the .NET 10 SDK container (Linux, arm64); the output shown is
real output. The root namespace of the scratch projects is ShelfApp. Design patterns are descriptions of solutions to recurring problems, not
language features. What changes between languages is how little code a pattern needs: in Visual Basic, events are Observer, delegates and lambdas are
Strategy, Lazy(Of T) is a thread-safe Singleton, and a Module is a static class. Each section below shows the idiomatic form and says when to stay
away from the pattern.
All examples use the shelf library catalogue. The types they share are listed once in the next section; each example is an extra file in the same
project, with its own Sub Main (for the examples that need a package, the project file lists it).
The shelf types
Domain.vb is the shared file: an Isbn structure with check-digit validation, Author, the abstract CatalogItem with Book and Magazine, a Loan
that raises a Returned event, the ICatalogStore abstraction and a Catalog that depends only on it. Add it, unchanged, to every example project below.
Option Strict On
Public Enum LoanState
Active = 1
Returned = 2
Overdue = 3
End Enum
Public Structure Isbn
Implements IEquatable(Of Isbn)
Public ReadOnly Property Value As String
Private Sub New(value As String)
Me.Value = value
End Sub
Public Shared Function TryParse(text As String, ByRef result As Isbn) As Boolean
Dim digits = New String(If(text, "").Where(AddressOf Char.IsLetterOrDigit).ToArray())
If digits.Length = 13 AndAlso digits.All(AddressOf Char.IsDigit) AndAlso HasValidCheckDigit(digits) Then
result = New Isbn(digits)
Return True
End If
Return False
End Function
Public Shared Function Parse(text As String) As Isbn
Dim result As Isbn = Nothing
If Not TryParse(text, result) Then
Throw New FormatException($"'{text}' is not a valid ISBN-13.")
End If
Return result
End Function
Private Shared Function HasValidCheckDigit(digits As String) As Boolean
Dim sum = 0
For i = 0 To 12
Dim d = AscW(digits(i)) - AscW("0"c)
sum += If(i Mod 2 = 0, d, d * 3)
Next
Return sum Mod 10 = 0
End Function
Public Overloads Function Equals(other As Isbn) As Boolean Implements IEquatable(Of Isbn).Equals
Return Value = other.Value
End Function
Public Overrides Function Equals(obj As Object) As Boolean
Return TypeOf obj Is Isbn AndAlso Equals(DirectCast(obj, Isbn))
End Function
Public Overrides Function GetHashCode() As Integer
Return If(Value, "").GetHashCode()
End Function
Public Overrides Function ToString() As String
Return Value
End Function
Public Shared Operator =(left As Isbn, right As Isbn) As Boolean
Return left.Equals(right)
End Operator
Public Shared Operator <>(left As Isbn, right As Isbn) As Boolean
Return Not left.Equals(right)
End Operator
End Structure
Public Class Author
Public Property Name As String
Public Property BornOn As Date?
Public Sub New(name As String, Optional bornOn As Date? = Nothing)
Me.Name = name
Me.BornOn = bornOn
End Sub
Public Overrides Function ToString() As String
Return Name
End Function
End Class
Public MustInherit Class CatalogItem
Public Property Id As Integer
Public Property Title As String
Public Property Year As Integer
Protected Sub New(title As String, year As Integer)
Me.Title = title
Me.Year = year
End Sub
Public MustOverride ReadOnly Property Kind As String
Public Overridable Function Describe() As String
Return $"{Kind}: {Title} ({Year})"
End Function
End Class
Public Class Book
Inherits CatalogItem
Public Property Isbn As Isbn
Public ReadOnly Property Authors As New List(Of Author)
Public Sub New(title As String, year As Integer, isbn As Isbn, ParamArray authors As Author())
MyBase.New(title, year)
Me.Isbn = isbn
Me.Authors.AddRange(authors)
End Sub
Public Overrides ReadOnly Property Kind As String
Get
Return "Book"
End Get
End Property
Public Overrides Function Describe() As String
Return MyBase.Describe() & " by " & String.Join(", ", Authors)
End Function
End Class
Public Class Magazine
Inherits CatalogItem
Public Property IssueNumber As Integer
Public Sub New(title As String, year As Integer, issueNumber As Integer)
MyBase.New(title, year)
Me.IssueNumber = issueNumber
End Sub
Public Overrides ReadOnly Property Kind As String
Get
Return "Magazine"
End Get
End Property
End Class
Public Class Loan
Public Event Returned As EventHandler(Of LoanEventArgs)
Public ReadOnly Property Item As CatalogItem
Public ReadOnly Property Borrower As String
Public ReadOnly Property DueOn As Date
Public Property State As LoanState = LoanState.Active
Public Sub New(item As CatalogItem, borrower As String, dueOn As Date)
Me.Item = item
Me.Borrower = borrower
Me.DueOn = dueOn
End Sub
Public Sub [Return]()
If State = LoanState.Returned Then
Throw New InvalidOperationException("The loan was already returned.")
End If
State = LoanState.Returned
RaiseEvent Returned(Me, New LoanEventArgs(Me))
End Sub
End Class
Public Class LoanEventArgs
Inherits EventArgs
Public ReadOnly Property Loan As Loan
Public Sub New(loan As Loan)
Me.Loan = loan
End Sub
End Class
Public Interface ICatalogStore
Function GetAll() As IReadOnlyList(Of CatalogItem)
Sub Add(item As CatalogItem)
Function Find(id As Integer) As CatalogItem
End Interface
Public Class Catalog
Private ReadOnly _store As ICatalogStore
Public Sub New(store As ICatalogStore)
_store = store
End Sub
Public Sub Add(item As CatalogItem)
_store.Add(item)
End Sub
Public Function Search(text As String) As IEnumerable(Of CatalogItem)
Return From item In _store.GetAll()
Where item.Title.Contains(text, StringComparison.OrdinalIgnoreCase)
Order By item.Title
Select item
End Function
Public Function Lend(item As CatalogItem, borrower As String, today As Date) As Loan
Return New Loan(item, borrower, today.AddDays(14))
End Function
End Class
Three details of this file matter to the patterns that follow. ICatalogStore is the seam where stores, decorators and test doubles plug in. Loan.Returned
is an ordinary Visual Basic event, so Observer needs no extra code. Isbn is a structure whose only constructor is private, so an invalid ISBN cannot exist.
The patterns at a glance
| Pattern | Idiomatic Visual Basic | Example |
|---|---|---|
Singleton |
|
|
Factory |
A |
|
Observer |
|
|
Strategy |
An interface for a family of algorithms, or a |
|
Decorator |
A class that implements the same interface as the object it wraps and holds it in a |
|
Repository |
An interface owned by the domain ( |
|
Dependency injection |
Constructor parameters, registered in a |
Singleton
A singleton guarantees one instance and a global way to reach it. The cost is hidden global state: code that calls ShelfSettings.Instance cannot be tested
with a different instance. Use the pattern only for state that really is process-wide, and prefer registering a singleton lifetime in the container (see
Dependency injection), which gives the same single instance without the global access point.
Option Strict On
' Lazy singleton: created on first use, thread-safe by default.
Public NotInheritable Class ShelfSettings
Private Shared ReadOnly LazyInstance As New Lazy(Of ShelfSettings)(Function() New ShelfSettings())
Public Shared ReadOnly Property Instance As ShelfSettings
Get
Return LazyInstance.Value
End Get
End Property
Public ReadOnly Property LoanDays As Integer = 14
Private Sub New()
Console.WriteLine(" ShelfSettings constructor runs")
End Sub
End Class
' Eager singleton: a Shared ReadOnly field initialised by the type initializer.
Public NotInheritable Class Clock
Public Shared ReadOnly Current As New Clock()
Private Sub New()
Console.WriteLine(" Clock constructor runs")
End Sub
Public Function Today() As Date
Return New Date(2026, 10, 19)
End Function
End Class
' A standard module is a built-in singleton with only Shared-style members.
Module Registry
Public ReadOnly Property Name As String = "main-branch"
End Module
Module Program
Sub Main()
Console.WriteLine("before first use of ShelfSettings.Instance")
Console.WriteLine($" instance obtained: {ShelfSettings.Instance IsNot Nothing}")
Console.WriteLine("second access:")
Dim same = ReferenceEquals(ShelfSettings.Instance, ShelfSettings.Instance)
Console.WriteLine($" same instance: {same}, LoanDays = {ShelfSettings.Instance.LoanDays}")
Console.WriteLine("1000 threads race for Clock.Current:")
Dim seen As New System.Collections.Concurrent.ConcurrentBag(Of Clock)
Parallel.For(0, 1000, Sub(i) seen.Add(Clock.Current))
Console.WriteLine($" distinct instances: {seen.Distinct().Count()}")
Console.WriteLine($" today: {Clock.Current.Today():yyyy-MM-dd}, Registry.Name = {Registry.Name}")
End Sub
End Module
before first use of ShelfSettings.Instance
ShelfSettings constructor runs
instance obtained: True
second access:
same instance: True, LoanDays = 14
1000 threads race for Clock.Current:
Clock constructor runs
distinct instances: 1
today: 2026-10-19, Registry.Name = main-branch
-
Lazy(Of T)creates the instance on first use of.Valueand, in its default mode, is thread-safe: only one thread runs the factory, and every thread sees the same object. The constructor line appeared at first access, not at start-up. -
A
Shared ReadOnlyfield initialised withNewis created by the type initializer, which the runtime runs exactly once even when 1,000 threads race for it (distinct instances: 1). When a class has only field initialisers, the runtime may run the initializer any time before the first access; add aShared Sub Newif the exact moment matters. -
The
Private Sub Newstops other code creating instances;NotInheritablestops a derived class from adding one. -
A standard
Moduleis a singleton in all but name: its members behave likeSharedmembers, there is no instance, and it cannot implement an interface. That is fine for stateless helpers and wrong for anything that needs to be replaced in a test. -
The classic hand-written form (a
Sharedfield set when a null check fails) is not thread-safe and needs a lock;Lazy(Of T)removes the need.
Source: Lazy initialization
Source: Lazy(Of T)
Factory
A factory hides which concrete class is created. CatalogItemFactory.Create takes a kind name and returns the abstract CatalogItem; the caller never
mentions Book or Magazine. The registry variant keeps the creators in a dictionary of delegates, so a new kind is a new registration and not a
change to a Select Case.
Option Strict On
Public NotInheritable Class CatalogItemFactory
' Factory method: callers name a kind, the factory chooses the subclass.
Public Shared Function Create(kind As String, title As String, year As Integer, Optional detail As String = "") As CatalogItem
Select Case kind.ToLowerInvariant()
Case "book"
Return New Book(title, year, Isbn.Parse(detail))
Case "magazine"
Return New Magazine(title, year, Integer.Parse(detail))
Case Else
Throw New ArgumentException($"Unknown catalogue item kind '{kind}'.", NameOf(kind))
End Select
End Function
End Class
' A registry of creator delegates lets new kinds be added without editing a Select Case.
Public Class CatalogItemRegistry
Private ReadOnly _creators As New Dictionary(Of String, Func(Of String, Integer, String, CatalogItem))(StringComparer.OrdinalIgnoreCase)
Public Sub Register(kind As String, creator As Func(Of String, Integer, String, CatalogItem))
_creators(kind) = creator
End Sub
Public Function Create(kind As String, title As String, year As Integer, detail As String) As CatalogItem
Dim creator As Func(Of String, Integer, String, CatalogItem) = Nothing
If Not _creators.TryGetValue(kind, creator) Then
Throw New ArgumentException($"No creator registered for '{kind}'.", NameOf(kind))
End If
Return creator(title, year, detail)
End Function
End Class
Module Program
Sub Main()
Dim dune = CatalogItemFactory.Create("Book", "Dune", 1965, "9780306406157")
Dim issue = CatalogItemFactory.Create("magazine", "Byte", 1984, "7")
Console.WriteLine($"{dune.Kind}: {dune.Title} ({dune.Year})")
Console.WriteLine(issue.Describe())
Try
CatalogItemFactory.Create("dvd", "Alien", 1979)
Catch ex As ArgumentException
Console.WriteLine(ex.Message.Replace(Environment.NewLine, " "))
End Try
Dim registry As New CatalogItemRegistry()
registry.Register("book", Function(t, y, d) New Book(t, y, Isbn.Parse(d)))
registry.Register("magazine", Function(t, y, d) New Magazine(t, y, Integer.Parse(d)))
Dim again = registry.Create("BOOK", "Emma", 1815, "9780141439587")
Console.WriteLine($"{again.Kind}: {again.Title} created through the registry")
End Sub
End Module
Book: Dune (1965)
Magazine: Byte (1984)
Unknown catalogue item kind 'dvd'. (Parameter 'kind')
Book: Emma created through the registry
-
Select Case kind.ToLowerInvariant()with aCase Elsethat throws makes the unknown-kind case explicit.NameOf(kind)keeps the parameter name in the message correct when the parameter is renamed (the message ends with(Parameter 'kind')). -
The creators are lambdas:
Function(t, y, d) New Book(t, y, Isbn.Parse(d)). A lambda with a body that is a single expression needs noReturn. -
The registry’s dictionary uses
StringComparer.OrdinalIgnoreCase, soBOOKfound thebookcreator. -
Isbn.Parsethrows for an invalid number, so the factory fails early rather than creating an item that cannot be valid. -
A constructor with many optional parts is a case for an object initialiser (
New Book(...) With {.Id = 5}) or a builder; Visual Basic has named and optional arguments, which solve most of what the Builder pattern solved in older languages.
Source: Procedures
Observer
Observer lets a subject notify subscribers without knowing them. In Visual Basic that is an event: the publisher declares Event Returned As EventHandler(Of
LoanEventArgs) and calls RaiseEvent; each subscriber attaches a handler either with AddHandler or with a WithEvents field and a Handles clause.
Option Strict On
Public Class LateFeeCalculator
Private ReadOnly _today As Date
Public Sub New(today As Date)
_today = today
End Sub
Public Sub OnReturned(sender As Object, e As LoanEventArgs)
Dim late = Math.Max(0, (_today - e.Loan.DueOn).Days)
Console.WriteLine($" fee for {e.Loan.Item.Title}: {late} day(s) late")
End Sub
End Class
Public Class ReturnNotifier
Private WithEvents _watched As Loan
Public Sub Watch(loan As Loan)
_watched = loan
End Sub
Private Sub Watched_Returned(sender As Object, e As LoanEventArgs) Handles _watched.Returned
Console.WriteLine($" notify {e.Loan.Borrower}: '{e.Loan.Item.Title}' is back on the shelf")
End Sub
End Class
Module Program
Sub Main()
Dim book = New Book("Dune", 1965, Isbn.Parse("9780306406157"))
Dim loan As New Loan(book, "placeholder-borrower", New Date(2026, 10, 10))
Dim calculator As New LateFeeCalculator(New Date(2026, 10, 19))
Dim notifier As New ReturnNotifier()
notifier.Watch(loan)
AddHandler loan.Returned, AddressOf calculator.OnReturned
AddHandler loan.Returned, Sub(sender, e) Console.WriteLine($" audit: state is now {e.Loan.State}")
Console.WriteLine("returning the loan:")
loan.Return()
Console.WriteLine("second return:")
Try
loan.Return()
Catch ex As InvalidOperationException
Console.WriteLine($" {ex.Message}")
End Try
Dim another As New Loan(book, "placeholder-borrower", New Date(2026, 10, 20))
AddHandler another.Returned, AddressOf calculator.OnReturned
RemoveHandler another.Returned, AddressOf calculator.OnReturned
Console.WriteLine("handler removed before returning another loan:")
another.Return()
Console.WriteLine(" (no output: nobody was subscribed)")
End Sub
End Module
returning the loan:
notify placeholder-borrower: 'Dune' is back on the shelf
fee for Dune: 9 day(s) late
audit: state is now Returned
second return:
The loan was already returned.
handler removed before returning another loan:
(no output: nobody was subscribed)
-
ReturnNotifierusesWithEventsandHandles: assigning_watchedattaches the handler, and assigning another loan detaches it again. It ran first, because it was attached first; handlers run in the order of attachment, on the thread that raised the event. -
LateFeeCalculator.OnReturnedwas attached withAddHandler loan.Returned, AddressOf calculator.OnReturned; the lambda handler printed the audit line. One subscriber that throws stops the others from running, so keep handlers short and catch inside them. -
RemoveHandlerwith the sameAddressOfexpression removed the handler. A handler written as a lambda cannot be removed this way because each lambda is a new delegate (the compiler warns with BC42326); keep the delegate in a variable if it must be removed. -
The publisher holds a reference to every subscriber, so a short-lived subscriber attached to a long-lived publisher leaks until it is detached.
-
The loan raised the event once: the second
Returnthrew before reachingRaiseEvent. -
For streams of values rather than one-off notifications, .NET has
IObservable(Of T)andIObserver(Of T); the Reactive Extensions library builds on them. Plain events are enough for notifications like this.
Source: Events
Source: AddHandler Statement
Source: WithEvents
Source: Handles Clause
Source: Observer design pattern
See also Events and Handlers.
Strategy
Strategy puts a family of algorithms behind one abstraction so the caller can choose at run time. With several related operations, define an interface and one
class per strategy; with one operation, a Func(Of ...) delegate is shorter and just as testable.
Option Strict On
Public Interface ILateFeePolicy
Function FeeFor(loan As Loan, returnedOn As Date) As Decimal
End Interface
Public Class NoFeePolicy
Implements ILateFeePolicy
Public Function FeeFor(loan As Loan, returnedOn As Date) As Decimal Implements ILateFeePolicy.FeeFor
Return 0D
End Function
End Class
Public Class PerDayPolicy
Implements ILateFeePolicy
Private ReadOnly _ratePerDay As Decimal
Private ReadOnly _cap As Decimal
Public Sub New(ratePerDay As Decimal, cap As Decimal)
_ratePerDay = ratePerDay
_cap = cap
End Sub
Public Function FeeFor(loan As Loan, returnedOn As Date) As Decimal Implements ILateFeePolicy.FeeFor
Dim days = Math.Max(0, (returnedOn - loan.DueOn).Days)
Return Math.Min(_cap, days * _ratePerDay)
End Function
End Class
Public Class Librarian
Private ReadOnly _policy As ILateFeePolicy
Public Sub New(policy As ILateFeePolicy)
_policy = policy
End Sub
Public Function Checkin(loan As Loan, returnedOn As Date) As Decimal
loan.Return()
Return _policy.FeeFor(loan, returnedOn)
End Function
End Class
Module Program
Private Function NewLoan() As Loan
Return New Loan(New Magazine("Byte", 1984, 7), "placeholder-borrower", New Date(2026, 10, 1))
End Function
Sub Main()
Dim returnedOn = New Date(2026, 10, 19)
' Strategy as an interface.
Console.WriteLine($"no fee : {New Librarian(New NoFeePolicy()).Checkin(NewLoan(), returnedOn):0.00}")
Console.WriteLine($"per day : {New Librarian(New PerDayPolicy(0.25D, 10D)).Checkin(NewLoan(), returnedOn):0.00}")
Console.WriteLine($"per day, cap : {New Librarian(New PerDayPolicy(1D, 10D)).Checkin(NewLoan(), returnedOn):0.00}")
' Strategy as a delegate: a lambda is enough when there is a single method.
Dim weekendFree As Func(Of Loan, Date, Decimal) =
Function(loan, day) If(day.DayOfWeek = DayOfWeek.Sunday, 0D, Math.Max(0, (day - loan.DueOn).Days) * 0.5D)
Console.WriteLine($"lambda, Mon : {weekendFree(NewLoan(), returnedOn):0.00} ({returnedOn.DayOfWeek})")
Console.WriteLine($"lambda, Sun : {weekendFree(NewLoan(), returnedOn.AddDays(-1)):0.00} ({returnedOn.AddDays(-1).DayOfWeek})")
End Sub
End Module
no fee : 0.00
per day : 4.50
per day, cap : 10.00
lambda, Mon : 9.00 (Monday)
lambda, Sun : 0.00 (Sunday)
-
Librariandepends onILateFeePolicy, not on a concrete policy. The policy is given in the constructor, which makes the strategy replaceable in tests. -
PerDayPolicyreads its rate and cap from the constructor, so two instances with different parameters are two strategies.Math.Minapplies the cap (1Dper day for 18 days is capped at10). -
The lambda assigned to
Func(Of Loan, Date, Decimal)is the delegate form. The lambda is one expression continued on the next line after the=, and theIf(cond, a, b)operator evaluates only the branch it returns. TheDsuffix makes0DaDecimal; money isDecimal, neverDouble. -
A
Select Caseon a "type" code is the smell that Strategy removes: each new case edits one method instead of adding one class.
Source: Lambda Expressions
Decorator
A decorator adds behaviour to an object by wrapping another object that implements the same interface. Because Catalog talks only to ICatalogStore, logging
and caching can be added around any store without changing Catalog or the store.
Option Strict On
Public Class InMemoryCatalogStore
Implements ICatalogStore
Private ReadOnly _items As New List(Of CatalogItem)
Public Property FindCalls As Integer
Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
Return _items
End Function
Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
item.Id = _items.Count + 1
_items.Add(item)
End Sub
Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
FindCalls += 1
Return _items.FirstOrDefault(Function(i) i.Id = id)
End Function
End Class
' Decorators implement the same interface and wrap another instance.
Public Class LoggingCatalogStore
Implements ICatalogStore
Private ReadOnly _inner As ICatalogStore
Public Sub New(inner As ICatalogStore)
_inner = inner
End Sub
Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
Console.WriteLine(" [log] GetAll")
Return _inner.GetAll()
End Function
Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
Console.WriteLine($" [log] Add {item.Title}")
_inner.Add(item)
End Sub
Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
Console.WriteLine($" [log] Find {id}")
Return _inner.Find(id)
End Function
End Class
Public Class CachingCatalogStore
Implements ICatalogStore
Private ReadOnly _inner As ICatalogStore
Private ReadOnly _cache As New Dictionary(Of Integer, CatalogItem)
Public Sub New(inner As ICatalogStore)
_inner = inner
End Sub
Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
Return _inner.GetAll()
End Function
Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
_inner.Add(item)
End Sub
Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
Dim hit As CatalogItem = Nothing
If Not _cache.TryGetValue(id, hit) Then
hit = _inner.Find(id)
If hit IsNot Nothing Then _cache(id) = hit
End If
Return hit
End Function
End Class
Module Program
Sub Main()
Dim inner As New InMemoryCatalogStore()
Dim store As ICatalogStore = New LoggingCatalogStore(New CachingCatalogStore(inner))
Dim catalog As New Catalog(store)
catalog.Add(New Book("Dune", 1965, Isbn.Parse("9780306406157"), New Author("Frank Herbert")))
catalog.Add(New Magazine("Byte", 1984, 7))
Console.WriteLine("three lookups of item 1:")
For i = 1 To 3
store.Find(1)
Next
Console.WriteLine($"calls that reached the real store: {inner.FindCalls}")
Console.WriteLine("search through the decorated store:")
For Each item In catalog.Search("dun")
Console.WriteLine($" found {item.Describe()}")
Next
End Sub
End Module
[log] Add Dune
[log] Add Byte
three lookups of item 1:
[log] Find 1
[log] Find 1
[log] Find 1
calls that reached the real store: 1
search through the decorated store:
[log] GetAll
found Book: Dune (1965) by Frank Herbert
-
The chain is built inside-out:
LoggingCatalogStore(CachingCatalogStore(InMemoryCatalogStore)). A call passes through the logger, then the cache, then the real store; that is why everyFindwas logged but only the first reached the real store (calls that reached the real store: 1). -
Each decorator implements every member of the interface and forwards what it does not change. That repetition is the cost of the pattern in a language without delegation; the Visual Studio "Implement Interface" quick action writes the members for you.
-
Order matters. Put the cache outside the logger and cache hits would no longer be logged.
-
A cache decorator must decide about staleness and thread safety; this one is deliberately minimal and is not safe to share between threads.
-
.NEThas decorators in its own libraries (Streamwrappers such asBufferedStreamandGZipStream) andHttpClientmessage handlers are a decorator chain.
See Inheritance and Polymorphism for why composition is preferred to deriving a
LoggingInMemoryCatalogStore.
Repository
A repository presents a collection of domain objects and hides how they are stored. In the shelf, ICatalogStore is the repository interface; Catalog
(the domain service) never knows whether the items live in memory, in a JSON file or in a database. The domain owns the interface, and the storage
technology implements it. The JSON store below maps between the domain classes and a persistence record, so the file format can change without touching
the domain.
Option Strict On
Imports System.IO
Imports System.Text.Json
' A persistence record: plain data, independent of the domain classes.
Public Class CatalogRecord
Public Property Kind As String = ""
Public Property Title As String = ""
Public Property Year As Integer
Public Property Detail As String = ""
End Class
Public Class JsonCatalogStore
Implements ICatalogStore
Private ReadOnly _path As String
Private ReadOnly _items As New List(Of CatalogItem)
Public Sub New(path As String)
_path = path
If File.Exists(path) Then
Dim records = JsonSerializer.Deserialize(Of List(Of CatalogRecord))(File.ReadAllText(path))
For Each r In records
_items.Add(ToItem(r))
_items(_items.Count - 1).Id = _items.Count
Next
End If
End Sub
Private Shared Function ToItem(r As CatalogRecord) As CatalogItem
Select Case r.Kind
Case "book"
Return New Book(r.Title, r.Year, Isbn.Parse(r.Detail))
Case "magazine"
Return New Magazine(r.Title, r.Year, Integer.Parse(r.Detail))
Case Else
Throw New InvalidDataException($"Unknown record kind '{r.Kind}'.")
End Select
End Function
Private Shared Function ToRecord(item As CatalogItem) As CatalogRecord
Dim book = TryCast(item, Book)
If book IsNot Nothing Then
Return New CatalogRecord With {.Kind = "book", .Title = book.Title, .Year = book.Year, .Detail = book.Isbn.ToString()}
End If
Dim magazine = DirectCast(item, Magazine)
Return New CatalogRecord With {.Kind = "magazine", .Title = magazine.Title, .Year = magazine.Year, .Detail = magazine.IssueNumber.ToString()}
End Function
Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
Return _items
End Function
Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
item.Id = _items.Count + 1
_items.Add(item)
File.WriteAllText(_path, JsonSerializer.Serialize(_items.Select(AddressOf ToRecord).ToList()))
End Sub
Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
Return _items.FirstOrDefault(Function(i) i.Id = id)
End Function
End Class
Module Program
Sub Main()
Dim path = IO.Path.Combine(IO.Path.GetTempPath(), "shelf-catalog.json")
If File.Exists(path) Then File.Delete(path)
Dim first As New Catalog(New JsonCatalogStore(path))
first.Add(New Book("Dune", 1965, Isbn.Parse("9780306406157")))
first.Add(New Magazine("Byte", 1984, 7))
Console.WriteLine(File.ReadAllText(path))
' A new store instance reads the same file: the repository hides persistence from Catalog.
Dim second As New Catalog(New JsonCatalogStore(path))
For Each item In second.Search("")
Console.WriteLine($"reloaded: #{item.Id} {item.Kind}: {item.Title}")
Next
File.Delete(path)
End Sub
End Module
[{"Kind":"book","Title":"Dune","Year":1965,"Detail":"9780306406157"},{"Kind":"magazine","Title":"Byte","Year":1984,"Detail":"7"}]
reloaded: #2 Magazine: Byte
reloaded: #1 Book: Dune
-
A new
Catalogover a newJsonCatalogStoreread the file written by the first one and found the same items, in title order becauseSearch("")orders by title. The ids were restored from the position in the file. -
The
CatalogRecordclass has only public auto-properties, which System.Text.Json serialises by default. The domain classes (Bookwith its private-constructorIsbn) are never serialised directly. -
ToItemandToRecordare the mapping layer. They fail loudly for an unknown kind (InvalidDataException), so a corrupted file does not produce half-built objects. -
_items.Select(AddressOf ToRecord)passes a method group where aFunc(Of CatalogItem, CatalogRecord)is expected. Visual Basic accepts it because the signature matches exactly underOption Strict On. -
In an EF Core application the
DbContextalready acts as repository and unit of work. Wrapping it in another repository helps when the domain must not reference EF Core, and adds code when it does not. See Data Access: ADO.NET and EF Core. -
The same interface makes testing easy: a test uses
InMemoryCatalogStore. See Testing.
Source: System.Text.Json overview
Dependency injection
Dependency injection is the pattern behind the others on this page: a class receives its collaborators through its constructor and does not create them.
Microsoft.Extensions.DependencyInjection is the container used by ASP.NET Core, the generic host and most libraries. It needs a package reference and
an Imports line for its extension methods.
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>
Option Strict On
Imports Microsoft.Extensions.DependencyInjection
Public Class InMemoryCatalogStore
Implements ICatalogStore
Private ReadOnly _items As New List(Of CatalogItem)
Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
Return _items
End Function
Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
item.Id = _items.Count + 1
_items.Add(item)
End Sub
Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
Return _items.FirstOrDefault(Function(i) i.Id = id)
End Function
End Class
Public Class LoggingCatalogStore
Implements ICatalogStore
Private ReadOnly _inner As ICatalogStore
Public Sub New(inner As ICatalogStore)
_inner = inner
End Sub
Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
Return _inner.GetAll()
End Function
Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
Console.WriteLine($" [log] Add {item.Title}")
_inner.Add(item)
End Sub
Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
Return _inner.Find(id)
End Function
End Class
Public Class RequestId
Implements IDisposable
Private Shared _next As Integer
Public ReadOnly Property Value As Integer = Threading.Interlocked.Increment(_next)
Public Sub Dispose() Implements IDisposable.Dispose
Console.WriteLine($" request id {Value} disposed")
End Sub
End Class
Module Program
Sub Main()
Dim services As New ServiceCollection()
services.AddSingleton(Of InMemoryCatalogStore)()
services.AddSingleton(Of ICatalogStore)(Function(provider) New LoggingCatalogStore(provider.GetRequiredService(Of InMemoryCatalogStore)()))
services.AddTransient(Of Catalog)()
services.AddScoped(Of RequestId)()
Using provider = services.BuildServiceProvider(New ServiceProviderOptions With {.ValidateScopes = True, .ValidateOnBuild = True})
' Catalog has one constructor parameter, ICatalogStore, and the container supplies it.
Dim first = provider.GetRequiredService(Of Catalog)()
Dim second = provider.GetRequiredService(Of Catalog)()
Console.WriteLine($"transient Catalog: same instance = {ReferenceEquals(first, second)}")
first.Add(New Book("Dune", 1965, Isbn.Parse("9780306406157")))
Console.WriteLine($"singleton store shared: second catalog finds {second.Search("dune").Count()} item(s)")
Using scope1 = provider.CreateScope()
Dim a = scope1.ServiceProvider.GetRequiredService(Of RequestId)()
Dim b = scope1.ServiceProvider.GetRequiredService(Of RequestId)()
Console.WriteLine($"scope 1: ids {a.Value} and {b.Value}, same = {ReferenceEquals(a, b)}")
End Using
Using scope2 = provider.CreateScope()
Console.WriteLine($"scope 2: id {scope2.ServiceProvider.GetRequiredService(Of RequestId)().Value}")
End Using
Try
provider.GetRequiredService(Of RequestId)()
Catch ex As InvalidOperationException
Console.WriteLine($"scoped service from the root provider -> {ex.Message}")
End Try
End Using
End Sub
End Module
transient Catalog: same instance = False
[log] Add Dune
singleton store shared: second catalog finds 1 item(s)
scope 1: ids 1 and 1, same = True
request id 1 disposed
scope 2: id 2
request id 2 disposed
scoped service from the root provider -> Cannot resolve scoped service 'ShelfApp.RequestId' from root provider.
-
services.AddSingleton(Of ICatalogStore)(Function(provider) ...)registers a factory. It builds the logging decorator around the registered in-memory store, so the decorator chain of the previous section is configured in one place andCatalogstill asks only forICatalogStore. -
AddTransient(Of Catalog)()registers a class without an interface; the container finds its one constructor and suppliesICatalogStore. Two requests gave two differentCatalogobjects over the same store. -
The three lifetimes: singleton (one instance for the provider), scoped (one per scope, for example per web request) and transient (a new instance every time). Scope 1 resolved
RequestIdtwice and got the same object; scope 2 got a new one, and each scoped instance was disposed when its scope ended, becauseRequestIdimplementsIDisposable. -
ValidateScopesandValidateOnBuildturn lifetime mistakes into exceptions: resolving a scoped service from the root provider failed with the message shown, and a singleton that depends on a scoped service would fail the same way. Switch both on in development. -
In Visual Basic the generic arguments are written with
Of, and the extension methods needImports Microsoft.Extensions.DependencyInjection. ASP.NET Core and the generic host call the same registration code for you; in a console project import the namespace yourself. -
A container is not a service locator: call
GetRequiredServiceonly at the composition root (hereMain), and let constructors receive what they need. -
Avoid injecting the container itself, avoid constructors with a long list of parameters (a sign that the class does too much) and register by the interface the consumer needs.
Source: Dependency injection in .NET
Source: Dependency injection guidelines
See Dependency Injection in ASP.NET Core for the host integration and Object Lifetime and Disposal for disposal of container-created objects.
Code smells and refactoring
A smell is a symptom in the code that suggests a design problem. Patterns often arrive as the cure for a smell, and the Visual Studio refactorings below perform the mechanical part. The program shows the same report computed twice: first by a method with several smells, then by the refactored version.
Option Strict On
Imports System.Globalization
' Before: a long method with magic numbers, a primitive state code and string-typed data.
Module Before
Public Function Report(rows As String()) As List(Of String)
Dim lines As New List(Of String)
For Each row In rows
Dim p = row.Split(","c)
Dim fee As Decimal = 0D
Dim status As String
If p(2) = "1" Then
status = "active"
ElseIf p(2) = "2" Then
status = "returned"
Else
status = "overdue"
Dim days = CInt(p(3))
fee = days * 0.25D
If fee > 10D Then fee = 10D
End If
lines.Add(p(0).Trim() & " [" & status & "] " & fee.ToString("0.00", CultureInfo.InvariantCulture))
Next
Return lines
End Function
End Module
' After: named types and constants, one job per method.
Public Enum LoanStatus
Active = 1
Returned = 2
Overdue = 3
End Enum
Public Structure LoanRow
Public ReadOnly Property Title As String
Public ReadOnly Property Status As LoanStatus
Public ReadOnly Property DaysLate As Integer
Public Sub New(title As String, status As LoanStatus, daysLate As Integer)
Me.Title = title
Me.Status = status
Me.DaysLate = daysLate
End Sub
Public Shared Function Parse(row As String) As LoanRow
Dim parts = row.Split(","c)
Return New LoanRow(parts(0).Trim(), CType(Integer.Parse(parts(2), CultureInfo.InvariantCulture), LoanStatus), Integer.Parse(parts(3), CultureInfo.InvariantCulture))
End Function
End Structure
Module After
Private Const RatePerDay As Decimal = 0.25D
Private Const MaxFee As Decimal = 10D
Private Function FeeFor(row As LoanRow) As Decimal
If row.Status <> LoanStatus.Overdue Then Return 0D
Return Math.Min(MaxFee, row.DaysLate * RatePerDay)
End Function
Public Function Report(rows As IEnumerable(Of String)) As IEnumerable(Of String)
Return From row In rows
Let loan = LoanRow.Parse(row)
Select $"{loan.Title} [{loan.Status.ToString().ToLowerInvariant()}] {FeeFor(loan).ToString("0.00", CultureInfo.InvariantCulture)}"
End Function
End Module
Module Program
Sub Main()
Dim rows = {"Dune,1965,1,0", "Emma,1815,3,12", "Ulysses,1922,3,100", "Byte,1984,2,0"}
Dim oldLines = Before.Report(rows)
Dim newLines = After.Report(rows).ToList()
For i = 0 To oldLines.Count - 1
Console.WriteLine($"{oldLines(i),-28} | {newLines(i)}")
Next
Console.WriteLine($"identical output: {oldLines.SequenceEqual(newLines)}")
End Sub
End Module
Dune [active] 0.00 | Dune [active] 0.00
Emma [overdue] 3.00 | Emma [overdue] 3.00
Ulysses [overdue] 10.00 | Ulysses [overdue] 10.00
Byte [returned] 0.00 | Byte [returned] 0.00
identical output: True
The two columns are identical, which is the point of a refactoring: the behaviour does not change. The "before" method has four smells that the "after" code removes:
| Smell | In the "before" method | Cure |
|---|---|---|
Primitive obsession |
The loan state is the string |
An |
Magic numbers |
|
Named constants |
Long method |
Parsing, classification, the fee rule and formatting are one 20-line body. |
One job per method: |
Mixed responsibilities |
Parsing the row and formatting the line happen in the same loop body. |
|
A larger set of smells and the tools for them:
| Smell | Symptom | Visual Studio refactoring or pattern |
|---|---|---|
Long method |
More than a screen, several levels of nesting. |
Extract Method (select the lines, Quick Actions). |
Large class |
Many unrelated fields and methods. |
Extract Interface, move members to new types, split with |
Poor names |
|
Rename, which updates every reference, including those in other projects of the solution. |
Public fields |
|
Encapsulate Field turns it into a property. |
Switch on a type code |
|
Polymorphism ( |
Long parameter list |
Five or more parameters that travel together. |
A parameter object (a |
One type per file violated |
Several unrelated classes in one file. |
Move Type to Matching File. |
Unimplemented interface |
A class that must implement a long interface. |
Implement Interface. |
Duplicate code |
The same ten lines in two places. |
Extract Method, then call it from both. |
Complexity |
Many nested |
Visual Studio Code Metrics (cyclomatic complexity) shows where. |
Refactor in small steps with the tests green after each step, and let the compiler help: a rename that breaks nothing, an extracted method that the original calls, then a deletion. Refactorings that depend on the language service (Rename, Extract Method, Extract Interface, Encapsulate Field, Move Type to Matching File) work for Visual Basic in Visual Studio; not every Quick Action exists for both languages, so check the Quick Actions menu before relying on one.
Source: Refactoring in Visual Studio
Source: Extract Method
Source: Rename
Source: Code metrics values
Where the books are outdated
| Older advice | Current practice |
|---|---|
Singleton by a |
|
Observer implemented with a hand-written interface, a list of observers and a |
|
Patterns shown with |
Generic collections and |
Creating collaborators inside constructors ( |
Constructor injection and the container; |
Repository examples built on LINQ to SQL or EF6. |
EF Core 10, or ADO.NET with parameters; see the data-access page. |
Version notes
-
Interpolated strings (used throughout) arrived in Visual Basic 14 (2015); the same literal converts to
FormattableString, which is what EF Core’sFromSqltakes. -
Lazy(Of T)has been in the base class library since .NET Framework 4.0. Events,HandlesandWithEventshave been in the language since the first version. -
Microsoft.Extensions.DependencyInjectionis a NuGet package, not part of the shared framework, so a console project must reference it; ASP.NET Core and the generic host reference it for you. -
No pattern on this page needed a Visual Basic feature newer than 15.x, and no newer syntax exists that would change them (Visual Basic 17.13 added consumption features only).