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

Lazy(Of T) or a Shared ReadOnly field with a Private Sub New; for stateless helpers, a Module. Better still, a container registration with a singleton lifetime.

Singleton

Factory

A Shared factory method with Select Case, or a dictionary of Func(Of ...) creators.

Factory

Observer

Event, RaiseEvent, AddHandler or WithEvents with Handles.

Observer

Strategy

An interface for a family of algorithms, or a Func(Of ...) delegate for a single operation.

Strategy

Decorator

A class that implements the same interface as the object it wraps and holds it in a Private ReadOnly field.

Decorator

Repository

An interface owned by the domain (ICatalogStore) with one implementation per storage technology.

Repository

Dependency injection

Constructor parameters, registered in a ServiceCollection and resolved from a ServiceProvider.

Dependency injection

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 .Value and, 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 ReadOnly field initialised with New is 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 a Shared Sub New if the exact moment matters.

  • The Private Sub New stops other code creating instances; NotInheritable stops a derived class from adding one.

  • A standard Module is a singleton in all but name: its members behave like Shared members, 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 Shared field set when a null check fails) is not thread-safe and needs a lock; Lazy(Of T) removes the need.

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 a Case Else that 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 no Return.

  • The registry’s dictionary uses StringComparer.OrdinalIgnoreCase, so BOOK found the book creator.

  • Isbn.Parse throws 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)
  • ReturnNotifier uses WithEvents and Handles: assigning _watched attaches 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.OnReturned was attached with AddHandler 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.

  • RemoveHandler with the same AddressOf expression 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 Return threw before reaching RaiseEvent.

  • For streams of values rather than one-off notifications, .NET has IObservable(Of T) and IObserver(Of T); the Reactive Extensions library builds on them. Plain events are enough for notifications like this.

Source: Events

Source: WithEvents

Source: Handles Clause

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)
  • Librarian depends on ILateFeePolicy, not on a concrete policy. The policy is given in the constructor, which makes the strategy replaceable in tests.

  • PerDayPolicy reads its rate and cap from the constructor, so two instances with different parameters are two strategies. Math.Min applies the cap (1D per day for 18 days is capped at 10).

  • 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 the If(cond, a, b) operator evaluates only the branch it returns. The D suffix makes 0D a Decimal; money is Decimal, never Double.

  • A Select Case on a "type" code is the smell that Strategy removes: each new case edits one method instead of adding one class.

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.

classDiagram class ICatalogStore { <<interface>> GetAll() Add(item) Find(id) } class InMemoryCatalogStore class LoggingCatalogStore class CachingCatalogStore class Catalog ICatalogStore <|.. InMemoryCatalogStore ICatalogStore <|.. LoggingCatalogStore ICatalogStore <|.. CachingCatalogStore LoggingCatalogStore o-- ICatalogStore : inner CachingCatalogStore o-- ICatalogStore : inner Catalog --> ICatalogStore : uses
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 every Find was 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.

  • .NET has decorators in its own libraries (Stream wrappers such as BufferedStream and GZipStream) and HttpClient message 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 Catalog over a new JsonCatalogStore read the file written by the first one and found the same items, in title order because Search("") orders by title. The ids were restored from the position in the file.

  • The CatalogRecord class has only public auto-properties, which System.Text.Json serialises by default. The domain classes (Book with its private-constructor Isbn) are never serialised directly.

  • ToItem and ToRecord are 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 a Func(Of CatalogItem, CatalogRecord) is expected. Visual Basic accepts it because the signature matches exactly under Option Strict On.

  • In an EF Core application the DbContext already 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.

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 and Catalog still asks only for ICatalogStore.

  • AddTransient(Of Catalog)() registers a class without an interface; the container finds its one constructor and supplies ICatalogStore. Two requests gave two different Catalog objects 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 RequestId twice and got the same object; scope 2 got a new one, and each scoped instance was disposed when its scope ended, because RequestId implements IDisposable.

  • ValidateScopes and ValidateOnBuild turn 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 need Imports 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 GetRequiredService only at the composition root (here Main), 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.

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 "1", "2" or another value.

An Enum (LoanStatus, like LoanState in the shelf) and a Structure that parses a row once.

Magic numbers

0.25D and 10D appear in the middle of the logic.

Named constants RatePerDay and MaxFee.

Long method

Parsing, classification, the fee rule and formatting are one 20-line body.

One job per method: LoanRow.Parse, FeeFor, Report.

Mixed responsibilities

Parsing the row and formatting the line happen in the same loop body.

LoanRow.Parse parses once; Report only formats.

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 Partial classes only when generated code requires it.

Poor names

Dim d, Sub DoIt.

Rename, which updates every reference, including those in other projects of the solution.

Public fields

Public Name As String.

Encapsulate Field turns it into a property.

Switch on a type code

Select Case item.Kind repeated in several places.

Polymorphism (CatalogItem.Describe), or Strategy.

Long parameter list

Five or more parameters that travel together.

A parameter object (a Structure or Class), or optional named arguments.

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 If and Select Case.

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: Extract Method

Source: Rename

Where the books are outdated

Older advice Current practice

Singleton by a Shared field and a null check, with or without a lock.

Lazy(Of T), a Shared ReadOnly field, or a container singleton.

Observer implemented with a hand-written interface, a list of observers and a For Each.

Event and RaiseEvent; IObservable(Of T) for streams.

Patterns shown with Option Strict Off shortcuts, ArrayList and Hashtable.

Generic collections and Option Strict On.

Creating collaborators inside constructors (New SqlConnection(...)).

Constructor injection and the container; Microsoft.Data.SqlClient instead of System.Data.SqlClient.

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’s FromSql takes.

  • Lazy(Of T) has been in the base class library since .NET Framework 4.0. Events, Handles and WithEvents have been in the language since the first version.

  • Microsoft.Extensions.DependencyInjection is 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).