This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

About Rivaas

Learn about the philosophy and principles behind Rivaas

Welcome to the About section! Here you can learn about the ideas and principles that guide Rivaas development.

What is Rivaas?

Rivaas is a web framework for Go. We built it to make creating web APIs easier and more enjoyable. The name comes from ریواس (Rivās), a wild rhubarb plant that grows in the mountains of Iran.

Just like this tough mountain plant, Rivaas is:

  • Strong — Built to handle production workloads
  • Light — Fast and uses little memory
  • Flexible — Works in many different environments
  • Independent — Each piece works on its own

Our Goals

We want Rivaas to be:

  1. Easy to use — You should understand it quickly
  2. Hard to misuse — Good defaults keep you safe
  3. Fun to work with — Clear APIs and helpful errors
  4. Ready for production — Works well from day one

Design Philosophy

Every decision we make follows a few key ideas:

  • Developer experience comes first — Your time is valuable
  • Simple things stay simple — Basic tasks need simple code
  • Advanced features are available — But they don’t get in your way
  • Each package stands alone — Use only what you need

Learn More

Want to understand how we built Rivaas?

Join the Community

Rivaas is open source. We welcome your ideas and contributions!

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:

  1. Basic — Works immediately with good defaults
  2. Intermediate — Common changes are easy
  3. 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.

Performance-Conscious Ergonomics

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 something
  • Without<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

PrincipleHow we implement it
DX FirstGood defaults, clear errors, progressive disclosure
Functional OptionsOptions apply to internal config; constructor builds public type from validated config
Standards ComplianceRFC 9457 errors, OpenAPI 3.x docs, OpenTelemetry observability
TestabilityNew() returns errors for tests; packages provide test helpers; standalone design reduces test dependencies
PerformanceContext 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 modulesapp/, router/
  • Cross-cutting modulesbinding/, config/, errors/, logging/, metrics/, tracing/, validation/, openapi/
  • Middleware modulesmiddleware/accesslog, middleware/cors, middleware/ratelimit, middleware/recovery, and more (each is its own module)
  • Subpackagesrouter/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 graphsgo 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"| routerNode

The 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.

PackageWhat it does
routerRoutes HTTP requests to handlers
metricsCollects and exports metrics
tracingTracks requests across services
loggingWrites structured log messages
bindingConverts request data to Go structs
validationChecks if data is valid
errorsFormats error responses (RFC 9457)
openapiGenerates API documentation
configLoads and validates configuration from multiple sources
appConnects 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:

  1. Work without the app package
  2. Have its own go.mod file
  3. Provide New() and MustNew() constructors
  4. Use functional options
  5. Have good defaults
  6. 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:

PackageImport PathWhat it does
routerrivaas.dev/routerHTTP routing
metricsrivaas.dev/metricsPrometheus/OTLP metrics
tracingrivaas.dev/tracingOpenTelemetry tracing
loggingrivaas.dev/loggingStructured logging
bindingrivaas.dev/bindingRequest binding
validationrivaas.dev/validationInput validation
errorsrivaas.dev/errorsError formatting (RFC 9457)
openapirivaas.dev/openapiAPI documentation
configrivaas.dev/configMulti-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:

  1. Connects packages — Wires standalone packages together
  2. Manages lifecycle — Handles startup, shutdown, and cleanup
  3. Shares configuration — Passes service name and version to all packages
  4. Provides defaults — Sets up everything for production use
  5. Makes it easy — One entry point for common use cases
  6. 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:

  1. 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.
  2. WrapResponseWriter — Wraps the response writer to capture status code and response size. Skipped when state is nil.
  3. 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 handling
  • MustNew() 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 downloadsgo 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:

  1. The user populates the struct by unmarshalling a config file.
  2. The user passes it to WithObservabilityFromConfig, which converts it to functional options internally.
  3. 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.