This repository is a polyglot microservices demo for the NAIS platform. Four services communicate over HTTP, deployed with NAIS manifests, and instrumented with OpenTelemetry.
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ quotes-frontend │────▶│ quotes-backend │◀────│quotes-analytics │
│ Next.js 16 │ │ Kotlin/Ktor 3 │ │ .NET 10 │
│ Port 3000 │ │ Port 8080 │ │ Port 8081 │
└─────────────────┘ └────────┬────────┘ └─────────────────┘
│
┌────────▼────────┐
│ PostgreSQL 16 │
└─────────────────┘
quotes-loadgen (Go) generates traffic against the frontend.
- Service-to-service URLs use plain HTTP without ports:
http://quotes-backend,http://quotes-analytics - NAIS
accessPolicyin.nais/app.yamlcontrols allowed traffic - OpenTelemetry auto-instrumentation — do not add manual instrumentation in production code
# Full CI for all services (lint → test → build)
mise run ci
# Per-service CI
cd quotes-frontend && mise run ci
cd quotes-backend && mise run ci
cd quotes-analytics && mise run ci
cd quotes-loadgen && mise run ci
# Start everything locally (requires Docker for infra)
mise run dev
# Start infra only, run services manually
mise run dev:manual| Service | Language | Build | Test | Lint |
|---|---|---|---|---|
| quotes-frontend | TypeScript (Next.js 16) | pnpm build |
vitest run |
eslint . |
| quotes-backend | Kotlin 2 (Ktor 3, Exposed) | ./gradlew build |
./gradlew test |
./gradlew ktlintCheck |
| quotes-analytics | C# (.NET 10) | dotnet build |
dotnet test |
dotnet format --verify-no-changes |
| quotes-loadgen | Go 1.24 | go build ./cmd/main.go |
go test ./... |
golangci-lint run |
- Run
mise run ci(or per-service equivalent) before considering work done - Follow existing patterns in each service — match the style you see, not a generic style guide
- Use environment variables for configuration; never hardcode secrets or URLs
- Keep service URLs portable:
http://quotes-backend(no ports, no localhost) - Update
.nais/app.yamlwhen adding env vars, changing ports, or modifying health paths - Use
ConcurrentDictionary(notDictionary) for any shared mutable state in .NET - Escape user input in SQL — e.g.
%and_in LIKE clauses (backend uses Exposed ORM) - Write deterministic tests — mock external HTTP calls, don't accept multiple status codes
- Adding new dependencies — justify why, check if an existing dep already covers it
- Changing Dockerfile base images or OpenTelemetry versions
- Modifying CI workflows (
.github/workflows/) - Altering
accessPolicyin.nais/app.yaml
- Add manual OpenTelemetry instrumentation — NAIS auto-instrumentation handles this
- Commit
.envfiles, secrets, or credentials - Hardcode
localhostor port numbers in service-to-service calls - Skip tests to make CI pass
- Path alias:
@/maps to./src/ - API routes live in
src/app/api/(Next.js App Router) - Tests in
src/__tests__/using Vitest + React Testing Library output: 'standalone'innext.config.js— required for the distroless Docker image- Use
pinofor logging (server-side via@navikt/next-logger)
// ✅ Import from path alias
import { getQuotes } from "@/utils/apiClient";
// ❌ Relative paths across directories
import { getQuotes } from "../../../utils/apiClient";- JVM toolchain: Java 21
- Ktor 3 with kotlinx.serialization (not Jackson)
- Database: Exposed ORM with PostgreSQL (H2 for tests)
- Tests use
testApplication { }from Ktor test host
// ✅ Escape LIKE wildcards from user input
val escaped = query.replace("%", "\\%").replace("_", "\\_")
QuotesTable.select { QuotesTable.text like "%$escaped%" }
// ❌ Raw user input in SQL
QuotesTable.select { QuotesTable.text like "%$query%" }- .NET 10 with top-level
Program.cs - Tests: xUnit + FluentAssertions + Moq
HttpClientinjected viaAddHttpClient<QuotesAnalyticsService>- Mock the
HttpMessageHandlerin tests, not theHttpClient
// ✅ Thread-safe cache
private readonly ConcurrentDictionary<string, QuoteAnalytics> _cache = new();
// ❌ Not thread-safe under concurrent requests
private readonly Dictionary<string, QuoteAnalytics> _cache = new();- Standard project layout:
cmd/for entrypoint,internal/for packages - Uses Cobra for CLI
CGO_ENABLED=0for static binaries in Docker
quotes-<service>/
├── .mise.toml # Tool versions + tasks (dev, test, ci, build)
├── .nais/app.yaml # NAIS deployment manifest
├── Dockerfile # Multi-stage build
├── README.md # Service-specific docs
└── ... # Language-specific source layout
- Each service is self-contained with its own
.mise.toml,Dockerfile, and.nais/app.yaml - Root
.mise.tomlorchestrates cross-service tasks (mise run dev,mise run ci) docker-compose.yamlprovides local infrastructure (PostgreSQL, Unleash, Grafana/OTEL)
Feature flags use Unleash via the NAIS platform.
Architecture:
- Each service has
.nais/unleash.yamlthat provisions an API token as a Kubernetes secret envFromin.nais/app.yamlinjectsUNLEASH_SERVER_API_URL,UNLEASH_SERVER_API_TOKEN,UNLEASH_SERVER_API_ENVIRONMENT- Backend uses
io.getunleash:unleash-client-java, frontend usesunleash-client(Node.js) - When Unleash is unavailable, flags fall back to their configured default values (graceful degradation)
Current flags:
quotes.submit— gates the "Submit a New Quote" feature (backend + frontend)quotes.errors— enables simulated error injection in the backend (default: disabled)
Patterns:
// Kotlin: check flag, default true when Unleash is unavailable
FeatureFlags.isEnabled(FeatureFlags.QUOTES_SUBMIT)
// Kotlin: error injection flag, default false (opt-in)
FeatureFlags.isEnabled(FeatureFlags.QUOTES_ERRORS, default = false)
// Kotlin: get variant for A/B testing
val variant = FeatureFlags.getVariant(FeatureFlags.QUOTES_SUBMIT)
if (variant.name != "disabled") { /* use variant */ }// TypeScript (server-side API routes): check flag
import { isEnabled, getVariant, FEATURE_FLAGS } from '@/utils/unleash';
isEnabled(FEATURE_FLAGS.QUOTES_SUBMIT);
// TypeScript: get variant
const variant = getVariant(FEATURE_FLAGS.QUOTES_SUBMIT);Impression data:
- Impression data and usage metrics are sent to Unleash automatically by the SDK
- Enable impression data per-toggle in the Unleash admin UI — metrics appear in the Unleash "Metrics" tab
GET /api/featuresreturns{ "flag": { "enabled": true, "variant": { "name": "..." } } }
Rules:
- Register flag names as constants in
FeatureFlags.kt/unleash.ts - Define per-flag defaults (e.g.,
quotes.submitdefaultstrue,quotes.errorsdefaultsfalse) - Use
GET /api/features(backend or frontend) to inspect flag states and variants - Local Unleash admin UI:
http://localhost:4242(started viamise run infra:up)