Skip to content

Simple, type-safe hook system to enable easier modularization of your Go code.

License

Notifications You must be signed in to change notification settings

mikestefanello/hooks

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Hooks

Go Report Card Test License: MIT Go Reference GoT

Overview

Hooks provides a simple, type-safe hook system to enable easier modularization of your Go code. A hook allows various parts of your codebase to tap into events and operations happening elsewhere which prevents direct coupling between the producer and the consumers/listeners. For example, a user package/module in your code may dispatch a hook when a user is created, allowing your notification package to send the user an email, and a history package to record the activity without the user module having to call these components directly. A hook can also be used to allow other modules to alter and extend data before it is processed.

Hooks can be very beneficial especially in a monolithic application both for overall organization as well as in preparation for the splitting of modules into separate synchronous or asynchronous services.

Installation

go get github.com/mikestefanello/hooks

Usage

  1. Start by declaring a new hook which requires specifying the type of data that it will dispatch as well as a name. This can be done in a number of different way such as a global variable or exported field on a struct:
package user

type User struct {
    ID int
    Name string
    Email string
    Password string
}

var HookUserInsert = hooks.NewHook[User]("user.insert")
  1. Listen to a hook:
package greeter

func init() {
    user.HookUserInsert.Listen(func(e hooks.Event[user.User]) {
        sendEmail(e.Msg.Email)
    })
}
  1. Dispatch the data to the hook listeners:
func (u *User) Insert() {
    db.Insert("INSERT INTO users ...")

    HookUserInsert.Dispatch(&u)
}

Or, dispatch all listeners asynchronously with HookUserInsert.DispatchAsync(u).

Things to know

  • The Listen() callback does not have to be an anonymous function. You can also do:
package greeter

func init() {
    user.HookUserInsert.Listen(onUserInsert)
}

func onUserInsert(e hooks.Event[user.User]) {
    sendEmail(e.Msg.Email)
}
  • If you are using init() to register your hook listeners and your package isn't being imported elsewhere, you need to import it in order for that to be executed. You can simply include something like import _ "myapp/greeter" in your main package.
  • The hooks.Event[T] parameter contains the data that was passed in at Event.Msg and the hook at Event.Hook. Having the hook available in the listener means you can use a single listener for multiple hooks, ie:
HookOne.Listen(listener)
HookTwo.Listen(listener)

func listener(e hooks.Event[SomeType]) {
    switch e.Hook {
    case HookOne:
    case HookTwo:
    }
}
  • If the Msg is provided as a pointer, a hook can modify the the data which can be useful to allow for modifications prior to saving a user, for example.
  • You do not have to use init() to listen to hooks. For example, another pattern for this example could be:
package greeter

type Greeter struct {
    emailClient email.Client
}

func NewGreeter(client email.Client) *Greeter {
    g := &Greeter{emailClient: client}
    
    user.HookUserInsert.Listen(func (e hooks.Event[user.User]) {
        g.sendEmail(e.Msg.Email)
    })
    
    return g
}
  • Following the previous example, hooks can be provided as part of exported structs rather than just global variables, for example:
package greeter

type Greeter struct {
    HookSendEmail *hooks.Hook[Email]
    emailClient email.Client
}

func NewGreeter(client email.Client) *Greeter {
    g := &Greeter{emailClient: client}

    user.HookUserInsert.Listen(func (e hooks.Event[user.User]) {
        g.sendEmail(e.Msg.Email)
    })

    return g
}

func (g *Greeter) sendEmail(email string) error {
    e := Email{To: email}
    if err := g.emailClient.Send(e); err != nil {
        return err
    }

    g.HookSendEmail.Dispatch(e)
}

More examples

While event-driven usage as shown above is the most common use-case of hooks, they can also be used to extend functionality and logic or the process in which components are built. Here are some more examples.

Router construction

If you're building a web service, it could be useful to separate the registration of each of your module's endpoints. Using Echo as an example:

package main

import (
    "github.com/labstack/echo/v4"
    "github.com/myapp/router"
    
    // Modules
    _ "github.com/myapp/modules/todo"
    _ "github.com/myapp/modules/user"
)

func main() {
    e := echo.New()
    router.BuildRouter(e)
    e.Start("localhost:9000")
}
package router

import (
    "net/http"
    
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/mikestefanello/hooks"
)

var HookBuildRouter = hooks.NewHook[echo.Echo]("router.build")

func BuildRouter(e *echo.Echo) {
    e.Use(
        middleware.RequestID(),
        middleware.Logger(),
    )
    
    e.GET("/", func(ctx echo.Context) error {
        return ctx.String(http.StatusOK, "hello world")
    })
    
    // Allow all modules to build on the router
    HookBuildRouter.Dispatch(e)
}
package todo

import (
    "github.com/labstack/echo/v4"
    "github.com/mikestefanello/hooks"
    "github.com/myapp/router"
)

func init() {
    router.HookBuildRouter.Listen(func(e hooks.Event[echo.Echo]) {
        e.Msg.GET("/todo", todoHandler.Index)
        e.Msg.GET("/todo/:todo", todoHandler.Get)
        e.Msg.POST("/todo", todoHandler.Post)
    })
}

Dependency creation (and injection)

Rather than inititalize all of your dependencies in a single place, hooks can be used to distribute these tasks to the providing packages and great dependency injection libraries like do can be used to manage them.

package main

import (
    "github.com/mikestefanello/hooks"
    "github.com/samber/do"

    "example/services/app"
    "example/services/web"
)

func main() {
    i := app.Boot()

    server := do.MustInvoke[*web.Web](i)
    server.Start()
}
package app

import (
    "github.com/mikestefanello/hooks"
    "github.com/samber/do"
)

var HookBoot = hooks.NewHook[*do.Injector]("boot")

func Boot() *do.Injector {
    injector := do.New()
    HookBoot.Dispatch(injector)
    return injector
}
package web

import (
    "net/http"

    "github.com/mikestefanello/hooks"
    "github.com/samber/do"

    "example/services/app"
)

type (
    Web interface {
        Start() error
    }

    web struct {}
)

func init() {
    app.HookBoot.Listen(func(e hooks.Event[*do.Injector]) {
        do.Provide(e.Msg, NewWeb)
    })
}

func NewWeb(i *do.Injector) (Web, error) {
    return &web{}, nil
}

func (w *web) Start() error {
    return http.ListenAndServe(":8080", nil)
}

Modifications

Hook listeners can be used to make modifications to data prior to some operation being executed if the message is provided as a pointer. For example, using the User from above:

var HookUserPreInsert = hooks.NewHook[*User]("user.pre_insert")

func (u *User) Insert() {
    // Let other modules make any required changes prior to inserting
    HookUserPreInsert.Dispatch(u)
	
    db.Insert("INSERT INTO users ...")
    
    // Notify other modules of the inserted user
    HookUserInsert.Dispatch(*u)
}
HookUserPreInsert.Listen(func(e hooks.Event[*user.User]) {
    // Change the user's name
    e.Msg.Name = fmt.Sprintf("%s-changed", e.Msg.Name)
})

Validation

Hook listeners can also provide validation or other similar input on data that is being acted on. For example, using the User again.

type UserValidation struct {
    User User
    Errors *[]error
}

var HookUserValidate = hooks.NewHook[UserValidation]("user.validate")

func (u *User) Validate() []error {
    errs := make([]error, 0)
    uv := UserValidation{
        User:   *u,
        Errors: &errs,
    }

    if u.Email == "" {
        uv.Errors = append(uv.Errors, errors.New("missing email"))
    }
    
    // Let other modules validate
    HookUserValidate.Dispatch(uv)
	
    return uv.Errors
}
HookUserValidate.Listen(func(e hooks.Event[user.UserValidate]) {
    if len(e.Msg.User.Password) < 10 {
        e.Msg.Errors = append(e.Msg.Errors, errors.New("password too short"))
    }
})

Full application example

For a full application example see hooks-example. This aims to provide a modular monolithic architectural approach to a Go application using hooks and do (dependency injection).

Logging

By default, nothing will be logged, but you have the option to specify a logger in order to have insight into what is happening within the hooks. Pass a function in to SetLogger(), for example:

hooks.SetLogger(func(format string, args ...any) {
    log.Printf(format, args...)
})
2022/09/07 13:42:19 hook created: user.update
2022/09/07 13:42:19 registered listener with hook: user.update
2022/09/07 13:42:19 registered listener with hook: user.update
2022/09/07 13:42:19 registered listener with hook: user.update
2022/09/07 13:42:19 dispatching hook user.update to 3 listeners (async: false)
2022/09/07 13:42:19 dispatch to hook user.update complete