Analysis App
A data analysis application built with Next.js, Go and Python.
Background
In physics lab classes, every unit required plotting and editing several graphs by hand. The work was repetitive and slow, and it sapped any motivation to write the report. I built this app to make plotting quick so that the reports themselves could be better.
What I Focused On
Graphs can be manipulated directly, so each experiment’s plot can be shaped to fit what that experiment actually needs. Caching and a database keep the data around between sessions.
Looking Back
Handling the database, the cache and the logs was the hard part. Deciding how much data to keep and how long to keep it — while leaving room for future changes — turned out to be difficult, but it taught me a lot.
Technical DetailsOpenClose
Overall Structure
Three services live in a single repository.
flowchart TB
B["Browser"] --> F["Next.js / App Router"]
F -->|"REST with JWT"| A["Go API"]
A --> P[("Supabase Postgres")]
A -.->|"best-effort"| R[("Redis")]
A -->|"internal HTTP"| W["Python worker / FastAPI"]
Only Next.js is reachable from outside; there is no path from the browser straight to the Go API. Every call into the Go API starts from a server component, a server action or a route handler, and that is where the JWT from the Supabase session is attached. The Python worker sits further in: it has no auth and no database, and is only ever called by the Go API.
The split of responsibilities is that Go owns the entrance, authorization and persistence, Python does the numerical analysis, and Next.js does the rendering. The worker returns numbers only and never draws a graph. That lets the same analysis result be presented in different ways, and it keeps client-side concerns — switching to a log axis, for example — out of the server.
Analysis Requests and Caching
sequenceDiagram
participant F as Next.js
participant A as Go API
participant R as Redis
participant W as worker
F->>A: POST analysis request
A->>R: look up cache
alt hit
R-->>A: stored result
else miss
A->>W: analyze over internal HTTP
W-->>A: numbers only
A->>R: store for 24 hours
end
A-->>F: data / error / meta
Cache keys have the shape analysis:<experiment id>:<analysis type>:<hash of parameters>. Parameters come as a map with no defined order, so rather than embedding them directly they are serialized to JSON and hashed with SHA-256. Go sorts map keys when marshalling to JSON, which makes the key independent of iteration order.
The cache is best-effort: if Redis returns an error it is treated exactly like a miss and the request continues. When it goes down I wanted only speed to suffer, never correctness. The flip side is that when the experiment data itself is edited, a stale result can survive for up to 24 hours, because the key does not include a hash of the data — so in that case every key for that experiment is deleted together by prefix.
Seams for Testing
Handlers receive interfaces rather than concrete types. There are four of them.
| Interface | Production implementation |
|---|---|
experiments.Store |
pgx repository |
projects.Store |
pgx repository |
cache.Cache |
Redis implementation |
worker.Client |
HTTP client |
These four are the seams, and handler tests run without Postgres, Redis or the worker. When adding I/O, the interface has to be defined before it is passed in — skip that order and the tests need real infrastructure from that moment on.
Analysis types are looked up through a registry on the Python side. Adding a new analysis means creating one file and decorating it with @register("type name"); nothing in Go or the frontend has to change.
Direction of Frontend Dependencies
Dependencies run one way, app/ → components/ → lib/, and lib/ never imports from components/.
lib/ holds pure functions whose output is determined by their input (the only exceptions being API calls and creating the Supabase client): log-scale ticks, axis ranges, regression scoring, ±1σ bands, significant figures, unit conversion, CSV parsing. Components only call them. That separation is what makes the logic testable without rendering — calculations written inside a useMemo are especially easy to miss because they can’t be reached without rendering, so I move them out into lib/ whenever I find one.
Authorization
Auth middleware is applied across everything under /api/v1, and only requests that pass it carry a user_id. That keeps authorization decisions in exactly one place.
Repository SQL always filters by user_id, and “does not exist” and “belongs to someone else” deliberately return the same Not Found. Distinguishing them would leak the fact that another user’s data exists at all.