Skip to content

Repository files navigation

Transaction Ingest Service

A microservice that accepts, validates, and durably records billable tolling transactions pushed by roadside lane controllers, image-review vendors, interoperability peers, and batch loaders.

Built against the Transaction Ingest API OpenAPI contract in api/openapi.yaml, which is the source of truth for the wire format — not documentation alongside it.

v0.1.0 — the ingest slice is complete and running: contract-enforced validation, idempotent durable ingest, and a transactional outbox feeding the resolution pipeline. See the changelog.


The problem this solves

A transaction is the atomic unit of revenue in a tolling back office. Ingest has three obligations, and getting any of them wrong costs real money:

Obligation How it is met
Never double-bill Idempotent on (source, source_reference). Producers retry over unreliable links and replay whole files; a retry must never create a second billable record.
Never lose an accepted transaction The transaction row and the event announcing it are committed in a single database transaction, via a transactional outbox.
Always explain a rejection Validation happens in three explicit layers, and a 400 names the field and the reason.

Ingest deliberately does not resolve who owns the vehicle, price the transaction, or collect payment. Those are downstream concerns.


Quick start

The fastest way to see the whole thing work:

git clone git@github.com:gregwinn/EmovisMicroService.git
cd EmovisMicroService
make demo

That builds the images, starts PostgreSQL, applies migrations, runs the API and the outbox relay, then walks the service end to end: a new transaction, a retry, a divergent replay, a schema rejection, a semantic rejection, the stored row, and the event the relay published downstream.

Requirements: Docker for make demo and the integration tests. Go 1.25+ to build and test locally.

Running it directly

make            # list every available target
make ci         # everything CI runs: tidy, contract, lint, cover, build, vuln
make run        # start the API on :8080 (in-memory store, no database needed)

Then, in another shell:

BODY='{
  "source": "lane-controller-07",
  "source_reference": "LC07-20260814-000918",
  "transaction_type": "toll",
  "transaction_time_utc": "2026-08-14T13:45:02Z",
  "base_amount": "12.50",
  "plate": { "number": "ABC1234", "jurisdiction": "TX" }
}'

# 201 — a new billable transaction
curl -s localhost:8080/ingest/v1/transactions -H 'Content-Type: application/json' -d "$BODY" | jq

# 200 with duplicate=true and the same id — the retry created nothing
curl -s localhost:8080/ingest/v1/transactions -H 'Content-Type: application/json' -d "$BODY" | jq

Note: with no DATABASE_URL set the service uses an in-memory store, so nothing survives a restart. It logs a warning saying so. Point DATABASE_URL at PostgreSQL and run make migrate for durable ingest.


Configuration

Every setting is read from the environment at startup and validated once. A misconfigured deployment reports all of its problems on the first boot rather than one per restart.

Variable Default Purpose
SERVICE_NAME transaction-ingest Identity attached to every log record
ENVIRONMENT local Deployment environment label
HTTP_ADDR :8080 Listen address
HTTP_READ_TIMEOUT 5s Request read timeout
HTTP_WRITE_TIMEOUT 10s Response write timeout
HTTP_IDLE_TIMEOUT 120s Keep-alive idle timeout
SHUTDOWN_TIMEOUT 15s Grace period for draining in-flight requests
LOG_LEVEL info debug | info | warn | error
LOG_FORMAT json json | text
TRANSACTION_TYPES toll,violation,fee Accepted billable event types — operator configuration, not a compiled enum
DEFAULT_CURRENCY USD Applied when a producer omits currency
MAX_CLOCK_SKEW 5m How far ahead of now transaction_time_utc may be. There is no bound on the past.
DATABASE_URL (unset) PostgreSQL connection string. Unset falls back to a non-durable in-memory store.
DATABASE_MAX_CONNS 10 Connection pool ceiling
OUTBOX_BATCH_SIZE 100 Events claimed per relay pass
OUTBOX_POLL_INTERVAL 2s Wait after an empty pass; a non-empty pass retries at once
METRICS_ADDR :9090 Where the outbox relay serves /metrics

Documentation

Document For
Architecture How the pieces fit and why they are arranged this way
Flows The same thing in nine diagrams — start here if you think visually
The Domain What a tolling transaction is — written for someone new to tolling
API Behaviour the schema cannot state · runnable requests
Decisions (ADRs) Twelve one-page records of what was decided and what it cost
Assumptions Every call made without you, phrased as the question I'd have asked
Runbook Alert → meaning → action
Security PII handling, trust boundaries, what I'd change before production
AGENTS.md How an AI coding agent should work in this repo — the invariants it must not break
AI workflow How this was actually built with AI, including what went wrong
Contributing Branching, PR expectations, what fails CI

If you only read two ADRs: 0006 is the gap the contract deliberately leaves open and how it is closed; 0007 is why the resolution pipeline cannot miss a billable transaction.

Repository layout

api/            OpenAPI contract — the source of truth for the wire format
cmd/            One directory per binary
  transaction-api/  the HTTP service
  migrate/          database migrations, run as a deploy step
  outbox-relay/     publishes accepted transactions downstream
internal/
  config/       Environment configuration, validated at startup
  httpapi/      Routing, middleware, and HTTP-to-domain adapters
    gen/        Generated from api/openapi.yaml — never edited by hand
  platform/     Cross-cutting concerns: logging, health, metrics
docs/           Architecture, domain notes, runbook, and ADRs

Business rules never live in internal/httpapi. That package translates between the wire contract and the domain, and nothing else.

The contract is executable

api/openapi.yaml drives two things:

  1. Types and routing are generated from it (make generate). The compiler will not let an operation in the spec go unimplemented.
  2. Every inbound request is validated against it at runtime, before any handler sees the body.

Generated output is committed so the repo builds without a codegen toolchain, and CI runs make generate-check to fail the build if it has drifted from the spec. Spec and implementation cannot silently diverge.

Validation failures return the contract's Error shape with a field-level breakdown:

{
  "code": 400,
  "message": "request does not satisfy the API contract",
  "fields": "base_amount: is required; plate.jurisdiction: is required"
}

Observability

The API serves /metrics on its main listener; the relay serves its own on :9090. The metrics are chosen to answer questions an operator actually asks, not to instrument everything countable:

Metric Answers
ingest_transactions_total{source,result} Is a producer failing, and which one?
ingest_validation_failures_total{source,layer,field} Which rule are they breaking?
ingest_divergent_duplicates_total{source} Is someone re-sending a changed amount under a used key? Worth alerting on.
outbox_pending_events · outbox_oldest_pending_age_seconds Is the resolution pipeline hearing about transactions?

That last pair is the SLI for the outbox. The pattern makes lost events impossible by converting them into late ones — these are how you find out they are late.

Logs are JSON with request-id correlation. Plate and transponder values are never logged; a redacting handler enforces it structurally on top of the code simply not logging them, so a future mistake produces [REDACTED] rather than a disclosure.

The local stack

make compose-up     # postgres + migrations + api + relay
make compose-logs   # follow everything
make compose-down   # stop and remove volumes

Images are distroless static and run as non-root — 14.4 MB, no shell, no package manager, no libc. There is nothing in the runtime image to exploit and nothing to patch, which matters for a service on an ingest boundary.

That leaves no curl for a container healthcheck, so the binary probes itself: /app -healthcheck. Kubernetes and ECS talk to /healthz and /readyz directly and need none of that.

Migrations run as their own compose service that exits on completion, mirroring production: a one-off task before the new revision is released, never inside a service's startup path where rolling tasks would race the same DDL.

Development

make test             # race detector on everything
make test-short       # unit tests only — no Docker required
make test-integration # PostgreSQL integration tests via testcontainers
make cover            # coverage report, enforced against a threshold
make lint             # golangci-lint, pinned to the same version CI uses

Integration tests start a real PostgreSQL container with testcontainers, because the behaviour under test is database behaviour — the idempotency guarantee lives in a unique constraint and ON CONFLICT semantics, and a mock would only assert that the code calls the code.

Troubleshooting: unable to find network with name or ID bridge

If Podman is installed alongside Docker Desktop, testcontainers may resolve to the Podman socket, which has no bridge network. Point it at Docker explicitly:

export DOCKER_HOST="unix://$HOME/.docker/run/docker.sock"
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE="/var/run/docker.sock"

Check which endpoint is active with docker context ls.

make ci runs the full pipeline locally. If it passes on your machine it passes in GitHub Actions — the Makefile is what the workflow calls.

Branching

This repo uses git flow:

Branch Purpose
main Production. Tagged releases only.
develop Integration branch. Features merge here first.
feature/* One branch per unit of work.
release/* Release stabilisation.
hotfix/* Urgent fixes off main.

Branches are created with git flow, but merged through GitHub pull requests rather than git flow finish, so every change gets CI and a reviewable diff.


What is built

  • Service scaffolding, configuration, health probes, structured logging
  • CI: lint, race-detector tests with a coverage gate, build, vulnerability and secret scanning
  • OpenAPI-generated types with spec validation enforced at runtime
  • Transaction domain model and semantic validation
  • Idempotent ingest with divergence detection
  • Postgres persistence with database-enforced idempotency
  • Transactional outbox and relay
  • Prometheus metrics and PII-safe logging
  • Docker Compose stack with a one-command end-to-end demo
  • Terraform for AWS ECS Fargate (validated in CI; never applied)
  • Architecture decision records, runbook, and domain guide
  • AI agent configuration (AGENTS.md, .claude/)

Working with AI agents

The brief asked for "whatever configuration you'd use to tell an AI coding agent how to interpret the repository and how it should work in it." That is two things, and both are here.

AGENTS.md is the contract. One file, referenced by CLAUDE.md, so every tool works from the same rules rather than each carrying a drifting copy. It states the invariants that are not style preferences — money is never a float, generated code is never hand-edited, migrations are append-only, identifiers never reach logs — along with the layout, the dependency rule, and what to do when the spec leaves something unspecified.

.claude/ is the tooling. Permission rules that allow the safe commands without prompting and deny edits to generated code outright, a hook that formats Go on save so formatting drift never lands as unrelated diff noise, and slash commands for the workflows with a right answer here — recording an ADR, adding a validation rule, opening a PR that is small enough to review.

docs/ai-workflow.md is the honest account of how this repo was actually built, including five confidently-wrong assumptions the contract's own prose corrected, and the throwaway test that caught two invented facts about a library in four minutes.

Deploying

deploy/terraform/ describes the service on AWS ECS Fargate: ECR, multi-AZ RDS, SQS with a dead-letter queue, an internal ALB, the API and relay services, and the migration task.

Nothing has been applied — no AWS account is attached to a public interview repository. It is formatted and validated in CI, so treat it as the deployment I would propose, reviewable as code.

Its README covers the decisions worth arguing about, and ends with an explicit list of what is still missing before it would be production-ready.

License

MIT

About

A tolling transaction ingest microservice in Go: OpenAPI as an enforced contract, idempotent durable ingest, and a transactional outbox.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages