Simple, type-safe hook system to enable easier modularization of your Go code.
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
package user
type User struct {
ID int
Name string
Email string
Password string
}
var HookUserInsert = hooks.NewHook[User]("user.insert")
package greeter
func init() {
user.HookUserInsert.Listen(func(e hooks.Event[user.User]) {
sendEmail(e.Msg.Email)
})
}
func (u *User) Insert() {
db.Insert("INSERT INTO users ...")
HookUserInsert.Dispatch(&u)
}
Or, dispatch all listeners asynchronously with HookUserInsert.DispatchAsync(u)
.
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)
}
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.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:
}
}
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.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
}
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