Dew frees us from the cognitive load for managing different interfaces for each operation handler or domain logic. It provides a lightweight command bus interface + Middleware System for Go.
- Lightweight: Clocks around 450 LOC with minimalistic design.
- Pragmatic and Ergonomic: Focused on developer experience and productivity.
- Production Ready: 100% test coverage.
- Zero Dependencies: No external dependencies.
- Fast: See benchmarks.
go get github.com/go-dew/dew
See examples for more detailed examples.
It's as easy as:
package main
import (
"context"
"fmt"
"github.com/go-dew/dew"
)
// HelloAction is a simple action that greets the user.
type HelloAction struct {
Name string
}
// Validate checks if the name is valid.
func (c HelloAction) Validate(_ context.Context) error {
if c.Name == "" {
return fmt.Errorf("invalid name")
}
return nil
}
func main() {
// Initialize the Command Bus.
bus := dew.New()
// Register the handler for the HelloAction.
bus.Register(new(HelloHandler))
// Alternatively, you can use the HandlerFunc to register the handler.
// bus.Register(dew.HandlerFunc[HelloAction](func(ctx context.Context, cmd *HelloAction) error {
// println(fmt.Sprintf("Hello, %s!", cmd.Name)) // Output: Hello, Dew!
// return nil
// }))
// Dispatch the action.
_ = dew.Dispatch(context.Background(), dew.NewAction(bus, &HelloAction{Name: "Dew"}))
}
type HelloHandler struct {}
func (h *HelloHandler) HandleHelloAction(ctx context.Context, cmd *HelloAction) error {
println(fmt.Sprintf("Hello, %s!", cmd.Name)) // Output: Hello, Dew!
return nil
}
Dew uses the following terminology:
- Action: Operations that change the application state. We use the term "Action" to avoid confusion with similar terms in Go. It's equivalent to what is commonly known as a "Command" in Command Query Separation (CQS) and Command Query Responsibility Segregation (CQRS) patterns.
- Query: Operations that retrieve data.
- Middleware: Functions that execute logic (e.g., logging, authorization, transaction management) before and after command execution.
- Bus: Manages registration of handlers and routing of actions and queries to their respective handlers.
It utilizes the command-oriented interface pattern, which allows for separation of concerns, modularization, and better readability of the codebase, eliminating unnecessary cognitive load.
You can find more about the pattern in the following articles:
- Command Oriented Interface by Martin Fowler
- What is a command bus and why should you use it?
- Laravel Command Bus Pattern
I've been working on multiple complex backend applications built in Go over the years, and looking for a way to make the code more readable, maintainable, and more fun to work with. I believe Command Bus architecture could be an answer to this problem. However, I couldn't find a library that fits my needs, so I decided to create Dew.
Dew is designed to be lightweight with zero dependencies, making it easy to integrate into any Go project.
Dew relies on a convention for Action
and Query
interfaces:
- Action Interface: Each action in Dew must implement a
Validate
method, as defined by theAction
interface. ThisValidate
method is responsible for checking that the action's data is correct before it is processed. - Query Interface: Each query in any struct that implements the
Query
interface, which is an empty interface. Queries do not need aValidate
method because they do not change the state of the application.
Here's a simple example of how both interfaces are defined and used:
// MyAction represents an Action
type MyAction struct {
Amount int
}
// Validate implements the Action interface
func (a *MyAction) Validate(ctx context.Context) error {
if a.Amount <= 0 {
return fmt.Errorf("amount must be greater than zero")
}
return nil
}
// MyQuery represents a Query
type MyQuery struct {
AccountID string
}
// MyQuery does not need a Validate method because it does not change state
Also, we use the function dew.Dispatch
to send actions and dew.Query
to send queries to the bus. The bus will then route the action or query to the appropriate handler based on the action or query type. The reason for using different functions for actions and queries is to make the code more readable and simpler to work with. You will see this when you start using Dew in your projects.
Create a bus and register handlers:
package main
import (
"context"
"fmt"
"github.com/go-dew/dew"
)
type MyHandler struct {}
type MyAction struct {
Message string
}
func (h *MyHandler) HandleMyAction(ctx context.Context, a *MyAction) error {
// handle command
fmt.Println("Handling action:", a)
return nil
}
func main() {
bus := dew.New()
// Register handlers
bus.Register(new(MyHandler))
}
Use the Dispatch
function to send commands:
func main() {
ctx := context.Background()
bus := dew.New()
bus.Register(new(MyHandler))
a := &MyAction{Message: "Hello, Dew!"}
if err := dew.Dispatch(ctx, dew.NewAction(a)); err != nil {
fmt.Println("Error dispatching command:", err)
}
}
Query
handling example:
type MyHandler struct {}
type MyQuery struct {
Question string
Result string
}
func (h *MyHandler) HandleMyQuery(ctx context.Context, query *MyQuery) error {
// Return query result
query.Result = "Dew is a command bus library for Go."
return nil
}
func main() {
ctx := context.Background()
bus := dew.New()
bus.Register(new(MyHandler))
result, err := dew.Query(ctx, dew.NewQuery(&MyQuery{Question: "What is Dew?"}))
if err != nil {
fmt.Println("Error executing query:", err)
} else {
fmt.Println("Query result:", result.Result)
}
}
Dew provides QueryAsync
, which allows for handling multiple queries concurrently.
QueryAsync
usage example:
type AccountQuery struct {
AccountID string
Result float64
}
type WeatherQuery struct {
City string
Result string
}
type AccountHandler struct {}
type WeatherHandler struct {}
func (h *AccountHandler) HandleQuery(ctx context.Context, query *AccountQuery) error {
// Logic to retrieve account balance
query.Result = 10234.56 // Simulated balance
return nil
}
func (h *WeatherHandler) HandleQuery(ctx context.Context, query *WeatherQuery) error {
// Logic to fetch weather forecast
query.Result = "Sunny with a chance of rain" // Simulated forecast
return nil
}
func main() {
ctx := context.Background()
bus := dew.New()
bus.Register(new(AccountHandler))
bus.Register(new(WeatherHandler))
accountQuery := &AccountQuery{AccountID: "12345"}
weatherQuery := &WeatherQuery{City: "New York"}
if err := dew.QueryAsync(ctx, dew.NewQuery(accountQuery), dew.NewQuery(weatherQuery)); err != nil {
fmt.Println("Error executing queries:", err)
} else {
fmt.Println("Account Balance for ID 12345:", accountQuery.Result)
fmt.Println("Weather in New York:", weatherQuery.Result)
}
}
Middleware can be used to execute logic before and after command or query execution. Here is an example of a simple logging middleware:
func loggingMiddleware(next dew.Middleware) dew.Middleware {
return dew.MiddlewareFunc(func(ctx dew.Context) error {
fmt.Println("Before executing command")
err := next.Handle(ctx)
fmt.Println("After executing command")
return err
})
}
func main() {
bus := dew.New()
bus.Use(dew.ACTION, loggingMiddleware)
bus.Register(new(MyCommandHandler))
}
Here is the interface for middleware:
// MiddlewareFunc is a type adapter to convert a function to a Middleware.
type MiddlewareFunc func(ctx Context) error
// Handle calls the function h(ctx, command).
func (h MiddlewareFunc) Handle(ctx Context) error {
return h(ctx)
}
// Middleware is an interface for handling middleware.
type Middleware interface {
// Handle executes the middleware.
Handle(ctx Context) error
}
It's easy to group handlers and apply middleware to a group. You can also nest groups to apply middleware to a subset of handlers. It allows for a clean separation of concerns and reduces code duplication across handlers.
Here is an example of grouping handlers and applying middleware:
func main() {
bus := dew.New()
bus.Group(func(bus dew.Bus) {
// Transaction middleware
bus.Use(dew.ACTION, middleware.Transaction)
// Logger middleware
bus.Use(dew.ALL, middleware.Logger)
// Register handlers
bus.Register(new(UserProfileHandler))
// Sub-grouping
bus.Group(func(g dew.Bus) {
// Tracing middleware
bus.Use(dew.ACTION, middleware.Tracing)
// Register sensitive handlers
bus.Register(new(SensitiveHandler))
})
// Register more handlers
})
}
- Middleware for handlers can be applied per command or query, based on the
dew.ACTION
,dew.QUERY
anddew.ALL
constants. - Middleware can be applied multiple times because they are executed per command or query. So make sure the middleware is idempotent when necessary.
- Middleware for
Dispatch
andQuery
functions can be configured using theUseDispatch()
andUseQuery()
methods on the bus. This middleware is executed once perDispatch
orQuery
call.
Here is an example of a middleware that starts a transaction at the beginning of a command dispatch and rolls it back if any error occurs during the command's execution.
package main
import (
"context"
"fmt"
"github.com/go-dew/dew"
"database/sql"
)
// TransactionalMiddleware creates a middleware for handling transactions
func TransactionalMiddleware(db *sql.DB) func(next dew.Middleware) dew.Middleware {
return func(next dew.Middleware) dew.Middleware {
return dew.MiddlewareFunc(func(ctx dew.Context) error {
// Check if a transaction is already present in the context
if tx, ok := ctx.Context().Value("tx").(*sql.Tx); ok && tx != nil {
// Transaction already exists, proceed without creating a new one
return next.Handle(ctx)
}
// Start a new transaction
tx, err := db.BeginTx(ctx.Context(), nil)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
// Attach the transaction to the context
txCtx := context.WithValue(ctx.Context(), "tx", tx)
ctx = ctx.WithContext(txCtx)
// Execute the command
err = next.Handle(ctx)
if err != nil {
// Roll back the transaction in case of an error
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %w", rbErr)
}
return err
}
// Commit the transaction if everything went well
if commitErr := tx.Commit(); commitErr != nil {
return fmt.Errorf("commit failed: %w", commitErr)
}
return nil
})
}
}
func main() {
db, err := sql.Open("driver-name", "database-url")
if err != nil {
panic("failed to connect database")
}
defer db.Close()
bus := dew.New()
bus.UseDispatch(TransactionalMiddleware(db))
// Register your handlers and continue with application setup
}
To mock command handlers for testing, you can create a new bus instance and register the mock handlers.
package example_test
import (
"context"
"github.com/go-dew/dew"
"github.com/your/application/internal/action"
"testing"
)
func TestExample(t *testing.T) {
// Create a new bus instance
mux := dew.New()
// Register your mock handlers
mux.Register(dew.HandlerFunc[CreateUserAction](
func(ctx context.Context, command *CreateUserAction) error {
// mock logic
return nil
},
))
// test your code
}
Results as of May 23, 2024 with Go 1.22.2 on darwin/arm64
BenchmarkMux/query-12 3012015 393.5 ns/op 168 B/op 7 allocs/op
BenchmarkMux/dispatch-12 2854291 419.1 ns/op 192 B/op 8 allocs/op
BenchmarkMux/query-with-middleware-12 2981778 407.8 ns/op 168 B/op 7 allocs/op
BenchmarkMux/dispatch-with-middleware-12 2699398 446.8 ns/op 192 B/op 8 allocs/op
We welcome contributions to Dew! Please see the contribution guide for more information.
- The implementation of Trie data structure is inspired by go-chi/chi.
Licensed under MIT License