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.