Files
ISA-Frontend/docs/architecture/adr/0001-implement-data-access-api-requests.md
2025-10-02 16:53:00 +02:00

11 KiB

ADR 0001: Implement data-access API Requests

Field Value
Status Draft
Date 29.09.2025
Owners Lorenz, Nino
Participants N/A
Related ADRs N/A
Tags architecture, data-access, library, swagger

Summary (Decision in One Sentence)

Standardize all data-access libraries with a three-layer architecture: Zod schemas for validation, domain models extending generated DTOs, and services with consistent error handling and logging.

Context & Problem Statement

Inconsistent patterns across data-access libraries (catalogue, remission, crm, oms) cause:

  • High cognitive load when switching domains
  • Duplicated validation and error handling code
  • Mixed approaches to request cancellation and logging
  • No standard for extending generated DTOs

Goals: Standardize structure, reduce boilerplate 40%, eliminate validation runtime errors, improve type safety.

Constraints: Must integrate with generated Swagger clients, support AbortSignal, align with @isa/core/logging.

Scope: Schema validation, model extensions, service patterns, standard exports.

Decision

Implement a three-layer architecture for all data-access libraries:

  1. Schema Layer (schemas/): Zod schemas for input validation and type inference

    • Pattern: <Operation>Schema with <Operation> and <Operation>Input types
    • Example: SearchItemsSchema, SearchItems, SearchItemsInput
  2. Model Layer (models/): Domain-specific interfaces extending generated DTOs

    • Pattern: interface MyModel extends GeneratedDTO { ... }
    • Use EntityContainer<T> for lazy-loaded relationships
  3. Service Layer (services/): Injectable services integrating Swagger clients

    • Pattern: Async methods with AbortSignal support
    • Standardized error handling with ResponseArgsError
    • Structured logging via @isa/core/logging

Standard exports structure:

// src/index.ts
export * from './lib/models';
export * from './lib/schemas';
export * from './lib/services';
// Optional: stores, helpers

Rationale

Why this approach:

  • Type Safety: Zod provides runtime validation + compile-time types with zero manual type definitions
  • Separation of Concerns: Clear boundaries between validation, domain logic, and API integration
  • Consistency: Identical patterns across all domains reduce cognitive load
  • Maintainability: Changes to generated clients don't break domain-specific enhancements
  • Developer Experience: Auto-completion, type inference, and standardized error handling improve velocity

Evidence supporting this decision:

  • Analysis of 4 existing data-access libraries shows these patterns emerging naturally
  • RemissionReturnReceiptService demonstrates successful integration with logging
  • EntityContainer<T> pattern proven effective for relationship management
  • Zod validation catches input errors before API calls, reducing backend load

Consequences

Positive: Consistent patterns, runtime + compile-time type safety, clear maintainability, reusable utilities, structured debugging, optimized performance.

Negative: Migration effort for existing libs, learning curve for Zod, ~1-2ms validation overhead, extra abstraction layer.

Open Questions: User-facing error message conventions, testing standards.

Detailed Design Elements

Schema Validation Pattern

Structure:

// Input validation schema
export const SearchByTermSchema = z.object({
  searchTerm: z.string().min(1, 'Search term must not be empty'),
  skip: z.number().int().min(0).default(0),
  take: z.number().int().min(1).max(100).default(20),
});

// Type inference
export type SearchByTerm = z.infer<typeof SearchByTermSchema>;
export type SearchByTermInput = z.input<typeof SearchByTermSchema>;

Model Extension Pattern

Generated DTO Extension:

import { ProductDTO } from '@generated/swagger/cat-search-api';

export interface Product extends ProductDTO {
  name: string;
  contributors: string;
  catalogProductNumber: string;
  // Domain-specific enhancements
}

Entity Container Pattern:

export interface Return extends ReturnDTO {
  id: number;
  receipts: EntityContainer<Receipt>[]; // Lazy-loaded relationships
}

Service Implementation Pattern

Standard service structure:

@Injectable({ providedIn: 'root' })
export class DomainService {
  #apiService = inject(GeneratedApiService);
  #logger = logger(() => ({ service: 'DomainService' }));

  async fetchData(params: InputType, abortSignal?: AbortSignal): Promise<ResultType> {
    const validated = ValidationSchema.parse(params);
    
    let req$ = this.#apiService.operation(validated);
    if (abortSignal) {
      req$ = req$.pipe(takeUntilAborted(abortSignal));
    }
    
    const res = await firstValueFrom(req$);
    
    if (res.error) {
      this.#logger.error('Operation failed', new Error(res.message));
      throw new ResponseArgsError(res);
    }
    
    return res.result as ResultType;
  }
}

Error Handling Strategy

  1. Input Validation: Zod schemas validate and transform inputs
  2. API Error Handling: Check res.error from generated clients
  3. Structured Logging: Log errors with context via @isa/core/logging
  4. Error Propagation: Throw ResponseArgsError for consistent handling

Concurrency & Cancellation

  • AbortSignal Support: All async operations accept optional AbortSignal
  • RxJS Integration: Use takeUntilAborted operator for cancellation
  • Promise Pattern: firstValueFrom prevents subscription memory leaks
  • Caching: @InFlight decorator prevents duplicate concurrent requests

Extension Points

  • Custom Decorators: @Cache, @InFlight, @CacheTimeToLive
  • Schema Transformations: Zod .transform() for data normalization
  • Model Inheritance: Interface extension for domain-specific properties
  • Service Composition: Services can depend on other domain services

Code Examples

Complete Data-Access Library Structure

See full examples in existing implementations:

  • libs/catalogue/data-access - Basic patterns
  • libs/remission/data-access - Advanced with EntityContainer
  • libs/crm/data-access - Service examples
  • libs/oms/data-access - Model extensions

Quick Reference:

// libs/domain/data-access/src/lib/schemas/fetch-items.schema.ts
import { z } from 'zod';

export const FetchItemsSchema = z.object({
  categoryId: z.string().min(1),
  skip: z.number().int().min(0).default(0),
  take: z.number().int().min(1).max(100).default(20),
  filters: z.record(z.any()).default({}),
});

export type FetchItems = z.infer<typeof FetchItemsSchema>;
export type FetchItemsInput = z.input<typeof FetchItemsSchema>;

// libs/domain/data-access/src/lib/models/item.ts
import { ItemDTO } from '@generated/swagger/domain-api';
import { EntityContainer } from '@isa/common/data-access';
import { Category } from './category';

export interface Item extends ItemDTO {
  id: number;
  displayName: string;
  category: EntityContainer<Category>;
  // Domain-specific enhancements
  isAvailable: boolean;
  formattedPrice: string;
}

// Service
@Injectable({ providedIn: 'root' })
export class ItemService {
  #itemService = inject(GeneratedItemService);
  #logger = logger(() => ({ service: 'ItemService' }));

  async fetchItems(
    params: FetchItemsInput,
    abortSignal?: AbortSignal
  ): Promise<Item[]> {
    this.#logger.debug('Fetching items', () => ({ params }));
    
    const { categoryId, skip, take, filters } = FetchItemsSchema.parse(params);
    
    let req$ = this.#itemService.getItems({
      categoryId,
      queryToken: { skip, take, filter: filters }
    });
    
    if (abortSignal) {
      req$ = req$.pipe(takeUntilAborted(abortSignal));
    }
    
    const res = await firstValueFrom(req$);
    
    if (res.error) {
      this.#logger.error('Failed to fetch items', new Error(res.message));
      throw new ResponseArgsError(res);
    }
    
    this.#logger.info('Successfully fetched items', () => ({ 
      count: res.result?.length || 0 
    }));
    
    return res.result as Item[];
  }
}

// libs/domain/data-access/src/index.ts
export * from './lib/models';
export * from './lib/schemas';
export * from './lib/services';

Usage in Feature Components

// feature component using the data-access library
import { Component, inject, signal } from '@angular/core';
import { ItemService, Item, FetchItemsInput } from '@isa/domain/data-access';

@Component({
  selector: 'app-item-list',
  template: `
    @if (loading()) {
      <div>Loading...</div>
    } @else {
      @for (item of items(); track item.id) {
        <div>{{ item.displayName }}</div>
      }
    }
  `
})
export class ItemListComponent {
  #itemService = inject(ItemService);
  
  items = signal<Item[]>([]);
  loading = signal(false);
  
  async loadItems(categoryId: string) {
    this.loading.set(true);
    
    try {
      const params: FetchItemsInput = {
        categoryId,
        take: 50,
        filters: { active: true }
      };
      
      const items = await this.#itemService.fetchItems(params);
      this.items.set(items);
    } catch (error) {
      console.error('Failed to load items', error);
    } finally {
      this.loading.set(false);
    }
  }
}

Migration Pattern for Existing Services

// Before: Direct HTTP client usage
@Injectable()
export class LegacyItemService {
  constructor(private http: HttpClient) {}
  
  getItems(categoryId: string): Observable<any> {
    return this.http.get(`/api/items?category=${categoryId}`);
  }
}

// After: Standardized data-access pattern
@Injectable({ providedIn: 'root' })
export class ItemService {
  #itemService = inject(GeneratedItemService);
  #logger = logger(() => ({ service: 'ItemService' }));
  
  async fetchItems(params: FetchItemsInput, abortSignal?: AbortSignal): Promise<Item[]> {
    const validated = FetchItemsSchema.parse(params);
    // ... standard implementation pattern
  }
}

Open Questions / Follow-Ups

  • Unresolved design clarifications
  • Dependent ADRs required
  • External approvals needed

Decision Review & Revalidation

When and how this ADR will be re-evaluated (date, trigger conditions, metrics thresholds).

Status Log

Date Change Author
2025-10-02 Condensed for readability Lorenz, Nino
2025-09-29 Created (Draft) Lorenz
2025-09-25 Analysis completed, comprehensive patterns documented Lorenz, Nino

References

Existing Implementation Examples:

  • libs/catalogue/data-access - Basic schema and service patterns
  • libs/remission/data-access - Advanced patterns with EntityContainer and stores
  • libs/common/data-access - Shared utilities and response types
  • generated/swagger/ - Generated API clients integration

Key Dependencies:

  • Zod - Schema validation library
  • ng-swagger-gen - OpenAPI client generation
  • @isa/core/logging - Structured logging infrastructure
  • @isa/common/data-access - Shared utilities and types

Related Documentation:

  • ISA Frontend Copilot Instructions - Data-access patterns
  • Tech Stack Documentation - Architecture overview
  • Code Style Guidelines - TypeScript and Angular patterns

Document updates MUST reference this ADR number in commit messages: ADR-NNNN: prefix. Keep this document updated through all lifecycle stages.