Implementing an Event Driven System in Go

4 min read

In this post, we will implement an event driven system in Go.

We are going to imagine a fictional application where we want to send out events for when a new account is created and another for when an account is deleted.

Let’s assume that the current structure of our program looks like this:

working-dir
|
|__auth.go/
|   |__auth.go
|
|__main.go
|__go.mod
|__go.sum

We would like this system to:

  • Be type safe. No interface{}, no need to type cast.
  • Be able to define a custom payload for each event.

Defining Events

To make it safe, we will create our events in a separate package and only export the complete events. Let’s call this package events. We’ll update our directory structure like this.

working-dir
|
|__auth.go/
|   |__auth.go
|
|__events/
|__main.go
|__go.mod
|__go.sum

Each event would be a unique type and would define the required payload for each event. Every handler would explicitly know what data it will receive.

Here would be our event for when a user is created:

// events/user_created.go
package events

import (
    "time"
)

var UserCreated userCreated

// UserCreatedPayload is the data for when a user is created
type UserCreatedPayload struct {
    Email string
    Time  time.Time
}

type userCreated struct {
    handlers []interface{ Handle(UserCreatedPayload) }
}

// Register adds an event handler for this event
func (u *userCreated) Register(handler interface{ Handle(UserCreatedPayload) }) {
    u.handlers = append(u.handlers, handler)
}

// Trigger sends out an event with the payload
func (u userCreated) Trigger(payload UserCreatedPayload) {
    for _, handler := range u.handlers {
        go handler.Handle(payload)
    }
}

We can then create another event for when a user is deleted:

// events/user_deleted.go
package events

import (
    "time"
)

var UserDeleted userDeleted

// UserDeletedPayload is the data for when a user is Deleted
type UserDeletedPayload struct {
    Email string
    Time  time.Time
}

type userDeleted struct {
    handlers []interface{ Handle(UserDeletedPayload) }
}

// Register adds an event handler for this event
func (u *userDeleted) Register(handler interface{ Handle(UserDeletedPayload) }) {
    u.handlers = append(u.handlers, handler)
}

// Trigger sends out an event with the payload
func (u userDeleted) Trigger(payload UserDeletedPayload) {
    for _, handler := range u.handlers {
        go handler.Handle(payload)
    }
}

Our directory structure now looks like this:

working-dir
|
|__auth.go/
|   |__auth.go
|
|__events/
|   |__user_created.go
|   |__user_deleted.go
|
|__main.go
|__go.mod
|__go.sum

A good thing about this system is that the event variable types are not exported, therefore, they cannot be changed or assigned to something different outside the package.

Listening for Events

To listen for an event, we import our events package and then register our handler to an event.

First, we create a listener that sends notifications to an admin and to slack when a user is created.

// create_notifier.go
package main

import (
    "time"

    "github.com/stephenafamo/demo/events"
)

func init() {
    createNotifier := userCreatedNotifier{
        adminEmail: "[email protected]",
        slackHook: "https://hooks.slack.com/services/...",
    }

    events.UserCreated.Register(createNotifier)
}

type userCreatedNotifier struct{
    adminEmail string
    slackHook string
}

func (u userCreatedNotifier) notifyAdmin(email string, time time.Time) {
    // send a message to the admin that a user was created
}

func (u userCreatedNotifier) sendToSlack(email string, time time.Time) {
    // send to a slack webhook that a user was created
}

func (u userCreatedNotifier) Handle(payload events.UserCreatedPayload) {
    // Do something with our payload
    u.notifyAdmin(payload.Email, payload.Time)
    u.sendToSlack(payload.Email, payload.Time)
}

Let’s add another listener that does the same when a user is deleted.

// delete_notifier.go
package main

import (
    "time"

    "github.com/stephenafamo/demo/events"
)

func init() {
    createNotifier := userCreatedNotifier{
        adminEmail: "[email protected]",
        slackHook: "https://hooks.slack.com/services/...",
    }

    events.UserCreated.Register(createNotifier)
}

type userCreatedNotifier struct{
    adminEmail string
    slackHook string
}

func (u userCreatedNotifier) notifyAdmin(email string, time time.Time) {
    // send a message to the admin that a user was created
}

func (u userCreatedNotifier) sendToSlack(email string, time time.Time) {
    // send to a slack webhook that a user was created
}

func (u userCreatedNotifier) Handle(payload events.UserCreatedPayload) {
    // Do something with our payload
    u.notifyAdmin(payload.Email, payload.Time)
    u.sendToSlack(payload.Email, payload.Time)
}

Now, we will have a directory structure that looks like this:

working-dir
|
|__auth.go/
|   |__auth.go
|
|__events/
|   |__user_created.go
|   |__user_deleted.go
|
|__create_notifier.go
|__delete_notifier.go
|__main.go
|__go.mod
|__go.sum

Triggering Events

Now that we have our listeners set up, we can then trigger these events from our auth package (or anywhere else).

// auth.go
package auth

import (
    "time"

    "github.com/stephenafamo/demo/events"
    // Other imported packages
)

func CreateUser() {
    // ...
    events.UserCreated.Trigger(events.UserCreatedPayload{
        Email: "[email protected]",
        Time: time.Now(),
    })
    // ...
}

func DeleteUser() {
    // ...
    events.UserDeleted.Trigger(events.UserDeletedPayload{
        Email: "[email protected]",
        Time: time.Now(),
    })
    // ...
}

Conclusion

We saw a way to define events in a type safe manner, how to listen for these events and how to trigger them.

Nothing fancy. As with all things Go, a good solution is a boring solution.

Powered By Swish

Comments