EdBoard was designed as a comprehensive tool for K-8 educators, providing an integrated system for tracking attendance, grades, behavior, hall passes, and more. By aggregating and analyzing this data in real-time, EdBoard helped teachers spend less time on administrative tasks and more time on teaching.
However, tracking data is only part of the equation. Communicating this information to relevant stakeholders, like parents, teachers, and administrators, is critical for effective decision-making. The challenge was to create a system that would deliver notifications near real-time, using the most appropriate communication channel—email, SMS, voice, push notifications, or web alerts—based on the situation. This communication system had to handle a wide range of notifications, from attendance alerts to progress reports, while being scalable and easy to modify as new channels or notification types were added. This is where the Strategy Pattern, in combination with the Factory Pattern, became key.
In this blog post, I’ll share my experience building EdBoard’s notification system using these patterns to create a flexible and maintainable solution. I won’t dive deep into the specifics of these patterns, as many excellent resources already exist. Instead, I’ll focus on how I used them to solve real-world problems at EdBoard. Please note that this blog post is longer than I anticipated. However, it is important to explain the background of the problem we’re trying to solve. By sharing the details of how I built EdBoard’s notification system, I want to help others see why this solution works.
The Problem: Growing Complexity
At first glance, a notification system might seem simple. Let’s say we’re only sending notifications about attendance, missing assignments, or suspensions, and we only use one delivery channel, like email. This could easily be handled with conditional logic or switch statements.
But as more notification types (such as progress reports or behavior updates) and delivery channels (SMS, push notifications, etc.) were added, the complexity grew quickly. Each time I needed to add a new channel, I had to modify existing code, risking bugs in production. It became clear that I needed a better approach.
Implementing Notifications: A Real-World Example
Types of Notifications
- Student is late or absent
- Assignment marked missing
- Progress report suspension
- Grades dropping below passing
- Achieving honor roll status
…many more
Delivery Methods
- SMS
- Voice
- Push Notification
In this blog, the term “Announcement” is used to refer to “Notification.” This was part of an earlier iteration of the system, and it has since been updated to “Notification” in the final version of EdBoard. Throughout the code and video, whenever you hear “Announcement,” it should be understood as “Notification.”
Let’s look at a simplified version of how it is implemented in EdBoard, starting with the notification hub and channels.

To help you better understand how the notification system is structured within the EdBoard project, let’s take a quick look at the project folder layout.
- Under EdBoard.Core, you’ll find the Announcements folder, which contains all the logic related to notifications, referred to here as announcements.
- Each type of communication method has its own folder. For example, the Email, SMS, Voice, and WebPush folders contain the channels through which notifications can be sent. Each folder has its respective channel implementation file, like the AnnouncementSMSChannel.cs under the SMS folder.
- The AnnouncementHub.cs is central to this structure, acting as the main hub that manages the distribution of notifications through different channels.
- Other files, such as AnnouncementJob.cs and AnnouncementEventHandler.cs, handle background processes and events triggered by various notification activities.
Building the Notification Hub
The Notification Hub serves as the context for the Strategy Pattern, responsible for distributing notifications through the available channels. When a notification event (e.g., a student is marked absent) occurs, the hub resolves the available channels and uses them to send the notification.
public class AnnouncementHub : IAnnouncementHub, ITransientDependency
{
private readonly IRepository<Announcement, Guid> _announcementRepository;
private readonly IUnitOfWorkManager _unitOfWorkManager;
private readonly IIocManager IocManager;
public AnnouncementHub(IIocManager iocManager, IRepository<Announcement, Guid> announcementRepository, IUnitOfWorkManager unitOfWorkManager)
{
IocManager = iocManager;
_announcementRepository = announcementRepository;
_unitOfWorkManager = unitOfWorkManager;
}
[UnitOfWork]
public void Send(Guid id)
{
try
{
var announcement = _announcementRepository.Get(id);
announcement.Status = AnnouncementStatus.Publishing;
_unitOfWorkManager.Current.SaveChanges();
var channels = new List<IAnnouncementChannel>(IocManager.ResolveAl<IAnnouncementChannel>());
foreach (var channel in announcement.Channel.GetUniqueFlags())
{
var announcementChannel = channels.FirstOrDefault(c => c.CommunicationChannel == channel);
if (announcementChannel != null && CanExecute(announcement))
{
AsyncHelper.RunSync(() => announcementChannel.SendAsync(id));
}
}
}
catch (Exception ex)
{
}
}
}
Here, the AnnouncementHub is responsible for:
- Retrieving the announcement that triggered the event
- Fetching the appropriate delivery channels from the IoC container
- Sending the announcement through each channel
Each channel implements its own version of the SendAsync method, allowing it to handle the specific nuances of delivering a message (e.g., formatting an SMS vs. an email).
Adding New Channels and Notifications
Thanks to the use of the Strategy Pattern, adding a new notification channel or notification type is straightforward. You don’t need to modify the core logic in the AnnouncementHub. Instead, you simply create a new class that implements the IAnnouncementChannel interface and register it with the IoC container.
For example, here’s the implementation of the SMS channel:
AnnouncementSMSChannel : AnnouncementChannelBase, IAnnouncementChannel, ITransientDependency
{
public CommunicationChannel CommunicationChannel => CommunicationChannel.SMS;
private readonly ISmsSender _smsSender;
public AnnouncementSMSChannel(ISmsSender smsSender)
{
_smsSender = smsSender;
}
public async Task SendAsync(Guid id)
{
var announcement = await GetAnnouncement(id);
if (CanExecute(announcement, CommunicationChannel))
{
var recipients = await GetRecipients(announcement);
foreach (var user in recipients.Where(a => !string.IsNullOrEmpty(a.PhoneNumber)))
{
_smsSender.Send(user.PhoneNumber, announcement.SMSMessage);
}
}
}
}
The final step in setting up EdBoard’s notification system is loading the communication channels using an Inversion of Control (IoC) container within the EdBoardCoreModule, (this is just start up class). By registering each channel—such as Email, SMS, Voice, and WebPush—in the PreInitialize method, we ensure that the application dynamically loads and resolves all available channels at runtime. This step is essential for maintaining flexibility and scalability, as it decouples the core logic from specific implementations, allowing new channels to be added or modified without changing existing code. This approach follows the Open/Closed Principle, making the system easy to extend and ensuring seamless future updates to the notification process. Be aware that the implementation of this step might differ depending on the IoC container you are using, as various containers have unique methods for registration and resolution.
public class EdBoardCoreModule : AbpModule
{
public override void PreInitialize()
{
IocManager.Register<IAnnouncementHub, AnnouncementHub>(DependencyLifeStyle.Transient);
IocManager.Register<IAnnouncementChannel, AnnouncementEmailChannel>(DependencyLifeStyle.Transient);
IocManager.Register<IAnnouncementChannel, AnnouncementSMSChannel>(DependencyLifeStyle.Transient);
IocManager.Register<IAnnouncementChannel, AnnouncementVoiceChannel>(DependencyLifeStyle.Transient);
IocManager.Register<IAnnouncementChannel, AnnouncementWebPushChannel>(DependencyLifeStyle.Transient);
}
}
An Inversion of Control (IoC) Container plays a significant role in implementing this pattern in modern applications, especially when combined with Dependency Injection (DI). The Strategy Pattern is a behavioral design pattern that enables selecting an algorithm or strategy at runtime from a family of interchangeable strategies. The core idea is to define a set of strategies (or algorithms) that can be selected dynamically based on the context, without changing the code of the object using the strategy.
Why Is It Easier with Dependency Injection?
Simplified Code and Initialization: Without Dependency Injection (DI), the class that needs to use a strategy would be responsible for creating instances of the strategies. This results in tight coupling and duplication of object creation code.
Easier to Test: With DI, you can easily mock or inject different strategies when testing. Instead of relying on real implementations during testing, you can inject mock strategies. This allows you to isolate unit tests and test specific behaviors or configurations.
Runtime Flexibility: DI makes it easier to change strategies at runtime based on configurations or conditions. The IoC container can be configured to provide different implementations based on factors like the environment, user input, or specific business logic.
For example, you might use the SMS channel during weekdays and email during weekends without changing any core logic in the class:
if (IsWeekend())
container.Register<IAnnouncementChannel, AnnouncementEmailChannel>();
else
container.Register<IAnnouncementChannel, AnnouncementSMSChanne>();
Reduced Boilerplate Code: If you were to implement the Strategy Pattern without DI, you’d have to manually write logic to select and instantiate the correct strategy. This often involves if or switch statements, making the code harder to maintain and scale.
Here is an example of how strategy management looks without IoC and DI:
public class AnnouncementHub
{
private IAnnouncementChannel _channel;
public AnnouncementHub(string channelType)
{
// Manual instantiation
if (channelType == "SMS")
_channel = new AnnouncementSMSChannel();
else if (channelType == "Email")
_channel = new AnnouncementEmailChannel();
}
public void Send(Guid id)
{
_channel.SendAsync(id);
}
}
Here’s how it simplifies with Dependency Injection and IoC:
public class AnnouncementHub
{
private readonly IAnnouncementChannel _channel;
public AnnouncementHub(IAnnouncementChannel channel)
{
_channel = channel; // Injected by IoC container
}
public void Send(Guid id)
{
_channel.SendAsync(id);
}
}
Conclusion: A Scalable Solution
Building EdBoard’s notification system using the Strategy and Factory Patterns allowed for scalability and maintainability. New notification types and delivery channels could be added easily, without the need to rewrite or risk breaking existing functionality.
By adhering to the Open/Closed Principle, we ensured that the system could be extended with new features without modifying the existing code, reducing bugs and increasing flexibility. This design approach not only saved time but also improved the robustness of the system, enabling it to handle the complexity of real-world K-8 education systems.
Feel free to drop a comment if you have any thoughts or suggestions on this approach!
Leave A Comment