Delegates, Lambdas and Events: Reacting to Payments – C# | Lesson 51
Introduction
In Lesson 50, you wrote m => m.MarksObtained > 95 inside Where dozens of times without
asking what it was. It is a small method, passed to another method as a value. The type that
makes this possible is called a delegate.
Delegates also power events: a way for an object to announce "something happened" to anyone who wants to know. In this lesson, a fee invoice announces each payment, and a receipt printer, a parent notice, and an audit log all react, while the invoice knows none of them.
By the end of this lesson, you will be able to:
- Store methods in delegate variables, using
FuncandAction. - Write lambdas, and explain closures over captured variables.
- Pass behaviour into a method, such as a grading rule.
- Declare, raise, subscribe to, and unsubscribe from an event.
- Choose between events, interfaces, and direct method calls.
Why this matters
Without events, RecordPayment would have to call the receipt printer, the notification
service, and the audit log itself. Every new reaction, such as updating a dashboard, would mean
editing the invoice class, which breaks the Single Responsibility and Open/Closed principles
from Lesson 46.
With an event, the invoice only announces the payment. New reactions subscribe from outside. ASP.NET Core, Windows apps, and many libraries are built on exactly this idea, and LINQ is built on delegates.
The concept
Example: a delegate holds a method
A delegate is a type-safe variable that holds a reference to a method. .NET provides ready-made delegate types, so you rarely declare your own:
| Delegate type | Shape | SMS example |
|---|---|---|
Func<decimal, string> | Takes a decimal, returns a string | A grading rule |
Func<MarkRecord, bool> | Takes a mark, returns true or false | A filter for Where |
Action<string> | Takes a string, returns nothing | Sending a reminder |
EventHandler<T> | Takes a sender and event details | A payment event |
The last type parameter of Func is always the return type. Action has no return value.
Both come in versions with up to sixteen parameters.
Example: lambdas
A lambda is a method written inline, without a name:
| Lambda | Reads as |
|---|---|
p => p >= 81 ? "Excellent" : "Good" | "given p, return ..." (an expression lambda) |
m => m.MarksObtained is null | "given m, is it absent?" |
name => { count++; Console.WriteLine(name); } | A statement lambda with a body in braces |
(sender, e) => ... | Two parameters in brackets |
The compiler works out the parameter types from the delegate it is assigned to. Writing
Func<decimal, string> rule = p => ... tells it that p is a decimal.
Keep parameter names short but meaningful: m for a mark and s for a student are fine in a
one-line lambda. In a longer lambda, write the full word, as you would for any variable.
Example: events
An event is a delegate with a protective wrapper. Outside code may only subscribe with
+= and unsubscribe with -=; only the class that owns the event can raise it.
Think of the school's notice board. Teachers who want news about sports day put their name on the list (subscribe).
When the date is fixed, the sports teacher pins one notice, and everyone on the list hears. Nobody else can pin a fake notice in the sports teacher's name.
Example: your own delegate type
Before Func and Action existed, every delegate type was declared by hand. You will still
meet them in older code and in libraries:
GradeRule rule = p => p >= 33 ? "Pass" : "Re-test";
Console.WriteLine($"{rule(67m)} {rule(23m)}");
delegate string GradeRule(decimal percentage);
It prints Pass Re-test. A named delegate type documents its purpose, such as GradeRule,
but Func<decimal, string> works everywhere without a declaration. Prefer Func and Action
in new code.
Example: what Where really takes
LINQ's Where on a list is declared roughly as
Where<T>(this IEnumerable<T> source, Func<T, bool> predicate). It is a generic extension
method (Lessons 45 and 48) whose second parameter is a delegate.
So marks.Where(m => m.Subject == "Science") passes a small method to Where, which calls it
once per mark. Every LINQ method you used in Lesson 50 works this way.
Apply it to the SMS
Step 1: the event on FeeInvoice
The details travel in a small class that inherits from EventArgs, as .NET convention suggests:
using SchoolApp.Core.Enums;
namespace SchoolApp.Core.Entities;
// Lesson 51: the details sent with FeeInvoice.PaymentRecorded to everyone who listens.
public class PaymentRecordedEventArgs : EventArgs
{
public string InvoiceNumber { get; }
public decimal Amount { get; }
public decimal Balance { get; }
public InvoiceStatus Status { get; }
public PaymentRecordedEventArgs(string invoiceNumber, decimal amount, decimal balance, InvoiceStatus status)
{
InvoiceNumber = invoiceNumber;
Amount = amount;
Balance = balance;
Status = status;
}
}
FeeInvoice declares the event and raises it at the end of RecordPayment:
public event EventHandler<PaymentRecordedEventArgs>? PaymentRecorded;
// at the end of RecordPayment
PaymentRecorded?.Invoke(this, new PaymentRecordedEventArgs(InvoiceNumber, amount, Balance, Status));
?.Invoke raises the event only when someone has subscribed; with no subscribers, the event
is null. The event is raised after the rules pass and the state has changed, so listeners
always see a valid invoice.
Show the full src/SchoolApp.Core/Entities/FeeInvoice.cs
using SchoolApp.Core.Enums;
using SchoolApp.Core.Exceptions;
namespace SchoolApp.Core.Entities;
// Lesson 38: an invoice that protects its own rules (encapsulation).
// Lesson 43: Status is an InvoiceStatus enum instead of a string.
// Lesson 44: every column of the FeeInvoices table, plus the audit columns.
public class FeeInvoice : AuditableEntity
{
private decimal _amountPaid; // a private field: only code inside this class can change it
public int Id { get; set; }
public string InvoiceNumber { get; init; } // init: can be set only while the object is created
public int EnrollmentId { get; set; }
public DateOnly InvoiceDate { get; set; }
public DateOnly DueDate { get; set; }
public decimal TotalAmount { get; } // no setter: fixed by the constructor
public decimal DiscountAmount { get; }
public InvoiceStatus Status { get; private set; } = InvoiceStatus.Unpaid; // only this class can write
public Enrollment? Enrollment { get; set; } // navigation property (Lesson 44)
public FeeInvoice(string invoiceNumber, decimal totalAmount, decimal discountAmount = 0m)
{
if (totalAmount < 0)
{
throw new ArgumentOutOfRangeException(nameof(totalAmount), "The total cannot be negative.");
}
if (discountAmount < 0 || discountAmount > totalAmount)
{
throw new ArgumentOutOfRangeException(nameof(discountAmount), "The discount must be between 0 and the total.");
}
InvoiceNumber = invoiceNumber;
TotalAmount = totalAmount;
DiscountAmount = discountAmount;
}
public decimal AmountPaid => _amountPaid;
public decimal Balance => TotalAmount - DiscountAmount - _amountPaid;
// Lesson 51: an event. Other code subscribes with +=; the invoice raises it after each payment,
// without knowing who is listening.
public event EventHandler<PaymentRecordedEventArgs>? PaymentRecorded;
// The only way to pay. The same rules and error numbers as usp_RecordFeePayment (Lesson 22).
public void RecordPayment(decimal amount)
{
if (Status == InvoiceStatus.Cancelled)
{
throw new SchoolRuleException(50011, "Invoice is cancelled.");
}
if (amount <= 0)
{
throw new SchoolRuleException(50012, "Amount must be more than zero.");
}
if (amount > Balance)
{
throw new SchoolRuleException(50013, "Amount is more than the balance due.");
}
_amountPaid += amount;
Status = Balance == 0 ? InvoiceStatus.Paid : InvoiceStatus.PartiallyPaid;
// ?.Invoke: raise the event only if someone has subscribed.
PaymentRecorded?.Invoke(this, new PaymentRecordedEventArgs(InvoiceNumber, amount, Balance, Status));
}
// An invoice with payments on it cannot be cancelled; the payments must be refunded first.
public void Cancel()
{
if (_amountPaid > 0)
{
throw new InvalidOperationException("An invoice with payments cannot be cancelled.");
}
Status = InvoiceStatus.Cancelled;
}
}
Step 2: the program
Show src/SchoolApp.Console/Program.cs
// Lesson 51: delegates, lambdas, and events.
using SchoolApp.Core.Entities;
using SchoolApp.Core.Rules;
// 1. A delegate is a variable that holds a method. Func<decimal, string>: takes a decimal, returns a string.
Func<decimal, string> secondaryGrade = SchoolRules.GetGrade; // a method group
Func<decimal, string> primaryBand = p => p >= 81 ? "Excellent" : p >= 51 ? "Good" : "Needs support"; // a lambda
PrintGrades("Secondary scale", secondaryGrade);
PrintGrades("Primary scale", primaryBand);
// 2. Predicates: LINQ's Where takes a Func<T, bool>, so a condition can be stored and reused.
Func<MarkRecord, bool> isMathsUt1 = m => m.Exam == "Unit Test 1" && m.Subject == "Mathematics";
Func<MarkRecord, bool> isAbsent = m => m.MarksObtained is null;
List<MarkRecord> marks = DataFiles.ReadMarks().ToList();
Console.WriteLine($"Mathematics UT1: {marks.Count(isMathsUt1)}; absent in it: {marks.Where(isMathsUt1).Count(isAbsent)}");
// 3. A lambda can capture a variable from outside: a closure.
int remindersSent = 0;
Action<string> sendReminder = name => { remindersSent++; Console.WriteLine($" Reminder to {name}"); };
sendReminder("Suresh Patel");
sendReminder("Harpreet Singh");
Console.WriteLine($"Reminders sent: {remindersSent}");
// 4. Events: three independent listeners react to one payment.
FeeInvoice invoice = new FeeInvoice("INV-2026-000002", 45500m);
invoice.PaymentRecorded += PrintReceipt;
invoice.PaymentRecorded += (sender, e) => Console.WriteLine($" Notice: {e.InvoiceNumber} is now {e.Status}");
invoice.PaymentRecorded += (sender, e) => Console.WriteLine($" Audit: {e.Amount} recorded");
invoice.RecordPayment(20000m);
// 5. Unsubscribe with -=: the receipt printer stops listening.
invoice.PaymentRecorded -= PrintReceipt;
invoice.RecordPayment(25500m);
static void PrintGrades(string title, Func<decimal, string> rule)
{
Console.WriteLine($"{title}: 95 -> {rule(95m)}, 67 -> {rule(67m)}, 23 -> {rule(23m)}");
}
static void PrintReceipt(object? sender, PaymentRecordedEventArgs e)
{
Console.WriteLine($" Receipt: {e.Amount} paid on {e.InvoiceNumber}, balance {e.Balance}");
}
Secondary scale: 95 -> A1, 67 -> B2, 23 -> E
Primary scale: 95 -> Excellent, 67 -> Good, 23 -> Needs support
Mathematics UT1: 1006; absent in it: 20
Reminder to Suresh Patel
Reminder to Harpreet Singh
Reminders sent: 2
Receipt: 20000 paid on INV-2026-000002, balance 25500
Notice: INV-2026-000002 is now PartiallyPaid
Audit: 20000 recorded
Notice: INV-2026-000002 is now Paid
Audit: 25500 recorded
Example: passing behaviour into a method
PrintGrades takes a Func<decimal, string> and calls it three times. Given
SchoolRules.GetGrade from Lesson 42, it prints A1, B2, and E. Given the primary-school
lambda, it prints Excellent, Good, and Needs support.
The method never changes; only the rule passed in does. That is the Strategy pattern, which Lesson 56 names formally, done with one delegate instead of a class hierarchy.
Func<decimal, string> secondaryGrade = SchoolRules.GetGrade; uses a method group: the
method's name without brackets. GetGrade has two overloads, and the compiler picks the one
whose parameters fit the delegate: the decimal version, not decimal?.
Example: stored conditions
isMathsUt1 and isAbsent are filters kept in variables and reused: marks.Count(isMathsUt1)
counts 1,006 Mathematics entries, and Count(isAbsent) on those finds the same 20 absences as
Lesson 50.
LINQ's Where, Count, and OrderBy all accept such delegates. Every lambda you wrote in
Lesson 50 was converted to a Func like these.
Example: closures
The sendReminder lambda changes remindersSent, a variable declared outside it. After two
calls, the variable holds 2. The lambda captured the variable itself, not a copy of
its value; this is called a closure.
Closures are powerful, and also a source of surprises. A lambda that captures a variable can keep it, and everything it refers to, alive for as long as the lambda exists. Lesson 55 shows how that causes memory leaks.
Example: three listeners, one event
The first payment of 20,000 reached all three listeners: the receipt, the notice
("PartiallyPaid"), and the audit line. Each listener was added with +=, and they run in
the order they were added.
After invoice.PaymentRecorded -= PrintReceipt, the second payment of 25,500 reached only the
notice and the audit. Unsubscribing needs the same method; that is why the receipt printer is
a named method, not a lambda, which could not be referred to again.
Example: why an event and not a public delegate
The event keyword limits what outside code can do. Trying to wipe out every listener with
invoice.PaymentRecorded = null; fails with error CS0079: The event 'FeeInvoice.PaymentRecorded' can only appear on the left hand side of += or -=.
With a plain public delegate field, any code could replace all listeners or raise the event itself, announcing a payment that never happened. The event keyword is encapsulation (Lesson 38) for notifications.
Example: events, interfaces, or a direct call?
| Situation | Best tool |
|---|---|
| Exactly one collaborator that must do its job | A direct call, or an interface passed to the constructor (Lesson 46) |
| Several optional listeners that may come and go | An event |
| A rule or formula the caller chooses | A Func parameter |
| A whole family of behaviour with several methods | An interface |
Events suit "something happened, whoever cares may react". If a listener's failure must stop
the payment, an event is the wrong tool: an exception in one handler stops the handlers after
it, and the caller of RecordPayment receives it.
Common mistakes
- Calling an event without
?.. With no subscribers, it throwsNullReferenceException. Fix:Event?.Invoke(...). - Subscribing a lambda and trying to unsubscribe it. A new lambda is a different delegate. Fix: keep a reference, or use a named method.
- Forgetting to unsubscribe from long-lived objects. Listeners stay alive. Fix:
-=when done (Lesson 55). - A delegate with the wrong shape.
Func<decimal, string> rule = SchoolRules.HasPassed;fails witherror CS0407: ... has the wrong return type. Fix: match the parameters and return type. - Raising an event before the state is valid. Listeners see half-updated data. Fix: raise it last, after all changes.
In real projects
In one school system, recording a payment directly called six other features: receipts, text messages, the dashboard, the accounts export, the audit log, and a parent app notification.
Every new feature added a line to the payment method, and a bug in any of them broke payments. We changed it to raise one "payment recorded" event, and each feature subscribed itself.
In the Web API, the same idea grows into messaging and background workers (Module 12). The principle stays: announce what happened, and let interested parts react.
Best practices
- Use
FuncandActioninstead of declaring your own delegate types. - Keep lambdas short; move long logic into named methods.
- Raise events with
?.Invoke, after the object is in a valid state. - Use named methods for handlers you will need to remove.
- Use events for optional listeners, and interfaces for required collaborators.
Recap
- A delegate holds a reference to a method;
Funcreturns a value,Actiondoes not. - A lambda is an inline method; LINQ converts every lambda into a delegate.
- A closure captures outside variables themselves, not copies.
- An event lets outside code only subscribe (
+=) and unsubscribe (-=). FeeInvoice.PaymentRecordednotifies any number of listeners without knowing them.- Choose events for "whoever cares may react", interfaces for required collaborators.
Homework
Library module task: Give a BookCopy class a Returned event. When a copy is returned,
one listener issues it to the next student in a waiting queue, and another listener writes a
log line. Advika returns The Jungle Book while Kabir and Meera are waiting.
A correct result looks like:
Advika Sharma returns The Jungle Book
Issued The Jungle Book to Kabir Singh
Log: The Jungle Book returned
Kabir Singh returns The Jungle Book
Issued The Jungle Book to Meera Iyer
Log: The Jungle Book returned
Meera Iyer returns The Jungle Book
Log: The Jungle Book returned
Now with: nobody; still waiting: 0
Show the solution
// Library homework, Lesson 51: when a copy is returned, an event gives it to the next student waiting.
// Run with: dotnet run library-assignment/051-library-events.cs
Queue<string> waitingList = new Queue<string>();
waitingList.Enqueue("Kabir Singh");
waitingList.Enqueue("Meera Iyer");
BookCopy jungleBook = new BookCopy("The Jungle Book", 1, "Advika Sharma");
// Listener 1: hand the copy to the next student in the queue.
jungleBook.Returned += (sender, title) =>
{
if (sender is BookCopy copy && waitingList.Count > 0)
{
copy.IssueTo(waitingList.Dequeue());
}
};
// Listener 2: a log line, which knows nothing about the queue.
jungleBook.Returned += (sender, title) => Console.WriteLine($" Log: {title} returned");
jungleBook.Return();
jungleBook.Return();
jungleBook.Return();
Console.WriteLine($"Now with: {jungleBook.Borrower ?? "nobody"}; still waiting: {waitingList.Count}");
class BookCopy
{
public string Title { get; }
public int CopyNumber { get; }
public string? Borrower { get; private set; }
public event EventHandler<string>? Returned;
public BookCopy(string title, int copyNumber, string borrower)
{
Title = title;
CopyNumber = copyNumber;
Borrower = borrower;
}
public void IssueTo(string student)
{
Borrower = student;
Console.WriteLine($" Issued {Title} to {student}");
}
public void Return()
{
Console.WriteLine($"{Borrower} returns {Title}");
Borrower = null;
Returned?.Invoke(this, Title);
}
}
The first listener captures waitingList from outside: a closure. BookCopy knows nothing
about queues or logs; it only announces that it was returned.
Quiz
What does Func<decimal, string> describe?
Show answer
Answer: B. A method that takes a decimal and returns a string The last type parameter of Func is the return type.
What is a closure?
Show answer
Answer: B. A lambda that captures variables from outside itself sendReminder changed remindersSent, declared outside the lambda.
From outside FeeInvoice, which is allowed on PaymentRecorded?
Show answer
Answer: C. PaymentRecorded += handler Outside code can only subscribe and unsubscribe (CS0079 otherwise).
Why raise the event with PaymentRecorded?.Invoke(...)?
Show answer
Answer: B. With no subscribers the event is null, and ?. avoids a NullReferenceException ?. raises it only when someone has subscribed.
Why is the receipt printer a named method instead of a lambda?
Show answer
Answer: B. So it can be removed later with -= Unsubscribing needs a reference to the same delegate.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Advanced C# interview questions.
Fresher (0-2 yrs)
F What is a delegate? concept
A type-safe reference to a method, stored in a variable and passed around like a value. Func,
Action, and EventHandler<T> are built-in delegate types; LINQ's Where takes a delegate.
F What is the difference between Func and Action? concept
Func returns a value; its last type parameter is the return type, as in Func<decimal, string>.
Action returns nothing, as in Action<string>.
F What is a lambda expression? concept
An inline, unnamed method, such as m => m.MarksObtained is null. It is converted into a delegate
(or an expression tree for IQueryable), with parameter types inferred from where it is used.
Mid (3-5 yrs)
M What is an event, and how does it differ from a public delegate field? concept
An event is a delegate wrapped so that outside code can only subscribe (+=) and unsubscribe
(-=); only the owning class can raise it. A public delegate field could be overwritten or invoked
by anyone, faking notifications (CS0079 prevents this for events).
M What is a closure? concept
A lambda that captures variables from its surrounding scope. It captures the variable itself, not a copy, so changes are shared and the captured objects stay alive as long as the lambda does.
M Why raise an event with Event?.Invoke(...), and why can't you unsubscribe an inline lambda? coding
With no subscribers the event is null, so ?. avoids a NullReferenceException. Each inline lambda
creates a new delegate, so -= with another identical-looking lambda removes nothing; keep a
reference or use a named method.
Senior (6-9 yrs)
S Recording a payment calls six features directly, and a bug in any one breaks payments. How would you redesign it? scenario
Have the payment raise one "payment recorded" event, or publish a message, after the payment is saved, and let each feature subscribe independently. Failures in optional reactions should not undo or block the payment; isolate them and log errors.
How to answer: Connect to Single Responsibility and Open/Closed, discuss error isolation, and mention that across services this becomes messaging with background processing.
S When would you use an event, an interface, or a Func parameter? design
An event for optional listeners that come and go; an interface for a required collaborator with
several methods, injected through the constructor; a Func parameter for a single rule or formula
chosen by the caller.
How to answer: Give one SMS example for each and the trade-offs: discoverability, testability, and how failures propagate.
Architect / Lead (10+ yrs)
A How do in-process events relate to messaging in a distributed system? design
Both announce that something happened and let others react. In-process events run synchronously in the same program and are lost if it crashes. Messaging (queues, an outbox) is durable, crosses services, retries on failure, and runs asynchronously, at the cost of eventual consistency.
How to answer: Compare reliability, coupling, and timing, then explain when to promote an event to a message, for example a payment notification that must not be lost.
Resources
- Code for this lesson: git tag
lesson-51-delegates-events(code repository coming soon) - Files:
src/SchoolApp.Core/Entities/PaymentRecordedEventArgs.cs,src/SchoolApp.Core/Entities/FeeInvoice.cs,src/SchoolApp.Console/Program.cs,library-assignment/051-library-events.cs - Video: coming soon
What's next
C# keeps gaining features that make code shorter and safer, such as list patterns, primary
constructors, collection expressions, and the new field keyword. Next: the modern C# you
will see in every new codebase. Lesson 52: Modern C# features
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.