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.
go get github.com/mikestefanello/hooks
- 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")
- Listen to a hook:
package greeter
func init() {
user.HookUserInsert.Listen(func(e hooks.Event[user.User]) {
sendEmail(e.Msg.Email)
})
}
- 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)
.
- 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 likeimport _ "myapp/greeter"
in yourmain
package. - The
hooks.Event[T]
parameter contains the data that was passed in atEvent.Msg
and the hook atEvent.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)
}
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.
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)
})
}
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)
}
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)
})
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"))
}
})
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).
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