Skip to Content
Course content

216: Building a Notification Service with Akka Actors

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this all the time when developers first jump into Akka: they treat Actors as if they are just a fancy way to wrap a Future. They'll build a system where every single interaction is an ask (the ? operator), waiting for a response to come back before proceeding. If you're doing this, you're essentially turning your asynchronous actor system into a synchronous, blocking chain of requests. You've just rebuilt a traditional REST API call inside your own JVM, and you've lost almost all the benefits of the actor model in the process.

The "Ask" Trap: Turning Actors into Blocking Services

Imagine we're building a Notification Service. You might be tempted to write a NotificationManager that does something like this: it receives a request to notify a user, uses ask to send that request to an EmailActor, and then waits for a Future[Success] to return so it can log the result.

// The wrong way: Treating actors like synchronous methods
case class SendEmail(userId: String, message: String)
case class EmailResult(status: String)

// Inside the Manager Actor
def receive = {
  case SendEmail(userId, msg) => 
    // This is the trap! Waiting for a Future inside an actor
    (emailActor ? SendEmail(userId, msg)).map {
      case EmailResult(status) => println(s"Sent: $status")
    }
}

Here's why this is a disaster: you're tying the lifecycle of your manager to the response time of the email provider. If the email service hangs, you start piling up Futures in memory. You aren't letting the actor do what it's best at—managing state and reacting to events independently. I've seen systems crash under moderate load simply because the developers were "asking" too much and "telling" too little.

The Protocol Approach: Fire-and-Forget with Callbacks

The correct way to think about this is through protocols. Instead of asking "Did this work?", you tell the actor to do the work and tell it where to send the result when it's finished. This keeps the NotificationManager completely non-blocking. It sends a message and immediately moves on to the next notification in the queue.

In a real notification service, we want a dispatcher that delegates to specific channel actors (Email, SMS, Push). Let's look at how we actually structure this using the tell (!) pattern.

sealed trait NotificationMsg
case class Dispatch(userId: String, content: String, channel: Channel) extends NotificationMsg
case class DeliveryReport(userId: String, success: Boolean) extends NotificationMsg

sealed trait Channel
case object Email extends Channel
case object SMS extends Channel

class NotificationManager extends Actor {
  val emailActor = context.actorOf(Props[EmailActor], "email-worker")
  val smsActor = context.actorOf(Props[SmsActor], "sms-worker")

  def receive = {
    case Dispatch(userId, content, Email) =>
      // Fire and forget. We don't wait. 
      // We tell the worker to send the report back to 'self'.
      emailActor ! SendEmail(userId, content, sender()) 
      
    case Dispatch(userId, content, SMS) =>
      smsActor ! SendSms(userId, content, sender())

    case DeliveryReport(userId, success) =>
      // The actor reacts to the result whenever it eventually arrives
      if (success) println(s"User $userId notified successfully")
      else println(s"Failed to notify $userId")
  }
}

class EmailActor extends Actor {
  def receive = {
    case SendEmail(userId, content, replyTo) =>
      // Simulate network latency
      val success = try { 
        /* actual email logic */ 
        true 
      } catch { case _: Exception => false }
      
      replyTo ! DeliveryReport(userId, success)
  }
}

Notice the shift. The NotificationManager is now a traffic controller, not a waiter. It doesn't care if the EmailActor takes 10 milliseconds or 10 seconds to respond; it stays responsive to new Dispatch messages. This is the essence of the actor model: decoupling the request from the response.

One detail I want you to catch: passing sender(). In Akka, sender() is a method that returns the ActorRef of whoever sent the current message. By passing this along to the worker actor, we give the worker a way to "call home" without the worker needing to have a hardcoded reference to the manager. It makes your components far more reusable.




📋 Practical Task

Build a Rate-Limited Push Notification Dispatcher

Your goal is to extend the notification service to prevent spamming users. You need to implement a PushNotificationActor that enforces a rate limit: a specific user cannot receive more than 3 push notifications per single "window" of time (for this exercise, just use a simple counter that resets when a ResetLimit message is received).

Requirements:

  • Create a PushNotificationActor that maintains an internal Map[String, Int] to track the number of messages sent per userId.
  • If a user has already reached 3 messages, the actor should not "send" the notification; instead, it should send a DeliveryReport(userId, success = false) back to the manager immediately.
  • If the user is under the limit, increment their count and send a DeliveryReport(userId, success = true).
  • Implement a ResetLimit message that clears the map, allowing users to receive notifications again.
  • Integrate this into the NotificationManager so that Dispatch(userId, content, Push) routes to this new actor.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.