bedrock provides a minimal, modular and composable foundation for quickly developing more use case specific frameworks in Go.
One of the guiding principals for bedrock is to be composable. This principal comes from the experience gained from working with custom, tailor made frameworks which over their lifetime within an organization are unable to adapt to changing development and deployment patterns. Eventually, these frameworks are abandoned for new ones or completely rewritten to reflect the current state of the organization.
bedrock defines a small set of types and carefully chooses its opinions to balance composability and functionality, as much as it can. The result is, in fact, a framework that isn't necessarily designed for building services directly, but instead meant for building more custom, use case specific frameworks.
For example, bedrock could be used by your organizations platform engineering or framework team(s) to quickly develop internal frameworks which abstract over all of your organizations requirements e.g. OpenTelemetry, Logging, Authenticated endpoints, etc. Then, due to the high composibility of bedrock, any changes within your organization would then be very easy to adapt to within your internal framework.
type Builder[T any] interface {
Build(context.Context) (T, error)
}Builder is a generic interface for constructing application components with context support. Builders can be composed using functional combinators like Map and Bind.
type Runtime interface {
Run(context.Context) error
}Runtime is a simple abstraction over the execution of your specific application type e.g. HTTP server, gRPC server, background worker, etc.
type Runner[T Runtime] interface {
Run(context.Context, Builder[T]) error
}Runner executes application components built from Builders. Runners can be wrapped to add cross-cutting concerns like signal handling with NotifyOnSignal and panic recovery with RecoverPanics.
package config
type Reader[T any] interface {
Read(context.Context) (Value[T], error)
}The config.Reader is arguably the most powerful abstraction defined in any of the bedrock packages. It abstracts over reading configuration values that may or may not be present, distinguishing between "not set" and "set to zero value." Readers can be composed using functional combinators like Or, Map, Bind, and Default.
Below is a tiny and simplistic example of all the core concepts of bedrock.
package main
import (
"context"
"log/slog"
"os"
"syscall"
"github.com/z5labs/bedrock"
"github.com/z5labs/bedrock/config"
)
func main() {
os.Exit(run())
}
func run() int {
// Create a runner with signal handling and panic recovery
runner := bedrock.NotifyOnSignal(
bedrock.RecoverPanics(
bedrock.DefaultRunner[bedrock.Runtime](),
),
os.Interrupt,
os.Kill,
syscall.SIGTERM,
)
// Build and run the application
err := runner.Run(
context.Background(),
bedrock.BuilderFunc[bedrock.Runtime](buildApp),
)
if err == nil {
return 0
}
return 1
}
type myApp struct {
log *slog.Logger
}
// buildApp constructs the application using functional config composition
func buildApp(ctx context.Context) (bedrock.Runtime, error) {
// Read log level from environment with a default
logLevelReader := config.Default(
"INFO",
config.Env("MIN_LOG_LEVEL"),
)
logLevel, err := config.Read(ctx, logLevelReader)
if err != nil {
return nil, err
}
// Parse the log level string
var level slog.Level
err = level.UnmarshalText([]byte(logLevel))
if err != nil {
return nil, err
}
return &myApp{
log: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})),
}, nil
}
// Run implements the bedrock.Runtime interface.
func (a *myApp) Run(ctx context.Context) error {
// Do something here like:
// - run an HTTP server
// - start the AWS lambda runtime,
// - run goroutines to consume from Kafka
// etc.
a.log.InfoContext(ctx, "running my app")
return nil
}