1 - Design Principles
Core principles that guide how we build Rivaas
This page explains the core ideas behind Rivaas. Understanding these principles helps you use the framework better. If you want to contribute code, these principles guide your work.
For how the packages and modules are structured, see Architecture. For why we chose specific approaches, see Design Decisions.
Core Philosophy
Developer Experience First
We put your experience as a developer first. Every choice we make thinks about how it affects you.
What this means:
When you use Rivaas, you should feel like the framework helps you, not fights you. Good defaults mean you can start quickly. Clear errors help you fix problems fast. The API should feel natural.
In practice:
- Everything works without configuration
- Simple tasks use simple code
- Error messages tell you what went wrong and how to fix it
- Your IDE can show you all available options
Example: Sensible Defaults
// This works right away - no setup needed
app := app.MustNew()
// Add configuration when you need it
app := app.MustNew(
app.WithServiceName("my-api"),
app.WithEnvironment("production"),
)
The first example works perfectly for getting started. The second example shows how to customize when you need to.
Progressive Disclosure
Simple use cases stay simple. Advanced features exist but don’t make basic tasks harder.
Three levels:
- Basic — Works immediately with good defaults
- Intermediate — Common changes are easy
- Advanced — Full control when you need it
Example:
// Level 1: Basic - just works
logger := logging.MustNew()
// Level 2: Common customization
logger := logging.MustNew(
logging.WithJSONHandler(),
logging.WithLevel(logging.LevelDebug),
)
// Level 3: Advanced - full control
logger := logging.MustNew(
logging.WithCustomLogger(myCustomLogger),
logging.WithSamplingInitial(100),
logging.WithSamplingThereafter(100),
logging.WithSamplingTick(time.Minute),
)
Discoverable APIs
Your IDE should help you find what you need. When you type metrics.With..., your IDE shows all options.
metrics.MustNew(
metrics.With... // IDE shows: WithProvider, WithPort, WithPath, etc.
)
Fail Fast with Clear Errors
Configuration errors happen at startup, not during requests. This helps you catch problems early. Error messages tell you what went wrong and how to fix it — for required fields (e.g. service name or version), the error includes which option or environment variable to use.
// Returns a clear error immediately
app, err := app.New(
app.WithServerTimeout(-1 * time.Second), // Invalid
)
// Error: "server.readTimeout: must be positive"
Rivaas constructors never return a non-nil value when they return an error, so you never receive a partially-initialized config.
Convenience Without Sacrificing Control
We provide two ways to create things:
MustNew() — Panics on error (good for main function)New() — Returns error (good for tests and libraries)
// In main() - panic is fine
app := app.MustNew(...)
// In tests or libraries - handle errors
app, err := app.New(...)
if err != nil {
return fmt.Errorf("failed to create app: %w", err)
}
Standards Compliance
Rivaas follows established industry standards instead of inventing its own formats. This means your team and your tools already know how to work with Rivaas output.
- RFC 9457 — Error responses use the Problem Details standard. Clients can parse errors without knowing Rivaas internals.
- OpenAPI 3.x — API documentation uses the OpenAPI specification. Any OpenAPI-compatible tool can read it.
- OpenTelemetry — Metrics and tracing use the OpenTelemetry standard. You can send data to any compatible backend.
When a well-adopted standard exists for a problem, we use it. This reduces what you need to learn and makes Rivaas work well with the wider ecosystem.
Testability
Every design choice should make testing easier, not harder.
The New() constructor returns errors that tests can check. Packages provide test helpers (for example, logging has utilities for capturing log output in tests). Because each package works on its own, you can test it with minimal dependencies.
When we design a new feature, we ask: “Can someone test this easily?” If the answer is no, we change the design.
Rivaas optimises hot paths without making the user API harder to use. The router uses sync.Pool to recycle request contexts, compiled route tables with Bloom filters for fast negative lookups, and optional cancellation-check elision for handler chains.
These optimisations happen behind the scenes. As a user, you call the same MustNew() and GET("/path", handler) — the fast path is the default path.
Architectural Patterns
Functional Options Pattern
All Rivaas packages use the same configuration pattern. This keeps the API consistent across packages.
Benefits:
- Backward compatible — Adding new options doesn’t break existing code
- Good defaults — You only specify what you want to change
- Self-documenting — Option names tell you what they do
- Easy to combine — Options work together naturally
- IDE-friendly — Autocomplete shows all options
How it works:
Every package follows this structure. Options apply to an internal config struct (often a private type). The constructor validates the config and then builds the public type from it. When the public type holds runtime state (e.g. Router, Logger, Recorder, Tracer), options must not mutate that type directly; they mutate a config struct, and the constructor builds the public type from the validated config.
// Step 1: Define an Option type (options apply to config, not the public type)
type Option func(*config)
// Step 2: Create constructor that accepts options
func New(opts ...Option) (*PublicType, error) {
cfg := defaultConfig() // Start with defaults
for _, opt := range opts {
opt(cfg) // Apply each option to config
}
if err := cfg.validate(); err != nil {
return nil, err
}
return newFromConfig(cfg), nil // Build public type from validated config
}
// Step 3: Convenience constructor that panics on error
func MustNew(opts ...Option) *PublicType {
t, err := New(opts...)
if err != nil {
panic(err)
}
return t
}
Packages like router, logging, metrics, tracing, and config use a private config struct; options apply to *config, and New() builds the public type (Router, Logger, Recorder, Tracer, Config) from the validated config.
Options must not be nil. Passing a nil option results in a validation error (reported by New() or by methods like ApplyLifecycle or Test() that accept options), not a panic. This applies to both top-level and nested options. Route options (e.g. passed to GET/POST/…) must not be nil; passing nil results in a validation error reported by ValidateRoutes, not a panic. When using MustNew, any error from New() (including nil-option validation) causes a panic.
Naming conventions:
With<Feature> — Enable or configure somethingWithout<Feature> — Disable something (when default is enabled)
// Enable features
metrics.WithPrometheus(":9090", "/metrics")
logging.WithJSONHandler()
app.WithServiceName("my-api")
// Disable features
metrics.WithServerDisabled()
app.WithoutDefaultMiddleware()
Examples across packages:
// Metrics package
recorder := metrics.MustNew(
metrics.WithPrometheus(":9090", "/metrics"),
metrics.WithServiceName("my-api"),
)
// Logging package
logger := logging.MustNew(
logging.WithJSONHandler(),
logging.WithLevel(logging.LevelInfo),
logging.WithServiceName("my-api"),
)
// Router package
r := router.MustNew(
router.WithNotFoundHandler(custom404),
router.WithMethodNotAllowedHandler(custom405),
)
Summary
| Principle | How we implement it |
|---|
| DX First | Good defaults, clear errors, progressive disclosure |
| Functional Options | Options apply to internal config; constructor builds public type from validated config |
| Standards Compliance | RFC 9457 errors, OpenAPI 3.x docs, OpenTelemetry observability |
| Testability | New() returns errors for tests; packages provide test helpers; standalone design reduces test dependencies |
| Performance | Context pooling, compiled routes, Bloom filters — all behind a simple API |
These principles guide all our development work. When you contribute to Rivaas, make sure your changes follow these principles.
For the package structure and module layout, see Architecture. For the reasoning behind specific choices, see Design Decisions.
2 - Architecture
How the Rivaas packages and modules are structured
This page explains how Rivaas is structured — the module layout, package boundaries, and the rules that keep everything clean. For the principles that drive these decisions, see Design Principles. For the reasoning behind specific choices, see Design Decisions.
Multi-Module Architecture
Rivaas is a monorepo with many independent Go modules. A go.work file at the root ties them together for development, but each module has its own go.mod and can be versioned and released on its own.
The workspace includes:
- Framework modules —
app/, router/ - Cross-cutting modules —
binding/, config/, errors/, logging/, metrics/, tracing/, validation/, openapi/ - Middleware modules —
middleware/accesslog, middleware/cors, middleware/ratelimit, middleware/recovery, and more (each is its own module) - Subpackages —
router/version/, router/compiler/, and internal packages within modules
This structure gives you:
- Independent versioning — A bug fix in
logging doesn’t force a new release of metrics - Minimal dependency graphs —
go get rivaas.dev/logging pulls only what logging needs - Compiler-enforced boundaries — If a standalone package accidentally imports
app, the build fails
Dependency Rules
Rivaas enforces a strict dependency direction. Standalone packages never import app. The app package imports standalone packages, not the other way around. Middleware modules depend only on router.
flowchart BT
appNode["app (integration layer)"]
routerNode["router"]
loggingNode["logging"]
metricsNode["metrics"]
tracingNode["tracing"]
bindingNode["binding"]
validationNode["validation"]
errorsNode["errors"]
openapiNode["openapi"]
configNode["config"]
mwNode["middleware/*"]
routerNode --> appNode
loggingNode --> appNode
metricsNode --> appNode
tracingNode --> appNode
bindingNode --> appNode
openapiNode --> appNode
configNode --> appNode
errorsNode --> appNode
validationNode --> appNode
mwNode -->|"depends on"| routerNodeThe arrows point in the direction of “is imported by.” Standalone packages sit at the bottom; app sits at the top.
Separation of Concerns
Each package does one thing well. This makes the code easier to test, maintain, and understand.
| Package | What it does |
|---|
router | Routes HTTP requests to handlers |
metrics | Collects and exports metrics |
tracing | Tracks requests across services |
logging | Writes structured log messages |
binding | Converts request data to Go structs |
validation | Checks if data is valid |
errors | Formats error responses (RFC 9457) |
openapi | Generates API documentation |
config | Loads and validates configuration from multiple sources |
app | Connects everything together |
Packages talk through clean interfaces. They don’t know about each other’s internal details.
type Recorder struct { ... }
func (r *Recorder) RecordRequest(method, path string, status int, duration time.Duration)
The app package calls RecordRequest without knowing how the recorder works inside.
Standalone Packages
Every Rivaas package works on its own. You can use any package without the full framework.
Benefits:
- No lock-in — Use Rivaas packages with any Go framework
- Gradual adoption — Start with one package, add more later
- Easy testing — Test with minimal dependencies
- Flexible — Different services can use different packages
Requirements for standalone packages:
Each package must:
- Work without the
app package - Have its own
go.mod file - Provide
New() and MustNew() constructors - Use functional options
- Have good defaults
- Include documentation and examples
Example: Using metrics with the standard library
package main
import (
"net/http"
"rivaas.dev/metrics"
)
func main() {
recorder := metrics.MustNew(
metrics.WithPrometheus(":9090", "/metrics"),
metrics.WithServiceName("my-api"),
)
defer recorder.Shutdown(context.Background())
handler := metrics.Middleware(recorder)(myHandler)
http.ListenAndServe(":8080", handler)
}
Any standalone package follows this same pattern — create with MustNew(), use it, and shut it down.
All standalone packages:
| Package | Import Path | What it does |
|---|
router | rivaas.dev/router | HTTP routing |
metrics | rivaas.dev/metrics | Prometheus/OTLP metrics |
tracing | rivaas.dev/tracing | OpenTelemetry tracing |
logging | rivaas.dev/logging | Structured logging |
binding | rivaas.dev/binding | Request binding |
validation | rivaas.dev/validation | Input validation |
errors | rivaas.dev/errors | Error formatting (RFC 9457) |
openapi | rivaas.dev/openapi | API documentation |
config | rivaas.dev/config | Multi-source configuration |
Middleware as Independent Modules
Each middleware is its own Go module with its own go.mod. Middleware modules depend only on router — they never import app or other standalone packages.
This means:
- You import only the middleware you need
- The dependency footprint stays minimal
- Adding a new middleware doesn’t affect existing ones
Available middleware modules include accesslog, basicauth, bodylimit, compression, cors, methodoverride, ratelimit, recovery, requestid, security, timeout, and trailingslash.
The App Package: Integration Layer
The app package is the glue that connects standalone packages into a complete framework.
What app does:
- Connects packages — Wires standalone packages together
- Manages lifecycle — Handles startup, shutdown, and cleanup
- Shares configuration — Passes service name and version to all packages
- Provides defaults — Sets up everything for production use
- Makes it easy — One entry point for common use cases
- Configures server transport — HTTP, HTTPS, or mTLS via WithTLS / WithMTLS at construction; a single
Start(ctx) runs the server. Default port is 8080 for HTTP and 8443 for TLS/mTLS, overridable with WithPort.
When building route handler chains (for both groups and version groups), the app layer uses a single handler type (router.HandlerFunc) so the integration layer stays consistent and predictable.
How app connects packages:
import (
"rivaas.dev/errors"
"rivaas.dev/logging"
"rivaas.dev/metrics"
"rivaas.dev/openapi"
"rivaas.dev/router"
"rivaas.dev/tracing"
)
type App struct {
router *router.Router
metrics *metrics.Recorder
tracing *tracing.Config
logging *logging.Config
openapi *openapi.Manager
// ...
}
Automatic wiring:
When you use app, packages connect automatically:
app := app.MustNew(
app.WithServiceName("my-api"),
app.WithObservability(
app.WithLogging(logging.WithJSONHandler()),
app.WithMetrics(),
app.WithTracing(tracing.WithOTLP("localhost:4317")),
),
)
Behind the scenes, app creates each package with the shared service name, connects the logger to metrics and tracing, sets up unified observability, and configures graceful shutdown for all components.
Choose your level:
// Full framework (recommended for most)
app := app.MustNew(
app.WithServiceName("my-api"),
app.WithObservability(
app.WithLogging(),
app.WithMetrics(),
app.WithTracing(),
),
)
app.GET("/users", handlers.ListUsers)
app.Start(ctx)
// Standalone packages (for maximum control)
r := router.MustNew()
logger := logging.MustNew()
recorder := metrics.MustNew()
r.Use(loggingMiddleware(logger))
r.Use(metricsMiddleware(recorder))
r.GET("/users", listUsers)
http.ListenAndServe(":8080", r)
Lifecycle Management
The app package provides lifecycle hooks that run at specific points during the application’s life:
OnStart — Runs before the server starts listening. Use for initialization that must succeed (database connections, migrations). Hooks run in order; if any hook returns an error, startup stops.OnReady — Runs after the server is ready to accept requests. Use for notifications or health check readiness.OnReload — Runs when the application receives a reload signal. Use for refreshing configuration.OnShutdown — Runs when the server is stopping. Hooks run in LIFO (last-in, first-out) order for clean teardown.OnStop — Runs on best-effort basis after shutdown completes.
app.OnStart(func(ctx context.Context) error {
return db.PingContext(ctx)
})
app.OnShutdown(func(ctx context.Context) {
db.Close()
})
Graceful shutdown handles in-flight requests, drains connections, and runs shutdown hooks — all managed by Start(ctx). Signal handling (SIGINT/SIGTERM) is built into Start; a second signal during the shutdown window triggers immediate os.Exit(1).
Observability Architecture
The router provides observability through the ObservabilityRecorder interface. This is an inversion-of-control hook: the router calls into observability code without importing any observability package.
type ObservabilityRecorder interface {
OnRequestStart(ctx context.Context, req *http.Request) (context.Context, any)
WrapResponseWriter(w http.ResponseWriter, state any) http.ResponseWriter
OnRequestEnd(ctx context.Context, state any, writer http.ResponseWriter, routePattern string)
}
The lifecycle works in three steps:
- OnRequestStart — Called before routing. Returns an enriched context (e.g., with a trace span) and an opaque state token. If the request should be excluded from observability (e.g., health checks), the state is
nil. - WrapResponseWriter — Wraps the response writer to capture status code and response size. Skipped when state is
nil. - OnRequestEnd — Called after the handler finishes. Records metrics, finishes traces, writes access logs. Skipped when state is
nil.
Context enrichment (step 1) always happens, even for excluded paths. This means trace propagation works for downstream calls from health check handlers.
The app package provides a default implementation that combines metrics, tracing, and access logging into a single recorder.
API Versioning
The router/version package provides API version detection and lifecycle header management. The version.Engine detects which API version a request targets by checking configurable sources (headers, query parameters, etc.) in order.
engine := version.MustNew(
version.WithHeaderDetection("X-API-Version"),
version.WithDefault("v1"),
)
When integrated with the router, version detection happens automatically and the detected version is available to handlers. The engine also manages lifecycle headers for version deprecation and sunset dates.
3 - Design Decisions
Why we chose specific approaches when building Rivaas
This page explains why we made certain choices. Each section describes the decision, the reasoning, and (where helpful) a short code comparison. For the principles behind these decisions, see Design Principles. For the resulting structure, see Architecture.
Why functional options over config structs?
Decision: Use functional options instead of configuration structs.
Reason:
- New options don’t break existing code
- Defaults are built in, not set by you
- Option names tell you what they do
- Your IDE can show all options
- Options can validate values when you apply them
Example of the benefit:
// With config struct: Adding new fields breaks code
type Config struct {
ServiceName string
Port int
NewFeature bool // New field — all code must be checked
}
// With functional options: Adding options doesn't break anything
metrics.MustNew(
metrics.WithServiceName("api"),
// New option added — old code still works
)
Why standalone packages?
Decision: Every package works independently.
Reason:
- You can try packages one at a time
- No vendor lock-in to the framework
- Testing is easier with fewer dependencies
- Library authors can use specific features
- Follows Go’s philosophy of composition
Why a separate app package?
Decision: Provide an app package that connects standalone packages.
Reason:
- Most users want everything to work together
- Connection code doesn’t pollute standalone packages
- Central place for lifecycle management
- Single place for shared concerns (service name, version)
- Consistent configuration across packages
Why New() and MustNew()?
Decision: Provide both error-returning and panic-on-error constructors.
Reason:
New() for libraries and code that needs error handlingMustNew() for main() where panic is acceptable- Follows standard library patterns (
regexp.Compile vs regexp.MustCompile) - Less boilerplate for common cases while keeping flexibility
Why multi-module over single module?
Decision: Structure the repository as many independent Go modules connected by go.work, instead of one large module.
Reason:
- Independent versioning — A bug fix in
logging doesn’t force a new release of metrics - Minimal downloads —
go get rivaas.dev/logging pulls only what logging needs, not the full framework - Compiler-enforced boundaries — If a standalone package accidentally imports
app, the build fails immediately. The dependency rules are checked by the Go toolchain, not just by convention. - Fits Go’s module model — Go modules are designed for this. Each module declares exactly what it needs.
The trade-off is more go.mod files to maintain, but the boundary enforcement and smaller dependency graphs are worth it.
Why OpenTelemetry for observability?
Decision: Use OpenTelemetry for metrics and tracing instead of building a custom observability layer.
Reason:
- Vendor-neutral — OpenTelemetry is a CNCF standard. You can send data to Prometheus, Jaeger, Datadog, Grafana, or any compatible backend.
- Single API — One set of APIs covers both metrics and tracing. You don’t learn two different systems.
- Wide ecosystem — Client libraries, auto-instrumentation, and exporters already exist for most languages and platforms.
- Future-proof — As the industry converges on OpenTelemetry, Rivaas stays aligned without migration work.
We didn’t build our own because observability standards are hard to get right, and the ecosystem benefits of a shared standard outweigh any framework-specific advantage.
Why private config structs?
Decision: Options apply to a private config struct. The public type is built from the validated config.
Reason:
- No mutation after construction — Once
New() returns, the configuration is sealed. Callers can’t change it from outside. - Validation at the gate — All checks happen in one place (the constructor). You never get a half-valid object.
- Small public surface — Users see
Option functions and the public type, not the internal config fields. This keeps the API clean and lets us change internals without breaking anyone.
// Options mutate the private config
type Option func(*config)
// Constructor validates and builds the public type
func New(opts ...Option) (*PublicType, error) {
cfg := defaultConfig()
for _, opt := range opts {
opt(cfg)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return newFromConfig(cfg), nil
}
Why router.HandlerFunc as the sole handler type?
Decision: Use a single handler type (router.HandlerFunc) throughout the framework — for middleware, route groups, version groups, and the app layer.
Reason:
- One type, one mental model — You write handlers and middleware the same way everywhere. No adapters, no conversion functions.
- Composable — Middleware, groups, and the app layer all chain the same type. This makes handler chains predictable.
- Less boilerplate — Frameworks that use multiple handler types need adapter functions at every boundary. Rivaas doesn’t.
Why context pooling?
Decision: Use sync.Pool to recycle Context objects in the router.
Reason:
- Hot-path allocation reduction — Every HTTP request needs a context. Allocating one per request puts pressure on the garbage collector. Pooling reuses contexts so the GC handles fewer allocations.
- Per-P caches — Go’s
sync.Pool already optimises for multi-core CPUs with per-processor caches. We get low-contention recycling without extra complexity. - Transparent to users — You never see the pool. You call
GET("/path", handler) and the router manages context lifecycle for you.
The trade-off is that handlers must not hold references to the context after the handler returns. This is documented and follows the same pattern as net/http request handling.
Why RFC 9457 for errors?
Decision: Use RFC 9457 Problem Details as the default error response format.
Reason:
- Industry standard — RFC 9457 defines a machine-readable format for HTTP error responses. Clients that understand this format can parse Rivaas errors without any framework-specific knowledge.
- Structured and extensible — The format includes standard fields (
type, title, status, detail, instance) and supports custom extensions. You get structure without losing flexibility. - Consistent across APIs — When all your services use the same error format, client-side error handling becomes simpler and more predictable.
{
"type": "https://api.example.com/problems/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "The 'email' field must be a valid email address",
"instance": "/users"
}
We chose RFC 9457 over custom formats because it’s well-defined, widely supported by HTTP tooling, and reduces what API consumers need to learn.
Why config bridge structs for file-based loading?
Decision: Export ObservabilityConfig, TracingConfig, MetricsConfig, and LoggingConfig as DTOs, even though the general rule is “no user-facing config structs”.
Reason:
Applications often load configuration from YAML or JSON files at startup. Functional options work well in code, but they don’t have a natural text format for file-based configuration. A bridge struct solves this without giving up the functional options API:
- The user populates the struct by unmarshalling a config file.
- The user passes it to
WithObservabilityFromConfig, which converts it to functional options internally. - The rest of the API stays functional-options-only.
This is different from replacing functional options with structs. These structs are DTOs — Data Transfer Objects — used only to move data from files into the options API. Users never mutate them to configure the app; they only pass them to one function. The functional options API (WithObservability, WithTracing, etc.) remains the primary way to configure observability in code.
// File-based config loading (uses the bridge struct)
var cfg AppConfig
yaml.Unmarshal(data, &cfg)
app.New(app.WithObservabilityFromConfig(cfg.Observability))
// Code-based config (uses functional options directly — same result)
app.New(app.WithObservability(
app.WithTracing(tracing.WithOTLP("localhost:4317")),
app.WithMetrics(metrics.WithPrometheus(":9090")),
))
The trade-off is that exporting these structs creates a surface that could be misused. We accept this because the structs are simple, well-documented, and the only entry point is WithObservabilityFromConfig.