This site uses one functional cookie to keep feature rollouts consistent for you. Nothing is set until you choose. See the privacy notice.
Dev notes
I'm new to Go. I write TypeScript all day, and I've learned the hard way that I don't absorb a language from tutorials — I need a project with real decisions in it. So I picked one where the domain would teach me something too: the thing a fraud or AML platform actually sells, a service that takes a payment transaction and hands back a risk score in milliseconds, with the reasons attached. This write-up is two sets of notes interleaved — the Go I learned and the risk-assessment concepts I learned — in the order the project taught me them. It's all standard library, net/http and no framework, partly because that's what every Go person told me to do and partly because I came to learn the language, not a framework's opinion of it.
First domain lesson: a risk engine doesn't detect fraud, it accumulates suspicion. A rule is one codified suspicion — a single question asked of every transaction, like "is this amount unusually large?" or "have we seen this device before?" No rule proves anything on its own; each is a weak signal, and the weight attached to it says how much you trust that signal relative to the others. The engine's job is to run all of them and add up what fired.
My TypeScript instinct was a base class, or a union type with a switch. Go's answer is a small interface — and Go interfaces are structural, which surprised me: a type implements Rule just by having these methods. There's no implements keyword, so the engine and the rules never have to know about each other.
type Rule interface {
ID() string
Weight() int // score contribution when it fires
Evaluate(ctx context.Context, txn model.Transaction) *model.RuleHit
}The *model.RuleHit return taught me another idiom: Go has no Option type, so a pointer doubles as one. A rule that fires returns a hit with a reason and its weight; a rule that doesn't returns nil. The engine walks the enabled rules, collects the hits, and hands them to the scorer — it never learns a rule's name, so adding the next rule is a new type and nothing else.
func (e *Engine) Score(ctx, txn) model.Score {
var hits []model.RuleHit
for _, r := range e.rules {
if !e.enabled[r.ID()] {
continue
}
if hit := r.Evaluate(ctx, txn); hit != nil {
hits = append(hits, *hit)
}
}
return scoring.Score(txn.ID, hits)
}The four rules I shipped are the beginner set every fraud primer starts with: amount-over-threshold (weight 30), new-device for a user (20), country-differs-from-last-seen (25), and velocity — too many transactions from one device in a window (40). Enablement is a map the engine owns, so toggling a rule at runtime backs the PUT /v1/rules/{id} endpoint: turn the velocity rule off on a live service and watch the scores move.
Second domain lesson: the highest-weight signals in fraud are about behaviour over time, not any single transaction. Velocity — one device firing many transactions in a short window — is the classic card-testing pattern, where someone validates a batch of stolen card numbers with rapid small charges. Three of my four rules are pure functions of one transaction. Velocity has to remember, so it keeps a per-device sliding window of timestamps: age out the old ones, record the new one, fire if the count crossed the limit.
recent := r.seen[txn.DeviceID][:0]
for _, ts := range r.seen[txn.DeviceID] {
if ts.After(cutoff) {
recent = append(recent, ts)
}
}
recent = append(recent, now)
r.seen[txn.DeviceID] = recent(That [:0] is a Go trick I had to look up: reslicing to zero length keeps the backing array, so filtering in place doesn't allocate.)
The Go lesson hiding in this rule is that concurrency isn't opt-in. Go's HTTP server runs every request in its own goroutine, so the moment a rule holds a map, that map is being read and written from many goroutines at once — which in Go is not "probably fine", it's a data race. So the map is guarded by a mutex, and the clock is injected (a now func() time.Time field — no DI framework, just a function) so the sliding window is testable without sleeping. Then I found go test -race, which felt like cheating: a test fires the rule from a pack of goroutines and the runtime itself proves the locking holds.
The scorer is the least Go-interesting code in the project and the most domain-interesting. Weighted additive scoring is the simplest model there is — sum the weights of the hits, clamp to 100 so a pile-up of rules can't overflow the scale — and it's what real platforms start with before anyone says machine learning, because you can explain it to an analyst and a regulator.
total := 0
for _, h := range hits {
total += h.Weight
}
if total > maxScore {
total = maxScore
}
// band: green < 30, amber 30-69, red >= 70The band is where the number becomes a decision: green flows through, amber queues for human review, red gets blocked. And the thresholds are not a technical choice at all — they are the risk appetite of whoever runs the service. Slide the lines down and you block more fraud but generate more false positives, which means declining real customers; slide them up and good customers sail through alongside the fraud you missed. That trade-off is the entire business, which is why the thresholds live as named constants in one place — policy should be one edit, not a hunt through conditionals.
The other domain rule I picked up: never return a bare number. The score carries its hits, so a red transaction says why it's red — which rules fired, each with a reason string. An analyst can action that; a bare 85 they can only argue with.
Scoring in milliseconds only matters if the flag reaches a human or a downstream system while it can still stop something — a fraud alert an hour later is forensics, not prevention. So anything amber or red goes out live over server-sent events at GET /v1/stream, and building the SSE broker behind it is where I finally understood channels: a channel is how goroutines hand values to each other, and select with a default arm is how you refuse to wait.
func (b *Broker) Publish(ev Event) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.subs {
select {
case ch <- ev:
default: // full buffer? skip, don't stall the stream
}
}
}That default arm encodes the one rule that keeps a monitoring feed honest: a publisher must never block. A subscriber whose buffer is full gets skipped, not waited on, so one slow consumer can't hold up the firehose for everyone else — liveness over completeness, which I'd have gotten wrong without reading about the pattern first, because dropping events feels wrong until you realise stalling the stream is worse. Subscribe hands back the channel and an idempotent unsubscribe closure, so a client that disconnects cleans itself out of the fan-out exactly once.
Persistence is an in-memory Store today, sitting behind an interface — and having just learned interfaces on the rules, I could see this is the same move one level up. The rules and handlers only know the interface, so a Postgres implementation (with pgx) drops in later without touching a line of scoring or HTTP. The thing most likely to change — where the data lives — is isolated so changing it is additive. In TypeScript I'd have reached for the same shape with more ceremony; in Go it's the default way to hold a dependency.
To watch it behave under something like real traffic there's a cmd/simulator load CLI that fires synthetic transactions with a configurable fraud rate — bands shifting and the stream lighting up is the closest thing this project has to a demo. And the deploy story was my favourite Go surprise: the whole service compiles to one static binary, so the multi-stage Dockerfile is a few lines with nothing else in the image.
Reading about a scoring engine is one thing; feeding it a transaction is better. This form posts to the real Go service through a small proxy on this site (/api/risk/transactions), so what comes back is the actual engine's answer: the score, the band, and every rule that fired with its reason. The defaults are tuned to trip the amount rule — drop the amount under 5000 and watch it go green. If the backend isn't deployed right now, the demo says so rather than pretending.
Update — September 11, 2026
This page got two changes in one sitting: the whole write-up was recast from the voice of someone who already knew Go into the voice I actually wrote the code in — a Go beginner taking notes on the language and the fraud domain at once — and it grew the demo above, so the service can be poked instead of just described.
The original version read like a staff Go engineer explaining design decisions they'd made a hundred times. I haven't. Every one of those decisions was something I learned the week I made it — that interfaces are structural, that a pointer stands in for an Option type, that an HTTP server puts every request on its own goroutine whether you asked or not. Flattening that into expert prose threw away the most useful thing the page had. The reframe went in test-first like everything else here, which produced the slightly absurd artifact of a failing test demanding humility:
FAIL RiskScoringApiContent > is written as someone new to Go,
not as a Go veteran
AssertionError: expected 0 to be greater than 0The demo posts a transaction and renders the engine's real answer. The browser never talks to the Go service directly — a Next route at /api/risk/transactions forwards to it server-side, which keeps the backend URL in one env var and means the Go service never needs to learn CORS for the sake of one page. What comes back is the same JSON the write-up describes:
{"score": {"value": 50, "band": "amber", "hits": [
{"rule_id": "amount_threshold",
"reason": "amount exceeds threshold", "weight": 30},
{"rule_id": "new_device",
"reason": "device not seen for this user", "weight": 20}]}}The Go service compiles to a static binary in a distroless image, so the deploy target is Railway with nothing but the repo's Dockerfile. Until RISK_API_URL points at that deployment, the proxy answers with the truth instead of a spinner, and the demo shows it verbatim:
{"error": "RISK_API_URL is not configured on this deployment"}I'd rather ship the page in that state than hold it for the deploy — a demo that names its missing dependency is documentation; a spinner that never resolves is a bug report.
Update — September 12, 2026
The service is deployed now — Railway builds the repo's Dockerfile, RISK_API_URL points at it, and the demo above scores against the real engine. It survived about four transactions before the page went down, and the one that killed it was the most innocent input the form can produce.
A $1 payment from a user and device the service had already seen trips nothing: no threshold, no new device, no geo mismatch, no velocity. Score zero, band green — the exact case the demo renders as "no rules fired — this one sails through." Instead the console said:
Uncaught TypeError: can't access property "length", m.score.hits is null
The engine collects hits with var hits []model.RuleHit and appends as rules fire. When none fire, nothing appends, and the slice stays nil — which encoding/json marshals as null, not []. So the same endpoint that returned an array all through testing quietly changed shape on the first all-clear transaction:
{"score":{"transaction_id":"","value":20,"band":"green","hits":[
{"rule_id":"new_device","reason":"device not seen for this user","weight":20}]}}
{"score":{"transaction_id":"","value":0,"band":"green","hits":null}}I never hit it locally because every test transaction was designed to make rules fire — that was the whole point of the demo. The one script nobody wrote was "boring payment, nothing happens," and it took a stranger's idle click about a minute to find it.
The real fix is in the Go service: scoring.Score now turns a nil slice of hits into an empty one, so hits is always a JSON array — the contract the write-up promised all along. But the demo also learned to tolerate null (hits ?? [] at the one place the response enters the page), because a redeploy lag on the backend shouldn't take the write-up down with it. Both sides went in test-first; the failing frontend test is just the crash, replayed on purpose:
stubFetch(200, scoreResponse({ value: 0, band: "green", hits: null }));
// before the fix: TypeError. after: "No rules fired — this one sails through."