Platform Technologies

The shared infrastructure every service runs against. Locally it starts with make up (see the Quick Start).

PostgreSQL

What: Relational database, one instance (v17), holding seven schemas, passport, personalization, fridge, shopping_list, identity, measurement, brand_analytics.

Why: One database engine covers every implemented storage shape: operational records and caches, profiles, identity data, the Fridge and Shopping List event stores, and their projections. Measurement and Brand Analytics own migrations for their durable aggregation and read-model tables. Service-schema ownership is enforced by the database: each service connects with its own role, GRANT-restricted to its schema, so a cross-schema query fails with permission denied. The roles and schemas are provisioned by deployment/postgres/init/01-schemas.sql.

Apache Kafka

What: Distributed event log, run as a single-node KRaft (ZooKeeper-less) broker.

Why: Independently deployed services use a broker for the integration-event catalog, ProductScanned, VerdictComputed, fridge facts, shopping-list facts and consent/erasure events. The event-driven architecture depends on a broker from day one; topic design, partitioning keys, consumer groups, and delivery semantics therefore remain explicit repository concerns. Kafka carries asynchronous facts only; immediate request/response interactions stay synchronous internal REST, declared in each service's openapi.yaml.

Keycloak

What: Self-hosted open-source identity provider speaking OpenID Connect.

Why: PackyTrace handles health-adjacent data, which must not ride on hand-rolled password auth. Keycloak owns password storage, reset, refresh-token rotation, token revocation and session handling. The gateway validates Keycloak-issued JWTs; identity-service keeps only the domain side, Visitor IDs, visitor->account linking, the consent ledger, Brand/BrandUser. Identity is a generic subdomain in the domain model: buy, don't build.

Realm: the packytrace realm is versioned as deployment/keycloak/import/packytrace-realm.json and loaded on boot via start-dev --import-realm (idempotent, an existing realm is skipped). It defines:

  • Clients. packytrace-web, a public SPA client configured for Authorization Code + PKCE, which is the required production browser flow. The current application session proxy uses the identity-service confidential client with Direct Access Grants (ROPC) at POST /api/v1/sessions; this demonstration flow and browser token storage are not production-ready and must be removed before production-user deployment. The client also has a service account whose realm-management roles let it provision users through the Keycloak Admin API on POST /api/v1/accounts.
  • Roles. consumer (in the realm default-roles composite, so every account created via the Admin API receives it) and brand_user (implemented; assigned explicitly, never derived from request data).
  • User profile. The declarative user profile makes firstName/lastName optional (only email is required). PackyTrace accounts are email + an optional display name; the Keycloak default of requiring both names would otherwise trigger a profile-completion required action and block ROPC sign-in for Admin-API-created users.
  • Development users. A demo@packytrace.dev / demo Consumer for local ROPC testing.

Secrets in the import file (the identity-service client secret, the demo password) are development-only and must never be reused in a deployed environment. Local Compose exposes the admin console on :8090.

Event contracts: JSON Schema + quicktype

What: Versioned JSON Schemas in contracts/jsonschema/, with one shared envelope plus one payload schema per published integration event and quicktype generating Go structs and TypeScript types from them.

Why: A polyglot fleet needs one contract source that is native to neither language. JSON Schema keeps the wire format human-readable in Kafka tooling and validates in CI without running a schema registry. Generated types reduce structural drift, but do not prove runtime or semantic conformance. The rules that keep this from becoming lockstep coupling (envelope + payloads only, additive evolution, tolerant readers) are in contracts/README.md.

Producers and consumers. Go services import the generated structs from contracts/gen/go directly through the Go workspace. passport-service, fridge-service, and shopping-list-service attempt best-effort publication after their application work; they have no durable outbox. TypeScript producers build envelopes in their Kafka adapters and validate them with Ajv in tests: identity-service publishes account and consent facts, and personalization-service publishes HealthProfileUpdated, HealthProfileDeleted, and VerdictComputed. personalization-service also consumes ConsentRevoked from identity.facts.v1 on a best-effort basis to erase Health Profiles. The measurement pipeline consumes scan and verdict facts, deduplicates and aggregates them behind the minimum-group-size privacy gate, and publishes BrandMetricBatchPublished. Brand Analytics consumes those batches into its tenant-scoped Postgres read model.

What is a contract?

A contract is an agreed description of the data two services exchange. It answers:

  • What is this message called?
  • Which fields must it contain?
  • What type and meaning does each field have?
  • Which version of the message is being sent?

For example, after passport-service resolves a scan, it publishes a ProductScanned event. Its contract requires a scanId, visitorId, gtin and brandId; consumerRef is optional because the visitor may be anonymous. A consumer can process that event without importing code from passport-service or knowing how the scan was resolved.

flowchart LR P[passport-service] -->|ProductScanned JSON| K[Kafka] K --> M[measurement-pipeline] C[JSON Schema contract] -. validates .-> P C -. validates .-> M

The contracts/ directory is therefore a small shared vocabulary, not a shared domain model. It contains only messages that cross service boundaries:

  • jsonschema/envelope.schema.json defines metadata common to every event, such as eventId, eventType, occurredAt and schemaVersion.
  • jsonschema/events/v1/ defines the payload of each event.
  • gen/go/ and gen/ts/ contain generated language types so producers and consumers do not manually recreate the schemas.

Changing a contract can affect every service that consumes it. Compatible additions are optional fields; incompatible changes require a new version. Internal entities, database rows and service-specific implementation details do not belong in contracts.

Event contracts describe asynchronous Kafka messages. HTTP request and response contracts serve the same purpose for REST APIs, but they are described separately with OpenAPI.

OpenAPI

What: Each service describes its HTTP surface in 'services//openapi.yaml' (OpenAPI 3.1), which is the authoritative source for that service's contract.

Why: A spec beside its service is easier to review with the implementation than separate prose, but proximity and linting do not prove handler conformance. The per-service specs describe public paths routed to that service, internal endpoints, and schemas. The gateway's spec documents its /health, /ready, and /metrics endpoints; proxied service routes remain in the owning service specifications. Specs are linted with Redocly CLI.

Docker & Compose

What: A multi-stage Dockerfile per service (Go: build -> Alpine; TypeScript: build -> node:24-alpine) and deployment/docker-compose.yml orchestrating all eight services plus Postgres, Kafka and Keycloak with healthchecks.

Why: Container-per-service deployment keeps the whole system reproducible with one command (make up). Every image builds from the repository root so services can import the shared generated contracts.

Observability and orchestration

Prometheus, Grafana and the local Kubernetes reference are implemented. Their current scope and known gaps are documented under Observability and Kubernetes deployment. CPU-based HPA configuration is not described as SLO-driven scaling without load-test evidence.