246 lines
7.8 KiB
Markdown
246 lines
7.8 KiB
Markdown
|
|
# einherjar/core
|
||
|
|
|
||
|
|
[](https://code.nochebuena.dev/einherjar/core)
|
||
|
|
[](LICENSE)
|
||
|
|
[](https://go.dev)
|
||
|
|
|
||
|
|
> 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](docs/adr/ADR-001-core-module-composition.md).
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Usage
|
||
|
|
|
||
|
|
### Launcher
|
||
|
|
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
|
||
|
|
```go
|
||
|
|
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
|
||
|
|
|
||
|
|
```bash
|
||
|
|
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](docs/adr/ADR-001-core-module-composition.md) | Four sub-packages in one Go module |
|
||
|
|
| [ADR-002](docs/adr/ADR-002-logz-contracts-errs.md) | logz adopts contracts/errs instead of private duck typing |
|
||
|
|
| [ADR-003](docs/adr/ADR-003-config-env-tags.md) | 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.*
|