Skip to main content

How we design systems

The principles and defaults we apply when building services at GoodFit. Treat these as the path of least resistance — deviate when there's a good reason and document it.

Monorepo

We work in a monorepo. The main React app lives in the monorepo alongside backend services, so we can share code and types end-to-end and ship full features in a single PR.

Key rule: services may only import from commons modules — never directly from another service. If you're tempted to cross that line, create a shared commons module instead.

See: Code & repos.

Serverless first

  • Default: Serverless framework with AWS Lambda, SQS and Step Functions.
  • Exception — appBackend: ECS with Docker, for easier local testing and to avoid Lambda cold-start latency on user-facing traffic.

See: Node / Serverless setup.

Secrets and configuration

  • Secrets → AWS Secrets Manager. Reference directly from serverless.ts.
  • Non-sensitive config → Systems Manager Parameter Store. Same pattern.
  • At runtime → injected as environment variables into the service.

Infrastructure

  • Shared infrastructure — defined in CDK in gf-infrastructure. Used by multiple services.
  • Service-owned infrastructure — defined in that service's serverless.ts.
  • Known exception: crawler ECS infrastructure is structured incorrectly for historic reasons.

See: App Environments.

Storage and state

Pick storage based on data size and access pattern:

  • S3 — long-term archive, and temporary working data (uploads, intermediate files). Always attach a retention policy.
  • Postgres — small datasets needing fast access, including anything serving interactive requests.
  • Redshift — large datasets where 10s+ access latency is acceptable.

Think multi-tenant

Every architectural decision has to account for tenant isolation:

  • Consistent experience — one client's behaviour mustn't degrade another's.
  • Resource isolation — misconfigured, unexpected, or high-scale usage by one client mustn't impact others.
  • Auth on every API — all client APIs must be authenticated, and every call must verify the user has access to the requested record. Shared tables are fine; missing auth checks aren't.
  • Use 404 instead of 403 when a user requests a record that exists but belongs to another tenant — 403 leaks the existence of the record.
  • Shared queues are risky — if one client can fill a shared queue, they can block or slow others. Scope per-tenant where possible.

Step Functions for multi-step workflows

Step Functions are the preferred tool for orchestrating multi-step work. They give us good observability and retry semantics for free.

  • Scope to a single client where possible (e.g. build dataset, update segment, market preview). This lets workflows run in parallel without cross-tenant impact.
  • When to reach for them — if you're designing a system with more than one stage and more than one queue, Step Functions is probably a better fit.
  • Distributed Map — use it for large-scale fan-out (hundreds of S3 files, multi-line file processing).
  • State size limit — 256KB per execution. For larger payloads, write to S3 (with retention) and pass the S3 URL through state.

Development cycle

Run services locally against shared dev resources where possible:

  • Frontend — point the React app at the dev environment for frontend-only changes, or at a local appBackend Express instance when backend changes are involved.
  • Backend (appBackend) — run locally against dev's Postgres. Apply migrations and deploy WIP to dev whenever you need.
  • Serverless functions — for simple cases, sls invoke local -f <function> --stage=dev -d '{}'. For Step Functions or anything stateful, deploy straight to dev with sls deploy --stage=dev or sls deploy function -f <function> --stage=dev.

See: Local database and test setup, App Environments, Node / Serverless setup.

Observability

Actively evolving — here's where we are today:

  • CloudWatch logs (console.log) — basic logging. Mind payload size for cost.
  • New Relic (via the Logger helper) — events and metrics.

See: New Relic.

Security

Defence in depth, multiple layers:

  • Network isolation — services deploy into the VPC's private subnet by default. Public-facing ones go through API Gateway or an ECS load balancer.
  • Databases — never publicly accessible. Behind VPC firewalls; access via tunnelling. See Accessing databases via port forwarding.
  • Security monitoring#eng-security-alerts carries Aikido alerts (dependencies, Docker images, AWS misconfigurations).
  • SQL injection — all SQL must use the sqlT helper. Never raw strings. See SQL templating.
  • Input validationzod for parsing and validating user input, wherever practical.