Rene Nochebuena 38a415c2ab feat(core): initial implementation — launcher, logz, xerrors, valid
Introduces `code.nochebuena.dev/einherjar/core` — the foundational implementation
module of the Einherjar framework. Provides four sub-packages that together cover
every service's baseline needs: lifecycle management, structured logging, typed
errors, and struct validation.

- launcher: Launcher interface — three-phase managed lifecycle (OnInit → BeforeStart
  hooks → OnStart → OS signal wait → OnStop in reverse). Accepts
  lifecycle.Component and logging.Logger from contracts. Prints an ASCII art banner
  at startup (EINHERJAR_BANNER=off to suppress). Banner includes core version via
  runtime/debug.ReadBuildInfo() and a loaded-module list for every registered
  component that implements observability.Identifiable. Config struct with
  EINHERJAR_COMPONENT_STOP_TIMEOUT env tag (caarlos0/env syntax, default 15s).

- logz: Logger implementation backed by log/slog. Returns contracts/logging.Logger.
  Detects errs.CodedError and errs.ContextualError (from contracts/errs) to enrich
  log records automatically — replaces the private duck-typed bridge from micro-lib.
  Context helpers: WithRequestID, WithField, WithFields, GetRequestID. Config struct
  with EINHERJAR_LOG_LEVEL (default INFO) and EINHERJAR_LOG_JSON (default false) env
  tags (caarlos0/env syntax); programmatic-only fields StaticArgs and Writer carry no
  tags.

- xerrors: Typed error codes with context enrichment. Complete gRPC canonical set
  (16 codes) plus HTTP 410 ErrGone. Adds ErrOutOfRange, ErrAborted, ErrDataLoss
  over micro-lib. One convenience constructor per code. *Err declares compile-time
  satisfaction of errs.CodedError and errs.ContextualError.

- valid: Struct validation wrapping go-playground/validator/v10. Validator interface
  + MessageProvider interface with full built-in tag coverage (~150 tags) in both
  DefaultMessages (English) and SpanishMessages (Spanish). Backend fully hidden;
  returns *xerrors.Err with ErrInvalidInput or ErrInternal. FieldLevel interface
  abstracts the backend's field-level access for custom validators.
  WithCustomValidator registers custom validation tags at construction time;
  OverrideProvider chains a tag→handler map with a fallback MessageProvider for
  custom tag messages without re-implementing built-ins.

Compliance test enforces CT-6 (at most one exported TypeSpec per file via AST) and
verifies behavioural correctness of all four sub-packages, including custom validator
registration and OverrideProvider composition. Compile-time var _ assertions prove
interface satisfaction.

docs: ADR-001 (core module composition), ADR-002 (logz contracts/errs adoption),
ADR-003 (Config naming convention and caarlos0/env tag standard)
2026-05-29 15:45:12 +00:00

einherjar/core

version license go

The chosen warriors do not choose their weapons. They forge them.

code.nochebuena.dev/einherjar/core is the foundational implementation module of the Einherjar framework. It sits directly above contracts in the dependency graph and provides the concrete tools every service needs before anything else can start: a lifecycle runner, a structured logger, typed errors, and struct validation.


What Is Einherjar?

In Norse mythology, the Einherjar are the chosen warriors of Valhalla — selected not for glory, but to be ready for what comes after. They train. They prepare. They build the capability that others will rely on.

This framework is named for that purpose. Every module is a piece of that preparation: built carefully, documented for those who were never in the room, and designed to hold under pressure.


Sub-packages

Package Import path Purpose
launcher .../core/launcher Application lifecycle — init, start, shutdown
logz .../core/logz Structured, leveled logging via log/slog
xerrors .../core/xerrors Typed error codes with context enrichment
valid .../core/valid Struct validation with pluggable i18n messages

All four are in one module because they ship together in every Einherjar service. See ADR-001.


Usage

Launcher

import (
    "code.nochebuena.dev/einherjar/core/launcher"
    "code.nochebuena.dev/einherjar/core/logz"
)

logger := logz.New(logz.Config{JSON: true, StaticArgs: []any{"service", "api"}})

lc := launcher.New(logger)
lc.Append(db, cache, server)
lc.BeforeStart(func() error {
    return server.RegisterRoutes(db, cache)
})

if err := lc.Run(); err != nil {
    logger.Error("launcher failed", err)
    os.Exit(1)
}

Environment variables (uses caarlos0/env tag syntax — application supplies the loader):

Variable Default Effect
EINHERJAR_BANNER (on) Set to off or false to suppress the startup banner
EINHERJAR_COMPONENT_STOP_TIMEOUT 15s Maximum time per component OnStop

Logger

import "code.nochebuena.dev/einherjar/core/logz"

logger := logz.New(logz.Config{Level: slog.LevelDebug, JSON: true})

// Attach request context in middleware
ctx = logz.WithRequestID(ctx, requestID)
ctx = logz.WithField(ctx, "user_id", userID)

// Enrich logger from context in handlers
reqLogger := logger.WithContext(ctx)
reqLogger.Info("handling request", "path", r.URL.Path)

// Error enrichment is automatic — no extra code needed
reqLogger.Error("query failed", err) // appends error_code and context fields

Environment variables:

Variable Default Effect
EINHERJAR_LOG_LEVEL INFO Minimum log level (DEBUG, INFO, WARN, ERROR)
EINHERJAR_LOG_JSON false JSON output when true

Errors

import "code.nochebuena.dev/einherjar/core/xerrors"

// Named constructors for common cases
err := xerrors.NotFound("user %s not found", userID)
err := xerrors.InvalidInput("email is required")
err := xerrors.Aborted("order modified by another session")

// Builder pattern for structured context
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
    WithContext("field", "email").
    WithContext("rule", "required").
    WithError(cause)

// Inspecting errors
var e *xerrors.Err
if errors.As(err, &e) {
    switch e.Code() {
    case xerrors.ErrNotFound:       // HTTP 404
    case xerrors.ErrUnauthorized:   // HTTP 401
    case xerrors.ErrInvalidInput:   // HTTP 400
    }
}

Validation

import "code.nochebuena.dev/einherjar/core/valid"

type CreateUserReq struct {
    Email string `json:"email" validate:"required,email"`
    Age   int    `json:"age"   validate:"gte=18"`
}

v := valid.New() // English messages
// v := valid.New(valid.WithMessageProvider(valid.SpanishMessages))

if err := v.Struct(req); err != nil {
    var xe *xerrors.Err
    errors.As(err, &xe)
    // xe.Code()    == xerrors.ErrInvalidInput
    // xe.Fields()  == {"field": "email", "tag": "required"}
    // xe.Message() == "field 'email' is required"
}

All go-playground/validator built-in tags have specific messages in both DefaultMessages and SpanishMessages. The generic fallback only fires for unknown tags.

Custom validators

import "strings"

v := valid.New(
    valid.WithCustomValidator("nohttp", func(fl valid.FieldLevel) bool {
        return !strings.HasPrefix(fl.Field().String(), "http://")
    }),
    valid.WithMessageProvider(valid.OverrideProvider(
        map[string]func(field, param string) string{
            "nohttp": func(field, _ string) string {
                return fmt.Sprintf("field '%s' must not use http://", field)
            },
        },
        valid.DefaultMessages,
    )),
)

OverrideProvider chains a tag→message map with a fallback provider, so custom tag messages are handled without re-implementing all built-ins.


Error Codes

xerrors provides the full gRPC canonical error code set plus HTTP 410 (ErrGone):

Constant Wire value HTTP When to use
ErrInvalidInput INVALID_ARGUMENT 400 Malformed or invalid request data
ErrOutOfRange OUT_OF_RANGE 400 Valid value but outside accepted bounds
ErrUnauthorized UNAUTHENTICATED 401 Missing or invalid credentials
ErrPermissionDenied PERMISSION_DENIED 403 Authenticated but not authorised
ErrNotFound NOT_FOUND 404 Resource does not exist
ErrAlreadyExists ALREADY_EXISTS 409 Creation conflict (duplicate)
ErrAborted ABORTED 409 Concurrent modification; retry may succeed
ErrGone GONE 410 Resource permanently deleted
ErrPreconditionFailed FAILED_PRECONDITION 412 Business rule blocks the operation
ErrRateLimited RESOURCE_EXHAUSTED 429 Rate limit or quota exceeded
ErrCancelled CANCELLED 499 Request cancelled by the caller
ErrInternal INTERNAL 500 Unexpected server-side failure
ErrDataLoss DATA_LOSS 500 Unrecoverable data corruption
ErrNotImplemented UNIMPLEMENTED 501 Operation not implemented
ErrUnavailable UNAVAILABLE 503 Service temporarily unavailable
ErrDeadlineExceeded DEADLINE_EXCEEDED 504 Operation timed out

Wire values are stable across versions and safe to persist, send over the network, or switch on in client code.


Dependency Rules

contracts  (zero dependencies)
    ↑
  core     (depends on contracts only)
    ↑
 starters  (depend on core + contracts)
    ↑
  your app

core imports contracts. Nothing above core in this chain may import core directly — they depend on starters, which compose core's sub-packages behind framework-specific APIs.


Verification

cd core/
go build ./...     # must compile clean
go vet ./...       # no warnings
go test ./...      # structural + behavioural compliance passes
gofmt -l .         # no output

Architecture Decisions

ADR Title
ADR-001 Four sub-packages in one Go module
ADR-002 logz adopts contracts/errs instead of private duck typing
ADR-003 Config naming convention and caarlos0/env tag standard

They were not chosen because they were the strongest. They were chosen because they understood what they were building toward.

Description
Foundation: launcher, structured errors, validation, and structured logging
Readme 86 KiB
2026-05-29 09:45:35 -06:00
Languages
Go 100%