Files
The-transit-Project/.github/agents/shared-references/service-abstraction.md
T
Sven laptop 635a130f0b
Build and Push Docker Images / detect-changes (push) Successful in 11s
Build and Push Docker Images / build-auth-api (push) Successful in 1m0s
Build and Push Docker Images / build-employee-api (push) Successful in 1m2s
Build and Push Docker Images / build-transit-api (push) Successful in 1m1s
Build and Push Docker Images / build-error-log-api (push) Successful in 1m5s
Build and Push Docker Images / build-admin-ui (push) Successful in 1m13s
Build and Push Docker Images / build-monitoring-ui (push) Successful in 1m15s
Build and Push Docker Images / deploy-test-node (push) Successful in 3s
──────
### Summary of Changes
  1. Documentation Consolidation:
      • Relocated the docs/ folder and all repository .md files (README.md, DEPLOYMENT.md, DOCKER_README.md,
      DOMAINS_README.md, GETTING_STARTED.md, CI_CD_PORTAINER_GUIDE.md, ADMIN_DEPLOYMENT_SUMMARY.md,
      SECURITY_PRIVATE_FILES.md, handoff.md, azure-project-plan.md, plus service-specific READMEs) into .
      • Updated document links in README.md.
  2. AI Agent Project Map:
      • Created PROJECT_MAP.md in the project root containing complete architecture diagrams, service specs, ports, DB
      schemas, environment variables, and developer workflows.
      • Added PROJECT_MAP.md to .gitignore.
  3. Monitoring UI & Infrastructure Integration:
      • Added monitoring_ui service to docker-compose.yml (port 5175).
      • Configured Gitea workflow docker-build.yml for monitoring_ui builds.
      • Added CORS middleware headers in index.js.
      • Added nginx.conf copy step in Dockerfile.

  ──────
  ### Recommended Commit Message

    docs: consolidate project documentation into monitoring_ui and add agent project map

    - Move all markdown documentation files and docs/ folder into monitoring_ui/docs/
    - Update documentation hub index (monitoring_ui/docs/README.md) with relative paths
    - Generate comprehensive AI agent reference map (PROJECT_MAP.md) in repository root
    - Add PROJECT_MAP.md and monitoring_ui build output/env overrides to .gitignore
    - Integrate monitoring_ui service into docker-compose.yml and Gitea CI workflow
    - Configure CORS handling in employee_api for monitoring dashboard access
    ──────
  ### Alternative Short Commit Message

    refactor(docs): move docs to monitoring_ui and create AI agent PROJECT_MAP.md
2026-09-20 23:33:55 +02:00

16 KiB

Important: This skill provides guidance and recommended instructions to assist the AI system. Outputs are not guaranteed to be complete, correct, secure, or applicable to every scenario. Results should be reviewed and validated by a human before being applied. The AI model may choose not to follow all instructions exactly, and additional verification may be required.

Service Abstraction Layer

Patterns for testable code that works against local mocks and live Azure services with zero code changes.


Core Principle

Keep two concerns separate:

  • Application user authentication exists only when the plan says API Login: Yes. It authenticates people using the application.
  • Azure client authentication is never a product choice. Backend-to-Azure communication uses emulator clients in explicit development and managed identity everywhere else.

Both concerns use small application-facing interfaces. Function handlers NEVER import Azure SDKs directly, construct credentials, or parse login tokens themselves. They receive auth services and Azure clients through dependency injection.

⚠️ Auto-initialization requirement: Service registry's getServices() MUST auto-initialize with concrete implementations at runtime. User runs func start after npm run build — no manual registerServices() call, no startup script. Tests override via registerServices() with mocks before each test.

⚠️ camelCase↔snake_case conversion requirement: TypeScript entities use camelCase (displayName, coupleId) but PostgreSQL columns use snake_case (display_name, couple_id). Concrete database service MUST handle conversion automatically — snake_case for outbound SQL, camelCase for inbound results. Mock database does NOT enforce this (uses plain Maps), so mismatch only surfaces at runtime against real database. Conversion must be built into concrete implementation.


Architecture

┌─────────────────────────────────────────────────┐
│              Function Handler                    │
│  (receives services — no SDK imports)            │
├─────────────────────────────────────────────────┤
│          Application-facing interfaces           │
│  AuthService  │  BlobClientProvider  │  Database │
├───────────────┼──────────────────────┼───────────┤
│ User sessions │ Local / managed ID   │ Data impl │
└───────────────┴──────────────────────┴───────────┘

API login contract

Read the API Login value from the approved plan:

  • No: do not scaffold user login, token middleware, login UI, or mock users.
  • Yes: define a small auth interface for the behavior the application needs, then scaffold login end to end across the frontend and API.

If the workspace already has an auth system, preserve it. Otherwise use a stack-appropriate REST API flow. A typical implementation verifies a stored password hash, issues a short-lived signed JWT, and verifies its signature, algorithm, issuer, audience, and expiry before protected requests. Put that behavior behind an interface such as login(credentials), authenticate(request), and getCurrentUser(). Require signing configuration in production. Never hard-code a signing secret or generate a new production secret at startup.

Managed identity does not authenticate application users. Never use a managed identity access token as a user session.

Azure client provider contract

Apply this pattern to every Azure service client:

  1. Define the smallest interface that expresses the client behavior the application needs. Do not expose SDK types unless the application genuinely operates on them.
  2. Implement it once for the local emulator and once for Azure with managed identity. Tests may register an in-memory implementation independently.
  3. Select the implementation once in the startup composition root, then inject it into handlers and domain services.
  4. Select local only when the platform environment is exactly Development. Missing, empty, misspelled, staging, and production values all select the managed-identity implementation.
  5. Validate the selected implementation before the app serves requests. Invalid production endpoint configuration must stop startup. Never recover by selecting an emulator, account key, SAS token, API key, or secret-bearing connection string.
  6. Keep environment checks out of the rest of the codebase.
  7. Keep the interface, both implementations, and factory together or under one provider folder. Use explicit names, document endpoints in .env.example, and put "AZURE_FUNCTIONS_ENVIRONMENT": "Development" in local.settings.json.

This Blob Storage example shows the shape:

import { DefaultAzureCredential } from '@azure/identity';
import { BlobServiceClient } from '@azure/storage-blob';

export interface BlobClientProvider {
  getClient(): BlobServiceClient;
}

export class AzuriteBlobClientProvider implements BlobClientProvider {
  getClient(): BlobServiceClient {
    return BlobServiceClient.fromConnectionString(
      requireSetting('AZURITE_CONNECTION_STRING'),
    );
  }
}

export class ManagedIdentityBlobClientProvider implements BlobClientProvider {
  getClient(): BlobServiceClient {
    const account = requireSetting('AZURE_STORAGE_ACCOUNT');
    return new BlobServiceClient(
      `https://${account}.blob.core.windows.net`,
      new DefaultAzureCredential(),
    );
  }
}

export function createBlobClientProvider(): BlobClientProvider {
  if (process.env.AZURE_FUNCTIONS_ENVIRONMENT === 'Development') {
    return new AzuriteBlobClientProvider();
  }

  return new ManagedIdentityBlobClientProvider();
}

Call the factory from the composition root, not from request handlers. Mirror the same boundary in Python with a Protocol and in .NET with an interface plus startup DI registration. Use the equivalent managed-identity credential for every other Azure client.


TypeScript Patterns

Service Interface

// services/interfaces/IDatabaseService.ts
export interface IDatabaseService {
  findAll<T>(collection: string, options?: QueryOptions): Promise<T[]>;
  findById<T>(collection: string, id: string): Promise<T | null>;
  findOne<T>(collection: string, filter: Record<string, unknown>): Promise<T | null>;
  create<T>(collection: string, data: T): Promise<T>;
  update<T>(collection: string, id: string, data: Partial<T>): Promise<T | null>;
  delete(collection: string, id: string): Promise<boolean>;
  count(collection: string, filter?: Record<string, unknown>): Promise<number>;
  healthCheck(): Promise<boolean>;

  // Execute multiple operations atomically — all succeed or all rollback.
  // The callback receives a transactional IDatabaseService scoped to the transaction.
  transaction<T>(fn: (trx: IDatabaseService) => Promise<T>): Promise<T>;
}

export interface QueryOptions {
  limit?: number;
  offset?: number;
  orderBy?: string;
  orderDirection?: 'asc' | 'desc';
  filter?: Record<string, unknown>;
}
// services/interfaces/IStorageService.ts
export interface IStorageService {
  upload(container: string, name: string, data: Buffer, contentType?: string): Promise<string>;
  download(container: string, name: string): Promise<Buffer>;
  list(container: string): Promise<string[]>;
  delete(container: string, name: string): Promise<void>;
  healthCheck(): Promise<boolean>;
}
// services/interfaces/ICacheService.ts
export interface ICacheService {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
  delete(key: string): Promise<void>;
  clear(pattern: string): Promise<void>;
  healthCheck(): Promise<boolean>;
}

Config Module with Environment Validation

⚠️ Use flat config structure (not nested objects). Canonical shape — tests and implementation must agree. Flat fields are simpler (config.databaseUrl not config.database.url), avoid ambiguity when multiple agents scaffold independently.

Only list env vars the project uses. Provider factories validate the settings needed by the selected implementation. Enhancement service vars (e.g., AZURE_OPENAI_ENDPOINT) are NOT required — accessed via process.env directly, may be undefined.

// services/config.ts
export interface AppConfig {
  environment: string;
  azureOpenAiEndpoint: string | undefined;  // Optional Enhancement service
}

export function requireSetting(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

export function loadConfig(): AppConfig {
  return {
    environment: process.env.AZURE_FUNCTIONS_ENVIRONMENT ?? 'Production',
    azureOpenAiEndpoint: process.env.AZURE_OPENAI_ENDPOINT,
  };
}

Provider constructors or factories call requireSetting for the selected implementation's settings. For example, a local PostgreSQL provider may require DATABASE_URL, while its production provider requires the Azure PostgreSQL host/database names and obtains an Entra access token with DefaultAzureCredential. Do not require local emulator settings in production or production resource settings in explicit development mode. The default environment is Production, never Development.

Concrete Implementation (PostgreSQL Example)

Important

: Includes camelCase↔snake_case key conversion and collectionToTable() mapping. See examples/service-abstraction-examples.md for complete implementation.

Key requirements for concrete implementation:

  • toSnake()/toCamel()/keysToSnake()/keysToCamel() conversion utilities
  • collectionToTable() mapping singular collection names to plural SQL table names (e.g., user → users)
  • create() and update() strip auto-managed fields (createdAt, updatedAt, id) before building SQL
  • transaction() uses BEGIN/COMMIT/ROLLBACK with pooled client
  • healthCheck() executes SELECT 1 wrapped in try/catch

Mock Implementation (For Tests)

See examples/service-abstraction-examples.md for complete MockDatabaseService.

Key requirements for mock:

  • In-memory Map<string, Map<string, unknown>> storage (collection → id → item)
  • Constructor accepts optional Record<string, unknown[]> for initial test data
  • findOne() iterates store values and matches all filter key-value pairs
  • update() auto-sets updatedAt timestamp
  • transaction() executes callback directly (no real transaction for unit tests)
  • Must replicate same implicit behaviors as concrete (field stripping, timestamp handling)

Service Registry (DI)

Critical: Registry MUST auto-initialize with concrete implementations at runtime. func start must work without manual registerServices() call. Tests pre-register mocks via setup.ts, overriding auto-initialization.

⚠️ getServices() MUST lazily call initializeServices() when services === null. Registry that throws "Services not initialized" when none pre-registered is BROKEN — func start will crash on every request. Correct behavior: if services is null, construct concrete implementations from config and cache them.

⚠️ Enhancement service safety: Enhancement services MUST be wrapped in try/catch during construction. If constructor throws, registry must substitute no-op fallback — NOT crash all handlers.

See examples/service-abstraction-examples.md for complete registry pattern.

Key requirements for the registry:

  • registerServices(registry) — stores provided services (used by tests)
  • getServices() — returns services; auto-initializes if none registered
  • clearServices() — resets to null (used in test teardown)
  • initializeServices() — selects local or production providers once, validates them, and creates concrete instances; Essential services can throw, Enhancement services use an explicit no-op fallback rather than a local implementation

Usage in Function Handlers

// functions/getItems.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { getServices } from '../services/registry';
import { handleError } from '../errors/errorHandler';
import { Item } from '../../shared/types/entities';

app.http('getItems', {
  methods: ['GET'],
  authLevel: 'anonymous',
  route: 'items',
  handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
    try {
      const { database } = getServices();
      const limit = Number(request.query.get('limit')) || 20;
      const offset = Number(request.query.get('offset')) || 0;

      const items = await database.findAll<Item>('items', { limit, offset });
      return { jsonBody: { items, total: items.length } };
    } catch (error) {
      return handleError(error, context);
    }
  }
});

Python and C# Patterns

For Python service abstraction patterns (Protocol interfaces, config, mock implementations, registry), see runtimes/python.md. For C# (.NET) patterns (interfaces, DI registration, mock implementations), see runtimes/dotnet.md.


Testing Service Abstractions

Every service implementation (real and mock) should be tested:

// tests/services/registry.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { registerServices, getServices, clearServices } from '../../src/services/registry';
import { MockDatabaseService } from '../mocks/mockDatabase';
import { MockStorageService } from '../mocks/mockStorage';
import { MockCacheService } from '../mocks/mockCache';

describe('ServiceRegistry', () => {
  beforeEach(() => {
    clearServices();
  });

  afterEach(() => {
    clearServices();
  });

  it('should return registered mock services (not auto-initialized ones)', () => {
    const registry = {
      database: new MockDatabaseService(),
      storage: new MockStorageService(),
      cache: new MockCacheService(),
    };
    registerServices(registry);

    const services = getServices();
    expect(services.database).toBe(registry.database);
    expect(services.storage).toBe(registry.storage);
    expect(services.cache).toBe(registry.cache);
  });

  it('should allow re-registration after clearServices', () => {
    const first = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() };
    const second = { database: new MockDatabaseService(), storage: new MockStorageService(), cache: new MockCacheService() };

    registerServices(first);
    clearServices();
    registerServices(second);

    expect(getServices().database).toBe(second.database);
  });

  it('pre-registered mocks take priority over auto-initialization', () => {
    const mock = new MockDatabaseService();
    registerServices({
      database: mock,
      storage: new MockStorageService(),
      cache: new MockCacheService(),
    });
    expect(getServices().database).toBe(mock);
  });

  // ⚠️ MANDATORY — Rule 13 auto-initialization test
  // This MUST call getServices() after clearServices() — a test that only
  // calls clearServices() without getServices() is a NO-OP and does not
  // satisfy the auto-initialization requirement.
  it('should auto-initialize without throwing when Enhancement config is missing (Rule 13)', () => {
    clearServices();
    // Set only Essential service env vars
    process.env.DATABASE_URL = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@localhost:5432/testdb`;
    process.env.STORAGE_CONNECTION_STRING = 'UseDevelopmentStorage=true';
    // Enhancement env vars intentionally NOT set
    delete process.env.AZURE_OPENAI_ENDPOINT;
    delete process.env.AZURE_OPENAI_API_KEY;

    // getServices() MUST auto-initialize without throwing
    expect(() => getServices()).not.toThrow();

    const services = getServices();
    expect(services.database).toBeDefined();
    expect(services.storage).toBeDefined();
    expect(services.cache).toBeDefined();
  });
});