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)
This commit is contained in:
2026-05-29 15:45:12 +00:00
commit 38a415c2ab
33 changed files with 3868 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
# ADR-001: core module composition — four sub-packages in one Go module
- **Date:** 2026-05-28
- **Module:** `code.nochebuena.dev/einherjar/core`
- **Status:** Accepted
## Context
In micro-lib, the four packages that become `core` are independent Go modules:
- `code.nochebuena.dev/go/launcher`
- `code.nochebuena.dev/go/logz`
- `code.nochebuena.dev/go/xerrors`
- `code.nochebuena.dev/go/valid`
Each has its own `go.mod`, version tag, and release cycle. A dependency update to
`xerrors` requires a version bump on `xerrors`, then updating `valid` to consume the
new version, then informing all callers of both. Four packages → four coordinated
version bumps per change.
In the Einherjar module structure, each entry in the module inventory is one
independently versionable unit. The Spring Boot starter model places the
distribution boundary at the starter level (`db-postgres`, `cache-valkey`, `auth`),
not at the foundational implementation level.
## Decision
`launcher`, `logz`, `xerrors`, and `valid` are sub-packages of a single
`code.nochebuena.dev/einherjar/core` Go module.
## Rationale
**They always ship together.** Every Einherjar service needs all four:
- `launcher` requires a `logging.Logger` — it calls `logz.New` in the same main function
- `valid` returns `*xerrors.Err` — they are semantically coupled at the type level
- No known use case requires `logz` without `xerrors`, or `launcher` without `logz`
**Version coordination cost is real.** With four separate modules, a one-line fix to
`xerrors` becomes four coordinated operations: tag xerrors, update valid's go.mod,
tag valid, inform consumers. With one module, it is one operation.
**The Spring Boot analogy holds at the right level.** Spring Boot's parent POM bundles
dozens of interdependent libraries into a single versioned unit. Starters (`spring-boot-starter-data-jpa`) are the distribution boundary, not the individual `spring-data-commons` jar inside them. Einherjar's `core` is the parent; the starters are the distribution units.
## Alternatives Considered
**Four separate modules (`core-launcher`, `core-logz`, etc.).** Rejected — over-engineering at the foundational layer. There is no consumer that needs `logz` but not `xerrors`, or `launcher` but not `logz`. The additional version coordination overhead has no compensating benefit.
**One flat `core` package (no sub-packages).** Rejected — importing `core` would pull all four concerns into any module that only needs errors or validation. Sub-packages preserve the ability to import only what is needed.
## Consequences
**Easier:** A single `go get code.nochebuena.dev/einherjar/core@v1.x.x` installs
everything. A single version bump covers all four sub-packages. Dependency graph for
downstream starters stays simple.
**Harder:** A change to any sub-package bumps the entire `core` version, even if the
change is isolated to `valid`. This is the accepted trade-off — the coupling is real;
the version increment reflects it.
**New obligations:** Sub-packages within `core` may import each other in one direction:
`valid` may import `xerrors`; nothing else crosses package boundaries within `core`.
Circular imports between sub-packages are prohibited. `launcher` uses
`contracts/logging` — not `core/logz` directly — to preserve the contracts layer.

View File

@@ -0,0 +1,115 @@
# ADR-002: logz adopts contracts/errs instead of private duck typing
- **Date:** 2026-05-28
- **Module:** `code.nochebuena.dev/einherjar/core`
- **Status:** Accepted
## Context
In micro-lib, `logz` enriches log records when the error passed to `Logger.Error`
carries a machine-readable code or structured fields. It detects this at runtime
using two private interfaces defined inside `logz`:
```go
// inside logz — never exported
type errorWithCode interface {
ErrorCode() string
}
type errorWithContext interface {
ErrorContext() map[string]any
}
```
`xerrors.Err` implements both via its `ErrorCode()` and `ErrorContext()` methods.
`logz` detects this with `errors.As` — without importing `xerrors`. The decoupling
is preserved. But the contract is invisible: a developer who wants their custom error
type to receive log enrichment must read `logz`'s internal source to discover what
methods are required.
This approach is acceptable in micro-lib (single team, single repository, total
visibility). In a framework distributed to third-party authors, it is a maintenance
trap.
## Decision
`core/logz` imports `contracts/errs` and checks `errs.CodedError` and
`errs.ContextualError` instead of defining private duck-typed equivalents.
```go
// core/logz/logger.go
var ec errs.CodedError
if errors.As(err, &ec) {
attrs = append(attrs, "error_code", ec.ErrorCode())
}
var ectx errs.ContextualError
if errors.As(err, &ectx) {
for k, v := range ectx.ErrorContext() {
attrs = append(attrs, k, v)
}
}
```
`core/xerrors` declares compile-time satisfaction:
```go
// core/xerrors/err.go
var _ errs.CodedError = (*Err)(nil)
var _ errs.ContextualError = (*Err)(nil)
```
## Why the `contracts/errs` Sub-package Exists
`contracts` is the only Einherjar module guaranteed to have zero dependencies.
If `CodedError` and `ContextualError` lived in `core/xerrors`, any module
implementing a custom error type would be forced to import `core` — pulling the
launcher, logger, and validator into its dependency graph. That defeats the
Separated Interface pattern.
`contracts/errs` is a zero-dependency home for these two 1-method interfaces.
Any module can implement them without taking on any Einherjar dependencies.
## Clean Separation Preserved
`logz` still does not import `xerrors`. Both import `contracts/errs`. The
decoupling is maintained; the contract is now visible.
```
contracts/errs (zero deps)
↑ ↑
core/logz core/xerrors
(imports) (implements)
```
A third-party error type implements `errs.CodedError` by satisfying one interface
from `contracts` — a zero-dependency import. It receives full log enrichment
automatically, without any wiring code.
## Alternatives Considered
**Keep private duck typing.** Rejected — invisible to third-party implementors.
A framework that requires developers to read its internal source code to understand
what their types must implement is hostile to the developers it serves.
**Export the interfaces from `core/logz`.** Rejected — would force custom error
types to import `core`, violating the Separated Interface pattern. An error type
should not need to know about logging infrastructure.
**One combined `RichError` interface.** Rejected — ISP. An error may carry a
machine-readable code without structured fields, or structured fields without a code.
Forcing both into one interface imposes unnecessary constraints on implementors.
## Consequences
**Easier:** Third-party error types have a clear, discoverable interface to implement:
`go doc code.nochebuena.dev/einherjar/contracts/errs`. Compile-time verification
replaces runtime discovery. The framework's own `*xerrors.Err` is provably correct
at compile time.
**Harder:** `logz` now imports `contracts`, adding one dependency to its import
graph. This dependency is zero-cost in practice — `contracts` has no external
dependencies and will not change its interfaces without a major version bump.
**New obligations:** The `errs.CodedError.ErrorCode()` and
`errs.ContextualError.ErrorContext()` signatures are permanent from `contracts v1.0.0`.
Any implementor of these interfaces is guaranteed that the signatures will not change
without a major version bump on `contracts`.

View File

@@ -0,0 +1,71 @@
# ADR-003 — Config naming convention and caarlos0/env tag standard
**Status:** Accepted
**Date:** 2026-05-28
---
## Context
`launcher` and `logz` originally followed micro-lib's `Options` naming for their
configuration structs. `Options` has no env tags, so there is no standard way for
applications to populate framework configuration from environment variables without
hardcoding field assignments.
The project requires a consistent, library-agnostic approach to environment-based
configuration across all Einherjar modules.
---
## Decision
1. **Naming:** Every package that accepts external configuration exposes a `Config`
struct. `Options` is not used for configuration types in Einherjar — it is reserved
for functional options patterns if ever needed.
2. **Tag standard:** `Config` struct fields that can be sourced from environment
variables carry `env` and `envDefault` struct tags following the `caarlos0/env`
library's syntax:
```go
type Config struct {
Level slog.Level `env:"EINHERJAR_LOG_LEVEL" envDefault:"INFO"`
JSON bool `env:"EINHERJAR_LOG_JSON" envDefault:"false"`
}
```
3. **No library import:** Modules do not import `caarlos0/env` or any other env
loader. The tags are metadata only. Applications choose their own loader and call
it against the `Config` struct before passing it to `New()`.
4. **Env var prefix:** All Einherjar-owned variables use the `EINHERJAR_` prefix,
consistent with the existing `EINHERJAR_BANNER` convention.
5. **Programmatic-only fields:** Fields that cannot be sourced from environment
variables (e.g. `io.Writer`, `[]any`) are included in `Config` without tags.
They default to sensible zero-value behaviour and are set directly by the caller.
---
## Rationale
- **Library-agnostic:** Different applications use different env loaders (`caarlos0/env`,
`sethvargo/go-envconfig`, `kelseyhightower/envconfig`, YAML via Viper, etc.).
Carrying only tags means no dependency is forced.
- **Consistent with the project:** micro-lib starters (`firebase`, `postgres`,
`mysql`, `sqlite`, `httpserver`, etc.) already use this pattern.
- **Discoverable defaults:** `envDefault` values are readable in the struct definition
itself — no need to trace through constructor logic to find what defaults are applied.
- **Zero cost when not used:** An application that populates `Config` directly in code
pays no overhead for the tags.
---
## Consequences
- All future Einherjar packages with external config must follow this convention.
- Starter packages (`db-postgres`, `cache-valkey`, etc.) will expose `Config` structs
with env tags when they are implemented.
- The `defaultComponentStopTimeout` constant in `launcher` is kept alongside
`Config.envDefault` so the programmatic default (used when zero `time.Duration` is
passed) remains explicit in code.

12
docs/adr/index.md Normal file
View File

@@ -0,0 +1,12 @@
# ADR Index — core
Module-level architecture decisions for `code.nochebuena.dev/einherjar/core`.
For framework-wide decisions see the
[Einherjar docs repository](https://code.nochebuena.dev/einherjar/docs).
| ADR | Title | Status |
|---|---|---|
| [ADR-001](ADR-001-core-module-composition.md) | core module composition — four sub-packages in one Go module | Accepted |
| [ADR-002](ADR-002-logz-contracts-errs.md) | logz adopts contracts/errs instead of private duck typing | Accepted |
| [ADR-003](ADR-003-config-env-tags.md) | Config naming convention and caarlos0/env tag standard | Accepted |