In the previous lesson you formalized "what an object can do" with interfaces. This lesson solves a different problem: how to make one part of the program notify another that something has happened — for example, that a loan has been registered — without whoever triggers the notification needing to know in advance who will react, or how many will. C#'s solution rests on two related pieces: delegates, variables that store a reference to a method, and events, built on top of delegates, which formalize the publisher/subscriber pattern. In this lesson you'll get to know both, and take the first step toward the Library class, which in the coming lessons will organize BiblioTech's entire catalog.

Contents

  1. The problem: notifying without coupling who notifies to who listens
  2. Delegates: variables that point to methods
  3. Predefined delegates: Action, Func, and Predicate
  4. Multicast: a delegate with several subscribed methods
  5. Events: event and the publisher/subscriber pattern
  6. event versus a plain public delegate
  7. Applying events in BiblioTech: LoanRegistered

  1. The problem: notifying without coupling who notifies to who listens

Imagine that, when a loan is registered in BiblioTech, you want several things to happen at once: show a confirmation message, perhaps in the future send an email notification, perhaps log the loan to an audit file. The direct approach — calling each of those actions explicitly inside the method that registers the loan — works, but it strongly couples the logic of "registering a loan" with "everything that must happen afterward," and every time a new notification is added, that method has to be touched again. Delegates and events let you invert that relationship: the code that registers the loan simply "announces that something happened," without knowing or caring who (none, one, or several) is listening for that announcement.

  1. Delegates: variables that point to methods

A delegate is a type that represents a method's signature (its parameters and return type) and whose variables can store a reference to any method compatible with that signature:

delegate bool ItemFilter(LibraryItem item);

This line declares a delegate type called ItemFilter: any method that takes a LibraryItem and returns a bool is compatible with it. A variable of this type can point to any of those methods:

bool IsMagazine(LibraryItem item)
{
    return item is Magazine;
}

ItemFilter filter = IsMagazine; // the "filter" variable now points to the IsMagazine method

Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Console.WriteLine(filter(book1)); // False: invokes IsMagazine(book1) through the delegate

filter(book1) doesn't call any method literally named filter: it invokes, through the delegate, whichever method filter points to at that moment (IsMagazine). This is what makes a delegate useful: it can be passed as a parameter, stored in a variable or a field, and changed at run time to point to a different concrete method.

  1. Predefined delegates: Action, Func, and Predicate

Declaring your own delegate for every distinct signature would be repetitive. .NET offers three families of predefined generic delegates (the generics, with <T>, are studied in depth in the next lesson) that cover almost every common case:

Delegate Parameters Return value Example signature
Action Zero or more (up to 16) None (void) Action<Loan>void M(Loan l)
Func<..., TResult> Zero or more The last generic type parameter Func<LibraryItem, bool>bool M(LibraryItem i)
Predicate<T> Exactly one bool Predicate<LibraryItem>bool M(LibraryItem i)

In practice, Predicate<T> and Func<T, bool> are equivalent (same parameters, same return type); Predicate<T> is older and appears mostly in legacy APIs, while Func/Action are the ones you'll see more often in modern code and in LINQ (a later lesson in this module). With these predefined delegates, there's no longer any need to declare ItemFilter:

Func<LibraryItem, bool> isMagazine = IsMagazine; // same method, predefined delegate

Action<string> showMessage = Console.WriteLine; // an Action wrapping a method from the BCL itself
showMessage("Notice from an Action");

  1. Multicast: a delegate with several subscribed methods

A key feature of delegates in C# is that they are not limited to pointing to a single method: with += you can "subscribe" more than one, and all of them run, in order, when the delegate is invoked (this is called multicast):

Action<string> notifications = null;
notifications += message => Console.WriteLine($"[Log] {message}");
notifications += message => Console.WriteLine($"[Console] {message}");

notifications("Loan registered");
// [Log] Loan registered
// [Console] Loan registered

-= removes a previously subscribed method. If an Action-typed delegate (with no return value) is null because nobody has subscribed yet, invoking it directly would throw an exception; that's why it's common to check first with ?.Invoke(...), as you'll see in section 7.

  1. Events: event and the publisher/subscriber pattern

A plain public delegate has one drawback: any external code could not only subscribe, but also replace the entire list of subscribers (with = instead of +=), or even invoke it directly from outside, breaking the intent that only the object itself should decide when to "notify." The event keyword solves this: it declares a delegate with stricter rules, designed exactly for the publisher/subscriber pattern.

class Library
{
    public event Action<Loan> LoanRegistered;
}
  • The Library class is the publisher: it's the only one that can invoke the event (LoanRegistered?.Invoke(...)), always from within the class itself.
  • Any other code can be a subscriber: it can add (+=) or remove (-=) a method that reacts to the event, but it cannot invoke it directly or replace the whole list of subscribers with =.
void NotifyLoan(Loan loan)
{
    Console.WriteLine($"'{loan.Book.Title}' has been lent to {loan.Member.Name}");
}

Library library = new Library();
library.LoanRegistered += NotifyLoan; // valid subscription from outside the class

// library.LoanRegistered(loan1);        // Compilation error: an event can't be invoked from outside
// library.LoanRegistered = NotifyLoan;  // Compilation error: can't be replaced with "="

  1. event versus a plain public delegate

public Action<Loan> Field; public event Action<Loan> Event;
Subscribe with += from outside Yes Yes
Replace the whole list with = from outside Yes (risk of accidentally wiping out other subscribers) No, compilation error
Invoke directly from outside the class Yes No, compilation error
Intent communicated when reading the code Ambiguous: looks like an ordinary field Clear: "this is a notification you can subscribe to"

In practice, whenever the purpose is "announce that something happened" (a loan registered, a return, an item added to the catalog), event is the right choice; a public delegate without event is rarely justified outside very specific cases.

  1. Applying events in BiblioTech: LoanRegistered

With what you've learned, here's the first version of the Library class (a minimal version, focused only on the event; in the next lesson, on Collections, it will be expanded with the full catalog of items and members):

class Library
{
    public event Action<Loan> LoanRegistered;

    public void RegisterLoan(Loan loan)
    {
        Console.WriteLine($"Registering loan of '{loan.Book.Title}'...");
        LoanRegistered?.Invoke(loan); // the "?." prevents an exception if nobody has subscribed
    }
}

A typical subscriber, thought of as a small "notification log":

void NotificationLog(Loan loan)
{
    Console.WriteLine($"'{loan.Book.Title}' has been lent to {loan.Member.Name}");
}

And the program that connects both pieces:

Library library = new Library();
library.LoanRegistered += NotificationLog;

Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Member member1 = new Member(1, "Ana Martinez");

book1.Lend();
Loan loan1 = new Loan(book1, member1);
library.RegisterLoan(loan1);

Output:

Registering loan of 'Hopscotch'...
'Hopscotch' has been lent to Ana Martinez

RegisterLoan doesn't know NotificationLog, and knows nothing about it; it simply invokes its event, and whoever has subscribed (one, several, or none) reacts. A second subscriber could be added tomorrow — for example, one that in Module 5 writes the loan to a file — without touching a single line of Library or RegisterLoan.

Common Mistakes and Tips

  • Invoking an event without a null check: if nobody has subscribed yet, LoanRegistered.Invoke(...) (without ?.) throws a NullReferenceException; always use LoanRegistered?.Invoke(...) inside the publishing class.
  • Trying to invoke an event from outside the class: library.LoanRegistered(l) doesn't compile; only the Library class itself can invoke its event. If you need something external to trigger the logic, expose a public method (like RegisterLoan) that internally invokes the event.
  • Forgetting to unsubscribe (-=) when it's no longer needed: if an object subscribes to an event of another, longer-lived object and never unsubscribes, the publisher keeps a reference to the subscriber indefinitely, which can prevent memory from being released (an introduction to this topic; it's revisited in more detail in Module 6, Memory Management and the GC).
  • Confusing the execution order of several subscribers with a strong guarantee: although in practice they run in the order they were subscribed with +=, you shouldn't design logic that critically depends on that order between independent subscribers.
  • Tip: if a method only needs to report a change, with no useful return value expected, event Action<T> (or EventHandler<T> in more traditional .NET APIs) is almost always the right choice.

Exercises

  1. Declare a Func<LibraryItem, bool> delegate called filter that points to a method IsBookAvailable(LibraryItem item) (returns true if the item is a Book and is Available). Invoke it on two different items and show the result.

  2. Create a Library class with an event Action<Loan> LoanRegistered and a method RegisterLoan(Loan loan) that invokes it. Subscribe two different methods to the event (one that shows a confirmation message, another that shows the member's name), and check that both run when a loan is registered.

  3. Add a third subscriber to the previous exercise's solution, then remove it with -= before registering a second loan. Check that, on the second loan, that third subscriber no longer runs.

Solutions

bool IsBookAvailable(LibraryItem item)
{
    return item is Book && item.Available;
}

Func<LibraryItem, bool> filter = IsBookAvailable;

Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Magazine magazine1 = new Magazine("National Geographic", "Various authors", 302);

Console.WriteLine(filter(book1));      // True
Console.WriteLine(filter(magazine1));  // False: not a Book
class Library
{
    public event Action<Loan> LoanRegistered;

    public void RegisterLoan(Loan loan)
    {
        LoanRegistered?.Invoke(loan);
    }
}

void Confirmation(Loan l) => Console.WriteLine($"Loan of '{l.Book.Title}' confirmed.");
void ShowMember(Loan l) => Console.WriteLine($"Member: {l.Member.Name}");

Library library = new Library();
library.LoanRegistered += Confirmation;
library.LoanRegistered += ShowMember;

Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Member member1 = new Member(1, "Ana Martinez");
book1.Lend();

library.RegisterLoan(new Loan(book1, member1));
// Loan of 'Hopscotch' confirmed.
// Member: Ana Martinez
void TemporaryNotice(Loan l) => Console.WriteLine("Temporary test notice");

library.LoanRegistered += TemporaryNotice;
// ... all three subscribers would run on the next loan ...

library.LoanRegistered -= TemporaryNotice; // removed before the second loan

Book book2 = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
Member member2 = new Member(2, "Luis Gomez");
book2.Lend();

library.RegisterLoan(new Loan(book2, member2));
// Only Confirmation and ShowMember run; TemporaryNotice no longer runs.

Conclusion

In this lesson you've learned what a delegate is, how to use the predefined Action, Func, and Predicate, how the same delegate can have several methods subscribed at once (multicast), and how event restricts that capability to the publisher/subscriber pattern, preventing improper invocations or replacements from outside the class. The Library class has been born, with its LoanRegistered event, which in the coming lessons of this module will grow into the central point of BiblioTech's entire catalog.

Delegates and events solve "how to react to something that already happened." The next lesson switches to another fundamental part of modern C#: pattern matching, which will let you express, much more compactly and readably, the type checks (is, as) you already used in the Polymorphism lesson, along with switch expressions and nullable reference types.

© Copyright 2026. All rights reserved