Part 4 of 4: Emails, audit trails, one event handler that catches dozens of event types it's never heard of, transaction semantics, and where this fits among MediatR, Channels, and message brokers
Across this series, a strategy has gone from "one branch in a 50-case switch statement" to "a small, independently testable class, resolved without a switch statement (Part 2), and permission-checked without inline if blocks (Part 3)." By the time ApproveLeaveRequestWorkflowAction.ExecuteAsync finishes, it's done its actual job: validated the request, updated the record, committed the transaction, returned Result<Unit>.Success(Unit.Value).
There was one line in that method, all the way back in Part 1, that got a passing mention and nothing more:
await _eventPublisher.Publish(
new LeaveRequestDecisionEvent(authenticatedUser.Id, leaveRequest.Id, request.IsApproved),
token);
This article is about that line - and about everything that's allowed to happen because of it, without the strategy knowing any of it is going on.
The Problem: Approval Isn't Really "Done" When the Strategy Returns
In any real approval system, "the request was approved" is rarely the end of the story. Someone probably wants an email. Compliance almost certainly wants an audit trail - who approved what, when, from where. Maybe a dashboard needs a cache invalidated. Maybe a downstream system needs a webhook fired.
The tempting place to put all of that is right inside the strategy, after the commit:
await _context.SaveChangesAsync(token);
await transaction.CommitAsync(token);
// Now bolt on everything else that needs to happen...
await _emailHelper.SendLeaveDecisionEmail(leaveRequest, request.IsApproved);
await _auditLogger.LogApproval(authenticatedUser, pendingRequest);
await _cache.InvalidateAsync($"leave-requests:{leaveRequest.EmployeeId}");
// ...and whatever gets added next month
This is the same mistake Part 3 fixed for permission checks, showing up again in a new spot. ApproveLeaveRequestWorkflowAction was supposed to answer one question - what does it mean to approve a leave request? - and now it also has to know about email templates, audit log schemas, and cache key formats. Multiply that across 50+ strategies, and every one of them needs to remember to bolt on the same email/audit/cache calls, with no compiler-enforced way to guarantee nobody forgets.
There's also a subtler problem: the list of things that should happen after an approval is going to grow over time. A switch statement was a wall you'd hit when adding new approval types; this is the same wall, but for "things that should happen when any approval succeeds."
The Fix: Announce What Happened, Don't Decide What Happens Next
The Observer pattern (often implemented as a publish/subscribe mechanism) inverts the relationship. Instead of the strategy calling out to email, audit, and cache code directly, it publishes a description of what happened - an event - and stops. Anything that cares about that event can subscribe to it, with zero coupling back to the strategy that raised it.
The strategy's responsibility shrinks to one line: announce the event. It has no idea how many handlers are listening - zero, one, or ten - and it doesn't need to. Adding a new side effect later means writing a new handler, not editing the strategy.
The Event: A Plain Description of What Happened
An event is deliberately uninteresting - just data describing something that already occurred:
/// <summary>
/// Raised when a maker (HR admin) approves or declines a leave request.
/// </summary>
public sealed class LeaveRequestDecisionEvent : BaseHrAuditEvent
{
public int EmployeeId { get; }
public string EmployeeName { get; }
public bool IsApproved { get; }
public LeaveRequestDecisionEvent(
int makerId,
string makerUserName,
int employeeId,
string employeeName,
bool isApproved,
DateTime? timestamp = null)
: base(makerId, makerUserName, timestamp)
{
EmployeeId = employeeId;
EmployeeName = employeeName;
IsApproved = isApproved;
}
public override string Description =>
$"{MakerUserName} {(IsApproved ? "approved" : "declined")} the leave request for {EmployeeName}";
public override AuditActivityEnum ActionType =>
IsApproved ? AuditActivityEnum.ApproveLeaveRequest : AuditActivityEnum.DeclineLeaveRequest;
public override HrTargetType TargetType => HrTargetType.LeaveRequest;
}
Note that it inherits from BaseHrAuditEvent rather than standing alone - that base class is the piece that makes the rest of this article work, so it's worth looking at directly:
/// <summary>
/// Base class for all HR audit events. Contains the metadata every audit
/// entry needs, regardless of what specific action occurred. Immutable.
/// </summary>
public abstract class BaseHrAuditEvent
{
public int MakerId { get; }
public string MakerUserName { get; }
public DateTime Timestamp { get; }
/// <summary>Human-readable description. Every derived event must supply one.</summary>
public abstract string Description { get; }
/// <summary>The category of action performed (Approve, Decline, Create, ...).</summary>
public abstract AuditActivityEnum ActionType { get; }
/// <summary>The kind of entity this event concerns (LeaveRequest, RoleChange, ...).</summary>
public abstract HrTargetType TargetType { get; }
protected BaseHrAuditEvent(int makerId, string makerUserName, DateTime? timestamp = null)
{
MakerId = makerId;
MakerUserName = makerUserName ?? throw new ArgumentNullException(nameof(makerUserName));
Timestamp = timestamp ?? DateTime.UtcNow;
}
}
Every specific event - LeaveRequestDecisionEvent, RoleChangeDecisionEvent, EmployeeOnboardingDecisionEvent, and 47 more - inherits from this same base. Each one knows how to describe itself (Description), classify itself (ActionType, TargetType), and carries whatever extra data is specific to it. None of that is the interesting part yet. The interesting part is what's about to subscribe to it.
Two Handlers, Two Very Different Subscriptions
Here's where this gets genuinely useful: you can write a handler that subscribes to one specific event type, and a handler that subscribes to the base event type - and the second one will automatically receive every event derived from that base, including ones that don't exist yet.
Handler #1 - specific, narrow, knows exactly what it's for:
/// <summary>
/// Sends a notification email when a leave request decision is made.
/// Subscribes only to LeaveRequestDecisionEvent - nothing else.
/// </summary>
public class LeaveRequestDecisionEmailHandler : IEventHandler<LeaveRequestDecisionEvent>
{
private readonly ITemplateEmailNotificationHelper _emailHelper;
private readonly HrEmailConfigs _emailConfigs;
private readonly ILogger<LeaveRequestDecisionEmailHandler> _logger;
public LeaveRequestDecisionEmailHandler(
ITemplateEmailNotificationHelper emailHelper,
IOptions<HrEmailConfigs> emailConfigs,
ILogger<LeaveRequestDecisionEmailHandler> logger)
{
_emailHelper = emailHelper;
_emailConfigs = emailConfigs.Value;
_logger = logger;
}
public async Task Handle(
LeaveRequestDecisionEvent @event,
HrContext context,
CancellationToken cancellationToken)
{
var action = @event.IsApproved ? "Approved" : "Declined";
var subject = $"{action} Leave Request: {@event.EmployeeName}";
var keyValuePairs = new Dictionary<string, string>
{
{ "EmployeeName", @event.EmployeeName },
{ "Decision", action },
{ "ApprovedBy", @event.MakerUserName }
};
var emailRequest = new EmailModel
{
MailSubject = subject,
MailTo = _emailConfigs.LeaveRequestNotificationAddress
};
await _emailHelper.SendMail(
emailRequest,
relativeTemplatePath: _emailConfigs.LeaveDecisionTemplate,
keyValuePairDictionary: keyValuePairs,
cancellationToken);
_logger.LogInformation("Sent leave decision email for {Employee}", @event.EmployeeName);
}
}
Nothing surprising here - it does one specific thing for one specific event, and would never be asked to handle RoleChangeDecisionEvent or anything else.
Handler #2 - generic, subscribes to the base type, handles everything:
/// <summary>
/// Writes an audit trail entry for ANY HR audit event. Subscribes to
/// BaseHrAuditEvent - every derived event type is routed here automatically,
/// with no changes needed when a new event type is added.
/// </summary>
public class HrAuditEventHandler : IEventHandler<BaseHrAuditEvent>
{
private readonly HrContext _context;
private readonly ILogger<HrAuditEventHandler> _logger;
public HrAuditEventHandler(HrContext context, ILogger<HrAuditEventHandler> logger)
{
_context = context;
_logger = logger;
}
public async Task Handle(
BaseHrAuditEvent @event,
HrContext context,
CancellationToken cancellationToken)
{
var auditTrail = new HrAuditTrail
{
MakerId = @event.MakerId,
ActionType = @event.ActionType,
TargetType = @event.TargetType,
Timestamp = @event.Timestamp,
Description = @event.Description
};
await _context.HrAuditTrails.AddAsync(auditTrail, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Audit record saved for Maker={MakerId}: {Description}",
@event.MakerId, @event.Description);
}
}
This handler has never heard of LeaveRequestDecisionEvent. It doesn't need to. It was written once, against the base type, and it will run for every one of the 50+ derived event types this series keeps referring to - including ones written after this handler was deployed.
How: The Publisher Walks Up the Inheritance Chain
This only works because of how the publisher resolves handlers. A naive pub/sub implementation would look for handlers registered for the exact runtime type of the event and stop there - which would mean HrAuditEventHandler, registered against BaseHrAuditEvent, would never be found for a LeaveRequestDecisionEvent instance. The publisher has to deliberately walk up the type hierarchy:
private IEnumerable<Type> ResolveHandlerTypes<TEvent>(TEvent @event) where TEvent : class
{
var eventType = @event.GetType();
var handlerTypes = new List<Type>();
while (eventType != null && eventType != typeof(object))
{
var handlerInterfaceType = typeof(IEventHandler<>).MakeGenericType(eventType);
var resolvedHandlers = _serviceProvider.GetServices(handlerInterfaceType);
handlerTypes.AddRange(resolvedHandlers.Select(h => h.GetType()));
eventType = eventType.BaseType;
}
return handlerTypes.Distinct();
}
Walking through what happens for a LeaveRequestDecisionEvent:
The loop climbs the inheritance chain one level at a time - LeaveRequestDecisionEvent → BaseHrAuditEvent → object - checking at each level whether any handler is registered for that exact type. LeaveRequestDecisionEmailHandler is registered for the concrete type and only fires for that one event. HrAuditEventHandler is registered for the base type and fires for every single event that derives from it, because every one of them passes through BaseHrAuditEvent on the way up.
This is the entire trick. No event router needs a giant mapping of "which events go to which handlers." The relationship is implicit in the class hierarchy: derive from BaseHrAuditEvent, and the audit handler picks you up automatically, forever, without anyone updating a list.
Registering Both Kinds of Handler
The DI registration mirrors the distinction. Specific handlers and generic base-type handlers can even be scanned and registered in separate passes, which is useful if they need different lifetimes or have different startup costs:
// Handlers for the base audit event - these fire for every derived event type
services.Scan(scan => scan
.FromAssemblyOf<BaseHrAuditEvent>()
.AddClasses(classes => classes.AssignableTo(typeof(IEventHandler<>))
.Where(t => t.GetInterfaces()
.Any(i => i.IsGenericType &&
typeof(BaseHrAuditEvent).IsAssignableFrom(i.GenericTypeArguments[0]) &&
i.GenericTypeArguments[0] == typeof(BaseHrAuditEvent))))
.AsSelf()
.AsImplementedInterfaces()
.WithScopedLifetime());
// Handlers for specific derived events - these fire for exactly one event type
services.Scan(scan => scan
.FromAssemblyOf<BaseHrAuditEvent>()
.AddClasses(classes => classes.AssignableTo(typeof(IEventHandler<>))
.Where(t => t.GetInterfaces()
.Any(i => i.IsGenericType &&
typeof(BaseHrAuditEvent).IsAssignableFrom(i.GenericTypeArguments[0]) &&
i.GenericTypeArguments[0] != typeof(BaseHrAuditEvent))))
.AsSelf()
.AsImplementedInterfaces()
.WithScopedLifetime());
Worth being precise about what that second block's != typeof(BaseHrAuditEvent) check is actually selecting: not "events unrelated to auditing," but specifically handlers for the concrete derived types - LeaveRequestDecisionEmailHandler, RoleChangeDecisionEmailHandler, and so on - as opposed to the one handler registered against BaseHrAuditEvent itself. The two scans are splitting "the one handler that catches everything via the base type" from "the many handlers that each care about exactly one derived event," which is the same email-handler-vs-audit-handler split this article has been drawing all along, just expressed as a filter at registration time instead of left for the publisher to sort out at runtime. Separating them like this is mostly useful if the two groups genuinely need different treatment - different lifetimes, different startup wiring, or just keeping the registration code readable instead of one giant unfiltered scan that bundles every handler together regardless of what it subscribes to.
Both LeaveRequestDecisionEmailHandler and HrAuditEventHandler get picked up by this scan, in the same way Part 2's strategies were picked up without anyone naming them individually. Adding RoleChangeDecisionEmailHandler next month requires writing the class - nothing here changes.
The Full Picture
Put together, here's everything that happens after ApproveLeaveRequestWorkflowAction commits its transaction:
The strategy publishes one event and is done. It has no reference to LeaveRequestDecisionEmailHandler or HrAuditEventHandler - it couldn't even if it wanted to, since the publisher resolves handlers from the DI container at publish time, not from anything the strategy provides.
A Decision That's Easy to Miss: Where Does Publish Sit Relative to Commit?
Every code sample in this series so far has published the event after committing the transaction:
await _context.SaveChangesAsync(token);
await transaction.CommitAsync(token);
await _eventPublisher.Publish(new LeaveRequestDecisionEvent(...), token);
This placement isn't an accident, and it isn't the only valid choice - it's a business decision with real consequences, and it's worth making deliberately rather than by default.
Publish after commit means the database change is already permanent by the time any handler runs. If LeaveRequestDecisionEmailHandler throws because the email provider is down, that's unfortunate, but the leave request is still approved - the failure can't unwind anything, because there's nothing left to unwind. This is usually the right call for side effects that are genuinely secondary: a failed email shouldn't be allowed to undo an otherwise-valid approval. You'd typically want the publisher to catch and log handler exceptions in this mode rather than let them propagate, since by this point a thrown exception can't fix the data, it can only make the strategy's caller think something failed when the approval itself actually succeeded.
Publish before commit, inside the same transaction, is the other valid shape:
await _context.SaveChangesAsync(token);
// Publish while the transaction is still open
await _eventPublisher.Publish(new LeaveRequestDecisionEvent(...), token);
await transaction.CommitAsync(token);
Here, if a handler throws, the exception propagates back up through Publish, the catch block in the strategy catches it, and transaction.RollbackAsync(token) actually means something - the leave request approval and the failed side effect both get undone together. This is the right shape when a handler represents a condition that should be able to veto the entire operation. A concrete example: if HrAuditEventHandler failing to write an audit row is treated as unacceptable for compliance reasons - "we will not approve anything we can't also prove we approved" - then publishing before commit, and letting that handler's exception bubble up and roll back the transaction, is the correct design, not a bug.
A few more scenarios where this isn't just a compliance preference but closer to a correctness requirement:
- Password reset. If completing a password reset publishes an event that also has to, say, invalidate every existing session token or notify a fraud-detection system, and that downstream step fails, you generally don't want the password considered "reset" while the old sessions are still silently valid - that's a security gap, not a minor inconvenience. Publishing before commit means a failure there rolls back the reset itself, so the user is left in a consistent state (still their old password, free to retry) rather than a half-applied one (new password active, but the security side effect silently never happened).
- Financial postings. If approving a transaction also has to update a ledger balance or trigger a downstream reconciliation entry, and that update fails, committing the original transaction anyway can leave two systems disagreeing about money - exactly the kind of inconsistency that's expensive to detect and reconcile later.
- Provisioning that depends on the side effect. If approving an onboarding request publishes an event that provisions a system account, and provisioning fails, you probably don't want the request marked "approved" while the employee has no account - better to roll back and let the maker retry the whole approval cleanly.
The common thread: publish-before-commit earns its complexity when the side effect isn't really secondary at all - it's a precondition the business considers part of "this operation actually succeeded," even though it's implemented as a separate handler for the sake of keeping the strategy's own code simple. The moment a failed handler would leave the system in a state someone would call "broken" rather than merely "incomplete," that's the signal to publish before commit instead of after.
Neither ordering is universally "more correct" - it depends on whether a given side effect is something the business considers optional-but-nice or non-negotiable. Pick one on purpose, per use case, rather than discovering the actual behavior the first time a handler fails in production. These can also be mixed: some events published before commit, where a veto matters, others fire-and-forget after commit, where it doesn't - there's no rule that every event in a system has to follow the same ordering.
Where This Sits Among Other Tools
Everything in this article - IEventPublisher, LocalEventPublisher, the handlers - is a deliberately small, in-process implementation: no message broker, no network hop, just C# resolving and invoking handlers from the same DI container the strategy itself is running in. That's a legitimate choice for this scale, and the alternatives below are what you'd reach for if the requirements changed.
In-process / synchronous, similar to what's shown here:
-
MediatR - by far the most common choice in .NET for exactly this in-process publish/subscribe shape. Its
INotification+INotificationHandler<T>pair is functionally whatBaseHrAuditEvent+IEventHandler<T>are doing here, with the wiring already built and battle-tested. Worth knowing: MediatR's built-in notification publishing dispatches to handlers registered for the exact type - the "walk up the inheritance chain to find a handler for the base type" trick this article leans on isn't out of the box; you'd still write that resolution logic yourself, or use a custom publisher. -
.NET's own
event/EventHandlerdelegates - the simplest possible option, with no DI scanning, no attributes, nothing. Reasonable for a small number of subscribers known at compile time; doesn't scale well to "dozens of handlers, dynamically discovered" the way this series needed.
Queue-based, for when the publisher shouldn't wait around:
-
Channels (
System.Threading.Channels) - an in-process, in-memory queue. The strategy publishes by writing to aChannel<TEvent>and returns immediately; a separate background consumer (e.g. anIHostedServicerunning awhile (await reader.WaitToReadAsync())loop) drains the channel and dispatches to handlers on its own schedule. This is the natural next step if the synchronousawait _eventPublisher.Publish(...)call starts being a latency problem - for example, if the email handler is slow and you don't want the approval request to wait for it. The tradeoff: events live only in memory, so a crash between "written to the channel" and "consumed" loses that event. Fine for things like cache invalidation; risky for anything that absolutely must happen, like the audit trail. - RabbitMQ / Azure Service Bus / Amazon SQS - message brokers, for when events need to survive a process restart, be consumed by an entirely different service or codebase, or be retried automatically on failure. This is the right tool once "side effect" stops meaning "another class in the same process" and starts meaning "a different microservice needs to know this happened." It's also the natural evolution if your audit handler, email handler, or any other consumer needs guaranteed delivery - at-least-once semantics, dead-letter queues, retry backoff - none of which an in-memory publisher gives you for free.
- Kafka - similar territory to the message brokers above, but suited to a different problem: very high event throughput, and consumers that care about replaying history or processing the same stream independently at their own pace, rather than each event being consumed once and discarded.
This series uses an in-process publisher because, at the scale of "a handful of handlers reacting to an approval decision within the same request," a queue or broker solves a performance and durability problem this system doesn't have - at the cost of real operational complexity (a broker to run, network calls that can fail, message serialization, consumer lag to monitor). The Observer pattern itself - strategy publishes, decoupled handlers react - is the actual idea. Whether it's implemented with a plain C# event, MediatR, a Channel, or a full message broker is a separate decision, driven by actual durability and latency requirements, not by default.
Why This Was Worth Four Articles
Step back across the whole series, and the same shape keeps repeating: take something that was one giant, tangled method, and split it along its actual seams - into pieces that can be written, tested, and changed independently, with the connections between them expressed as declarative metadata (attributes) rather than imperative logic (switches and inline if checks).
- Part 1 split "what approval logic runs" away from "how a request gets routed."
- Part 2 split "which strategy applies" away from "a central method that has to know about every strategy."
- Part 3 split "is this allowed" away from "what does this action do."
- Part 4 splits "this happened" away from "everything that should occur as a consequence."
None of these splits are free - there's more files, more indirection, more concepts to hold in your head than a single switch statement would require. That trade only pays for itself at scale, which is why a 50+ strategy production system is the right example for this series rather than a 3-case tutorial. Below a certain size, a switch statement is fine. Above it, these four patterns are what keep the system from collapsing under its own weight as approval type #51, #52, and #80 get added by however many developers end up touching this code over its lifetime.
Part 1 mentioned that the real system this series is based on reuses this exact same IHrApprovalStrategy / factory / Context machinery for a second, unrelated maker-checker relationship - privileged users managing other privileged users, with its own Super Initiator / Super Authorizer pair, resolved against a different TargetType on the same shared request table. That reuse is the actual test of whether the architecture is general, now that all four pieces are in view: nothing in the strategy interface, the attribute-based factory, the permission decorator, or the event-publishing mechanism had to know or care that a request was about managing an admin account rather than approving leave. Every one of these four patterns solves "route and execute a maker-checker decision," not "handle HR requests specifically" - and it transferred to a structurally similar but semantically unrelated problem without any changes to the shared infrastructure.
None of this - the order of Publish relative to Commit, or the choice between an in-process publisher, MediatR, a Channel, or a message broker - has a universally correct answer. They're implementation decisions downstream of a business question someone has to actually answer: should this side effect failing be able to undo the approval, or not? Get that answer first; the pattern and the tooling follow from it.
Further Reading
- Observer pattern - Refactoring Guru - the canonical explanation of the publish/subscribe relationship this article is built on
-
MediatR - GitHub (LuckyPennySoftware/MediatR) - the most common in-process publish/subscribe library for .NET, mentioned in the tooling-landscape section above; see its wiki for
INotification/INotificationHandler<T>usage - System.Threading.Channels - .NET, Microsoft Learn - official docs for the in-memory producer/consumer queue discussed as an alternative to a synchronous publisher; see also the original .NET Blog introduction
- Handling Concurrency Conflicts - EF Core, Microsoft Learn - relevant background for the transaction/commit mechanics discussed in the publish-before-commit-vs-after-commit section
This concludes the 4-part series. Thanks for reading.















