103 lines
3.9 KiB
Markdown
103 lines
3.9 KiB
Markdown
|
|
# ADR-001: errs Sub-package — Formalising the Error Enrichment Contract
|
||
|
|
|
||
|
|
- **Date:** 2026-05-27
|
||
|
|
- **Module:** `code.nochebuena.dev/einherjar/contracts`
|
||
|
|
- **Status:** Accepted
|
||
|
|
|
||
|
|
## Context
|
||
|
|
|
||
|
|
In micro-lib, `logz` and `xerrors` are decoupled through duck typing. `logz` defines
|
||
|
|
two private interfaces internally:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// inside logz — never exported
|
||
|
|
type errorWithCode interface {
|
||
|
|
ErrorCode() string
|
||
|
|
}
|
||
|
|
type errorWithContext interface {
|
||
|
|
ErrorContext() map[string]any
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`xerrors.Err` happens to satisfy both. `logz` detects this at runtime via
|
||
|
|
`errors.As`. The contract exists — it just lives nowhere. Any type that implements
|
||
|
|
`ErrorCode() string` and `ErrorContext() map[string]any` gets the enrichment
|
||
|
|
behaviour, whether its author knew about `logz` or not.
|
||
|
|
|
||
|
|
This works in a flat library collection where both packages are authored by the same
|
||
|
|
team. It breaks down in a framework context for two reasons:
|
||
|
|
|
||
|
|
1. **No canonical definition.** A developer implementing a custom error type that
|
||
|
|
wants logger enrichment has no interface to implement against — they must read
|
||
|
|
`logz`'s source code to discover the implicit contract.
|
||
|
|
|
||
|
|
2. **Impossible to mock or test cleanly.** A test that wants to verify error
|
||
|
|
enrichment behaviour cannot assert against a defined interface. It must rely on
|
||
|
|
the duck-type resolution happening implicitly.
|
||
|
|
|
||
|
|
## Decision
|
||
|
|
|
||
|
|
Introduce `contracts/errs` as a sub-package with two exported interfaces:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// errs/coded_error.go
|
||
|
|
type CodedError interface {
|
||
|
|
ErrorCode() string
|
||
|
|
}
|
||
|
|
|
||
|
|
// errs/contextual_error.go
|
||
|
|
type ContextualError interface {
|
||
|
|
ErrorContext() map[string]any
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`core/logz` imports `contracts/errs` and checks against these interfaces explicitly
|
||
|
|
instead of defining private duck-typed equivalents. `core/xerrors` declares compile-time
|
||
|
|
satisfaction:
|
||
|
|
|
||
|
|
```go
|
||
|
|
var _ errs.CodedError = (*Err)(nil)
|
||
|
|
var _ errs.ContextualError = (*Err)(nil)
|
||
|
|
```
|
||
|
|
|
||
|
|
The clean separation is preserved — `logz` still does not import `xerrors`, and
|
||
|
|
`xerrors` still does not import `logz`. Both now depend on `contracts/errs`, which
|
||
|
|
has zero dependencies of its own.
|
||
|
|
|
||
|
|
## Why Two Interfaces, Not One
|
||
|
|
|
||
|
|
The interfaces are intentionally separate. ISP (Interface Segregation Principle)
|
||
|
|
requires that no consumer is forced to implement methods it does not use.
|
||
|
|
|
||
|
|
An error may carry a machine-readable code without structured context fields. An
|
||
|
|
error may carry structured context fields without a machine-readable code. Forcing
|
||
|
|
both into a single `RichError` interface would require implementors to provide both
|
||
|
|
— a constraint that is not justified by the actual use cases.
|
||
|
|
|
||
|
|
`logz` checks each independently via `errors.As`, which is exactly how the
|
||
|
|
duck-typed version already worked. The two-interface shape matches the actual
|
||
|
|
consumption pattern.
|
||
|
|
|
||
|
|
## Why in `contracts`, Not in `core`
|
||
|
|
|
||
|
|
`contracts` is the only module guaranteed to have zero Einherjar dependencies. If
|
||
|
|
`CodedError` and `ContextualError` lived in `core`, any module that wanted to
|
||
|
|
implement them would be forced to import `core` — pulling the launcher, logger, and
|
||
|
|
validator into its dependency graph. That violates the Separated Interface pattern
|
||
|
|
that `contracts` exists to enforce.
|
||
|
|
|
||
|
|
## Consequences
|
||
|
|
|
||
|
|
**Easier:** Custom error types have a clear interface to implement against.
|
||
|
|
`go doc code.nochebuena.dev/einherjar/contracts/errs` is the single authoritative
|
||
|
|
answer to "what does my error need to provide for logger enrichment to work?"
|
||
|
|
Compile-time verification replaces implicit duck-type discovery.
|
||
|
|
|
||
|
|
**Harder:** `errs` is a sub-package that did not exist in micro-lib. Developers
|
||
|
|
migrating from micro-lib must adopt these interfaces explicitly rather than relying
|
||
|
|
on the duck-type bridge. This is a one-time migration cost with no ongoing burden.
|
||
|
|
|
||
|
|
**New obligations:** `CodedError.ErrorCode()` and `ContextualError.ErrorContext()`
|
||
|
|
signatures are permanent from this release. They may not be changed without a major
|
||
|
|
version bump on `contracts`.
|