Technical Decisions
This page documents the key technical decisions made for the Proxima Console project, along with the rationale behind each choice.
Decision Log
| Decision | Choice | Rationale |
|---|---|---|
| API framework | chi router | Lightweight, composable, and fully compatible with Go's net/http stdlib. No heavy framework overhead. |
| Database access | sqlx (raw SQL) | Full control over query construction and performance. No ORM abstraction layer to debug through. Straightforward use of PostgreSQL-specific features. |
| Logging | slog (stdlib) | Structured JSON logging with zero external dependencies. Part of Go's standard library since Go 1.21. |
| Messaging | NATS with JetStream | Persistent streams with at-least-once delivery, request-reply patterns, and a lightweight operational footprint compared to alternatives like Kafka. |
| Frontend framework | React + TypeScript | Team familiarity, mature ecosystem, strong TypeScript integration, and wide availability of developers. |
| UI components | shadcn/ui + Tailwind | Copy-paste component model provides full ownership and customization. No version lock-in to a component library. Tailwind enables rapid, consistent styling. |
| Real-time updates | REST + polling | Simpler to implement, debug, and operate than WebSocket connections. Sufficient data freshness for the MVP use cases (30-60 second polling intervals). |
| Config management | Env vars + YAML | Twelve-Factor App compliant. Environment variables for deployment-specific and secret values, YAML files for application defaults. |
| Error handling | Sentinel errors + %w wrapping | Type-safe error checking with errors.Is() and errors.As(). Context-preserving error chains via fmt.Errorf("context: %w", err). |
| Migrations | golang-migrate | Simple up/down SQL migration files. No code generation or DSL. Migrations are plain SQL, reviewable in merge requests. |
| Container scanning | Trivy | Open-source vulnerability scanner with broad CVE database coverage. Integrates directly into CI pipelines. |
| Agent distribution | Cloudflare R2 | S3-compatible object storage with global edge delivery. Cost-effective for distributing agent binaries to geographically distributed servers. |
| Interface pattern | Consumer-defined | Interfaces defined where consumed (Go idiom). Small single-method interfaces, implicit satisfaction, composition root wiring in main.go. |
Detailed Rationale
Why Not WebSocket?
WebSocket adds operational complexity: connection lifecycle management, reconnection logic, state synchronization on reconnect, and load balancer configuration. For the MVP, the frontend polls at 30-60 second intervals, which provides adequate data freshness. If sub-second updates become necessary in a future phase, Server-Sent Events (SSE) will be evaluated before WebSocket.
Why Not an ORM?
ORMs introduce an abstraction layer that can obscure query behavior, make performance tuning difficult, and limit access to database-specific features. With sqlx and raw SQL, every query is visible, reviewable, and optimizable. PostgreSQL-specific features like ON CONFLICT and jsonb are used directly without workarounds.
Why NATS Over Kafka?
NATS with JetStream provides persistent message streams with at-least-once delivery, which meets the project's reliability requirements. Compared to Kafka, NATS has a significantly lighter operational footprint: single binary, minimal configuration, lower resource consumption. For the scale of Proxima Console (hundreds to low thousands of agents), NATS is a better fit than Kafka's partition-based architecture.
Why Monorepo?
A single repository for agent, backend, and frontend simplifies dependency management, atomic cross-component changes, and CI/CD configuration. Shared tooling (Makefile, Docker Compose, linting configs) lives in one place. The monorepo does not impose tight coupling; each component has its own build, its own dependencies, and can be deployed independently.
Why Single-Process Backend?
Running the HTTP API and NATS workers in one process reduces operational complexity during the MVP phase. There is one binary to build, deploy, and monitor. The internal architecture (separate packages for API, workers, store) maintains clean boundaries, so splitting into multiple services is a straightforward refactor if scaling demands it.
Why Consumer-Defined Interfaces?
Go's structural typing enables an effective pattern: define interfaces where they are consumed, not where they are implemented. This is the approach used by Kubernetes and other large Go projects.
In Proxima Console, this means:
- The
Publisherinterface lives inagent/internal/metrics/collector.go, where the metrics task consumes it — not in the transport package. - The
MessageProcessorinterface lives inbackend/internal/worker/message.go, where the parent workers consume it — not alongside each sub-worker. - The
staleMarkerinterface is unexported and lives instale.go— the only file that uses it.
Benefits:
- Small interfaces — each has a single method, following the Interface Segregation Principle
- Implicit satisfaction —
*transport.TransportsatisfiesPublisherwithout importing the metrics package - Testability — unit tests use mock structs instead of real infrastructure (no NATS, no database, no VictoriaMetrics)
- Composition root — all concrete wiring happens in
main.go. Deeper packages only see interfaces.