Software Structure

This page explains how the PackyTrace code is organised: what the repository holds, which technologies each service uses, how a single service is laid out inside, which automated checks keep that layout from decaying, and how the code gets from a commit to a running system.

It is the practical companion to microservices and patterns, which explains why the services are split the way they are. This page explains how the code that implements them is arranged.

1. The repository

packytrace/
├── client/web-app/        # SvelteKit consumer SPA
├── client/brand-app/      # SvelteKit brand dashboard
├── contracts/             # JSON Schemas for events + Go/TS codegen
├── services/              # the eight services, one directory each
├── simulators/            # mock-brand-service, a stand-in for producer APIs
├── packages/              # small shared libraries (GS1 SDK, Go observability helpers)
├── deployment/            # docker-compose, Kubernetes, Prometheus, AWS
├── scripts/               # the checks the Makefile calls
├── go.work                # spans all Go modules
└── docs/                  # this documentation

Services never import another service's domain or application code and integrate only through HTTP or versioned event contracts. The repository currently also shares domain-free GS1 and Go observability libraries under packages/ by accepted decision; the broader “nothing else” wording is pending a maintainer decision. Nothing under contracts/gen or sqlc output is edited by hand because both are regenerated and compared in CI.

2. Technologies, in short

The fleet is polyglot. Go is used where proxying, resilience and event throughput dominate; TypeScript where auth tooling, fast-changing rules and query-shaped work dominate.

The five Go services are api-gateway, passport-service, fridge-service, shopping-list-service and measurement-pipeline. They use the subset of chi, pgx/sqlc, goose and franz-go required by each service. The gateway, for example, has neither a service database nor a Kafka role. Tests use the standard testing package and testify where useful.

The three TypeScript services are identity-service, personalization-service and brand-analytics-service. They use Fastify with schema-validated routes, Kysely over pg through the typed Kysely query builder, the Confluent librdkafka client, and Vitest. Kysely is not a code generator equivalent to sqlc.

Everything shares the same infrastructure: one PostgreSQL instance holding seven schemas behind seven GRANT-restricted roles, one Apache Kafka node in KRaft mode for asynchronous integration facts only, Keycloak for authentication, versioned JSON Schema event contracts, and a common observability surface of /health, /ready and /metrics scraped by Prometheus and drawn by Grafana. Caddy serves the SvelteKit static applications and proxies API traffic to the gateway in deployed environments.

Each technology is discussed on its own in the Technologies section.

3. Inside a service

Every service is built on the same idea: the business logic sits in the middle and knows nothing about HTTP, Postgres or Kafka. The outside world reaches it through adapters, and one composition root is the only place that knows which concrete adapter is which. The two languages express that idea with slightly different folder names.

The style has one name for the whole fleet: ports and adapters, also called hexagonal. The Go and TypeScript trees do not look alike, but that is packaging, not architecture, and the decision records state it explicitly. Two differences are part of the accepted design. Go collapses entities and use cases into one core package, while TypeScript keeps domain/ and application/ apart, which is the separation Clean Architecture draws on top of ports and adapters. And Go declares no interface for the driving side, while TypeScript co-locates one with each use case. Everything else is the same rule in both languages.

Go services

Passport, Fridge and Shopping List use a cmd/server composition root plus one internal/ tree. Their business logic lives in a single core package named after the service, with adapters grouped first by direction and then by technology.

cmd/server/main.go            # composition root: builds adapters, wires them, serves
internal/
  passport/                   # the core: one package for this service
    scan.go catalog.go        #   entities and domain rules
    resolve_scan.go           #   use cases (structs with an Execute method)
    inputs.go                 #   request DTOs and ErrValidation
    ports.go                  #   the interfaces the core needs from the outside
  adapters/
    inbound/http/             # chi router and handlers: they call into the core
    outbound/postgres/        # repositories plus sqlc-generated code
    outbound/kafka/           # the event publisher
    outbound/offapi/          # external-source clients
  config/                     # configuration
  metrics/                    # Prometheus helpers
migrations/                   # embedded SQL

An outbound port is an interface declared in the core, in ports.go, because the core is the package that uses it. The ports are sliced per use case, so a use case depends only on the methods it actually calls:

// internal/passport/ports.go
type CatalogRepository interface {
    GetByGTIN(ctx context.Context, gtin string) (CatalogEntry, error)
}

The adapter is a struct in an outer package that satisfies that interface:

// internal/adapters/outbound/postgres/catalog_repo.go
func (r *CatalogRepo) GetByGTIN(ctx context.Context, gtin string) (passport.CatalogEntry, error) {
    // SQL implementation
}

The inbound side has no port interface. An outbound port is a contract the core declares but does not implement, so an interface is needed to invert the dependency toward the adapter. The inbound contract is a method the core already implements, so wrapping it in an interface would invert nothing. The HTTP handler directly holds the concrete use case and calls h.Resolve.Execute(...).

Two services sit slightly outside this shape. api-gateway is the public edge, not a bounded context, so it has no domain and no use cases: it stays one internal/gateway package with a file per upstream, plus internal/config. measurement-pipeline follows the same inward rule with internal/measurement as its core and Kafka and Postgres adapters around it.

TypeScript services

The TypeScript services express the same rule with folders directly under src/:

src/
  domain/                  # entities, value objects, domain rules: imports nothing
  application/
    ports/                 # outbound ports, one interface per file
    use-cases/             # the services, with their inbound contracts alongside
  adapters/
    inbound/               # the Fastify router and its metrics wiring
    outbound/              # Postgres, Kafka, HTTP clients, the ruleset loader
  config/                  # environment loader
  composition-root.ts      # the only module that names concrete adapters
  server.ts                # thin entrypoint: calls the root, handles signals, listens
tests/                     # mirrors the layers, black-box through the public API

The one difference from the Go form is where the inbound contract lives. A use-case file exports both its request type and the function that runs it, so the inbound adapter imports from application directly rather than from a separate ports folder. Outbound ports stay one per file in application/ports/.

The dependency rule

In both languages, source dependencies point inward only:

  • The core (Go 'internal/', TypeScript domain and application) imports no adapter and no infrastructure. It is the technology-free center.
  • Each adapter implements a port declared in the core and may import the core, including its error sentinels.
  • The inbound and outbound adapters never import each other. They meet only through the core's ports.
  • The composition root imports everything and does the wiring.

Adding an external source normally requires a port implementation and composition-root wiring. If the source introduces new domain semantics or policy, the core and its contract must also change; the adapter boundary prevents provider-specific transport shape from leaking inward.

flowchart LR HTTP[Inbound HTTP adapter] --> Core[Core: entities, use cases, ports] KafkaIn[Inbound Kafka adapter] --> Core Core --> PG[Outbound Postgres adapter] Core --> KafkaOut[Outbound Kafka adapter] Core --> External[Outbound external-source adapter] Root[Composition root] -.->|wires| HTTP Root -.->|wires| Core Root -.->|wires| PG Root -.->|wires| KafkaOut Root -.->|wires| External

Where the tests live

Go tests are colocated with the code they exercise, as external test packages (package passport_test), which is the Go idiom: internal/passport/resolve_scan_test.go sits next to resolve_scan.go. Each test imports the package under test and drives it through its public API only, so tests stay off the production dependency graph. TypeScript tests live in a sibling tests/ tree that mirrors the layers, black-box for the same reason. make test runs both, with the race detector always on for Go.

The testing strategy page covers the test pyramid itself.

Observability endpoints

Every service exposes GET /health as a lightweight liveness check. Services expose GET /ready, but the dependencies it verifies vary: the gateway has no Postgres/Kafka dependency and the current TypeScript services do not all verify Kafka. Treat complete dependency readiness as an explicit implementation gap rather than a fleet-wide guarantee. Services also expose GET /metrics for Prometheus. These endpoints are wired at the inbound adapter and middleware seam, so the core never imports an instrumentation library. Metric labels are bounded (route templates, never raw paths), so no per-scan or per-visitor fact can leak into telemetry.

4. Fitness functions: enforcing the structure

A fitness function is an automated test whose subject is an architectural characteristic rather than a feature. A unit test asks whether a function returns the right number; a fitness function asks whether the system is still modular, still isolated, still fast enough.

The architectural rules of PackyTrace are recorded as ADRs. The checks below automate a subset of those rules; the final subsection lists rules that still depend on review or runtime evidence. make check runs the fast local gate, while workflow path filters and schedules determine when each CI check runs.

Structure and boundaries

  • Core purity, three depguard rules in .golangci.yml, one per Go service using the core layout. The core package of passport-service, fridge-service and shopping-list-service may not import adapters, config or metrics.
  • Adapter isolation, six depguard rules in .golangci.yml. Inbound and outbound adapters may not import each other; they meet through the core's ports.
  • TypeScript layering, five path-scoped no-restricted-imports blocks in eslint.config.js: domain imports nothing, a port imports only domain, a use case never imports adapters or config, and the two adapter directions stay apart.
  • No service-to-service imports: make check-boundaries walks go list -deps for every Go service and fails if its dependency tree reaches another service, and a repo-wide ESLint pattern does the same for TypeScript. The policy for domain-free shared packages is pending a maintainer decision.

Go depguard and TypeScript ESLint implement the same inward-dependency rule for their respective physical layouts.

Artifacts and generated code

  • Contract validity: scripts/validate-contracts.mjs, run by npm run contracts:validate, proves event schemas are well formed and carry the envelope.
  • No generated-code drift: scripts/check-contracts-generated.sh and scripts/check-sqlc-generated.sh regenerate and diff, so a hand edit to contracts/gen or the sqlc output cannot survive a build.
  • Deployables render: make compose-check and make k8s-check build the Compose config and the kustomize overlay.
  • Docs stay current: make docs-build runs MkDocs in strict mode, and scripts/docs-guard.sh fails a feat: pull request that touches no documentation unless it carries the docs-exempt label.
  • Hygiene: make secrets-check runs gitleaks over the whole history, make vuln-check runs govulncheck and npm audit.

Runtime behavior

  • The stack actually starts: scripts/journey-test.sh, run by make journey, brings up the full Compose stack, waits for every container to become healthy and walks one user journey through the gateway.
  • The privacy wall holds: TestPublishGatesOnMinGroupSize in services/measurement-pipeline/internal/measurement/measurement_test.go proves a sub-threshold group is finalized but never published, so no per-scan fact crosses into brand analytics.
  • Latency and availability: the Prometheus recording rules in deployment/prometheus/rules/slo.yml are the one continuous fitness function here. They are evaluated against live traffic rather than at build time, which is the only way to observe a characteristic that exists only under load. See SLOs and SLIs.

What is not automated yet

Recorded here rather than left implied, because an unchecked rule is a rule that will drift:

  • Brand isolation is enforced at runtime by required tenant-scope repository parameters and query predicates. Postgres GRANTs isolate service schemas, not brands within one schema. No repository-wide triggered check proves every tenant query uses trusted token scope.
  • Schema-per-service is likewise enforced only by role GRANTs. A test asserting that each service role is refused a SELECT on a sibling schema would turn the intent into a fitness function.
  • No hand-rolled auth is a review-time rule. A depguard rule denying password-hashing and token-signing packages outside the gateway would make it a build-time one.
  • Performance has no build-time check. The 3-second budget of QAS-1 is observed in production through SLO-1, so a latency regression is caught after deploy, not before. There is no load test in the repository.

5. Build and deployment references

This page owns repository and service structure. The following pages own the operating details:

  • Contributing: branch, pull-request and local validation rules;
  • Development Tooling: workflow, release and generated-artifact checks;
  • Deployment: AWS, Kubernetes and documentation hosting;
  • Runbook: incident diagnosis and recovery commands.