Skip to main content

Technical Decisions

This page documents the key technical decisions made for the Proxima Console project, along with the rationale behind each choice.

Decision Log

DecisionChoiceRationale
API frameworkchi routerLightweight, composable, and fully compatible with Go's net/http stdlib. No heavy framework overhead.
Database accesssqlx (raw SQL)Full control over query construction and performance. No ORM abstraction layer to debug through. Straightforward use of PostgreSQL-specific features.
Loggingslog (stdlib)Structured JSON logging with zero external dependencies. Part of Go's standard library since Go 1.21.
MessagingNATS with JetStreamPersistent streams with at-least-once delivery, request-reply patterns, and a lightweight operational footprint compared to alternatives like Kafka.
Frontend frameworkReact + TypeScriptTeam familiarity, mature ecosystem, strong TypeScript integration, and wide availability of developers.
UI componentsshadcn/ui + TailwindCopy-paste component model provides full ownership and customization. No version lock-in to a component library. Tailwind enables rapid, consistent styling.
Real-time updatesREST + pollingSimpler to implement, debug, and operate than WebSocket connections. Sufficient data freshness for the MVP use cases (30-60 second polling intervals).
Config managementEnv vars + YAMLTwelve-Factor App compliant. Environment variables for deployment-specific and secret values, YAML files for application defaults.
Error handlingSentinel errors + %w wrappingType-safe error checking with errors.Is() and errors.As(). Context-preserving error chains via fmt.Errorf("context: %w", err).
Migrationsgolang-migrateSimple up/down SQL migration files. No code generation or DSL. Migrations are plain SQL, reviewable in merge requests.
Container scanningTrivyOpen-source vulnerability scanner with broad CVE database coverage. Integrates directly into CI pipelines.
Agent distributionCloudflare R2S3-compatible object storage with global edge delivery. Cost-effective for distributing agent binaries to geographically distributed servers.
Interface patternConsumer-definedInterfaces 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 Publisher interface lives in agent/internal/metrics/collector.go, where the metrics task consumes it — not in the transport package.
  • The MessageProcessor interface lives in backend/internal/worker/message.go, where the parent workers consume it — not alongside each sub-worker.
  • The staleMarker interface is unexported and lives in stale.go — the only file that uses it.

Benefits:

  • Small interfaces — each has a single method, following the Interface Segregation Principle
  • Implicit satisfaction*transport.Transport satisfies Publisher without 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.