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:
64
docs/adr/ADR-001-core-module-composition.md
Normal file
64
docs/adr/ADR-001-core-module-composition.md
Normal 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.
|
||||
115
docs/adr/ADR-002-logz-contracts-errs.md
Normal file
115
docs/adr/ADR-002-logz-contracts-errs.md
Normal 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`.
|
||||
71
docs/adr/ADR-003-config-env-tags.md
Normal file
71
docs/adr/ADR-003-config-env-tags.md
Normal 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
12
docs/adr/index.md
Normal 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 |
|
||||
Reference in New Issue
Block a user