mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
committed by
Nino Righi
parent
1d4c900d3a
commit
89b3d9aa60
@@ -6,7 +6,7 @@
|
||||
| Date | 29.09.2025 |
|
||||
| Owners | Lorenz, Nino |
|
||||
| Participants | N/A |
|
||||
| Related ADRs | N/A |
|
||||
| Related ADRs | [ADR-0002](./0002-models-schemas-dtos-architecture.md) |
|
||||
| Tags | architecture, data-access, library, swagger |
|
||||
|
||||
---
|
||||
@@ -35,9 +35,11 @@ Implement a **three-layer architecture** for all data-access libraries:
|
||||
- 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 { ... }`
|
||||
2. **Model Layer** (`models/`): Domain-specific types based on generated DTOs
|
||||
- **Simple re-export pattern** (default): `export type Product = ProductDTO;`
|
||||
- **Extension pattern** (when domain enhancements needed): `interface MyModel extends GeneratedDTO { ... }`
|
||||
- Use `EntityContainer<T>` for lazy-loaded relationships
|
||||
- **Rule**: Generated DTOs MUST NOT be imported outside data-access libraries (see ADR-0002)
|
||||
|
||||
3. **Service Layer** (`services/`): Injectable services integrating Swagger clients
|
||||
- Pattern: Async methods with AbortSignal support
|
||||
@@ -79,9 +81,12 @@ export * from './lib/services';
|
||||
## Detailed Design Elements
|
||||
|
||||
### Schema Validation Pattern
|
||||
**Structure:**
|
||||
|
||||
**Two schema patterns coexist:**
|
||||
|
||||
**Pattern A: Operation-based schemas** (for query/search operations)
|
||||
```typescript
|
||||
// Input validation schema
|
||||
// Input validation schema for search operation
|
||||
export const SearchByTermSchema = z.object({
|
||||
searchTerm: z.string().min(1, 'Search term must not be empty'),
|
||||
skip: z.number().int().min(0).default(0),
|
||||
@@ -93,27 +98,67 @@ export type SearchByTerm = z.infer<typeof SearchByTermSchema>;
|
||||
export type SearchByTermInput = z.input<typeof SearchByTermSchema>;
|
||||
```
|
||||
|
||||
### Model Extension Pattern
|
||||
**Generated DTO Extension:**
|
||||
**Pattern B: Entity-based schemas** (for CRUD operations, see ADR-0002)
|
||||
```typescript
|
||||
// Full entity schema defining all fields
|
||||
export const ProductSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
contributor: z.string().optional(),
|
||||
price: z.number().positive().optional(),
|
||||
// ... all fields
|
||||
});
|
||||
|
||||
// Derived validation schemas for specific operations
|
||||
export const CreateProductSchema = ProductSchema.pick({ name: true });
|
||||
export const UpdateProductSchema = ProductSchema.pick({ id: true, name: true }).required();
|
||||
|
||||
// Validate requests only, not responses
|
||||
```
|
||||
|
||||
### Model Pattern
|
||||
|
||||
**Pattern A: Simple Re-export** (default, recommended - see ADR-0002)
|
||||
```typescript
|
||||
import { ProductDTO } from '@generated/swagger/catalogue-api';
|
||||
|
||||
/**
|
||||
* Product model for catalogue domain.
|
||||
* Simple re-export of generated DTO.
|
||||
*/
|
||||
export type Product = ProductDTO;
|
||||
```
|
||||
|
||||
**Pattern B: Extension** (when domain-specific enhancements needed)
|
||||
```typescript
|
||||
import { ProductDTO } from '@generated/swagger/cat-search-api';
|
||||
|
||||
/**
|
||||
* Enhanced product with computed/derived fields.
|
||||
*/
|
||||
export interface Product extends ProductDTO {
|
||||
name: string;
|
||||
contributors: string;
|
||||
catalogProductNumber: string;
|
||||
// Domain-specific enhancements
|
||||
// Domain-specific computed fields
|
||||
displayName: string;
|
||||
formattedPrice: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Entity Container Pattern:**
|
||||
**Entity Container Pattern** (for lazy-loaded relationships)
|
||||
```typescript
|
||||
import { ReturnDTO } from '@generated/swagger/remission-api';
|
||||
import { EntityContainer } from '@isa/common/data-access';
|
||||
|
||||
export interface Return extends ReturnDTO {
|
||||
id: number;
|
||||
receipts: EntityContainer<Receipt>[]; // Lazy-loaded relationships
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Generated DTOs (`@generated/swagger/*`) MUST NOT be imported directly in feature/UI libraries. Always import models from data-access.
|
||||
|
||||
### Service Implementation Pattern
|
||||
**Standard service structure:**
|
||||
```typescript
|
||||
@@ -323,6 +368,7 @@ When and how this ADR will be re-evaluated (date, trigger conditions, metrics th
|
||||
## Status Log
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2025-11-03 | Updated model/schema patterns to align with ADR-0002, added entity-based schemas, clarified DTO encapsulation | System |
|
||||
| 2025-10-02 | Condensed for readability | Lorenz, Nino |
|
||||
| 2025-09-29 | Created (Draft) | Lorenz |
|
||||
| 2025-09-25 | Analysis completed, comprehensive patterns documented | Lorenz, Nino |
|
||||
@@ -340,6 +386,9 @@ When and how this ADR will be re-evaluated (date, trigger conditions, metrics th
|
||||
- `@isa/core/logging` - Structured logging infrastructure
|
||||
- `@isa/common/data-access` - Shared utilities and types
|
||||
|
||||
**Related ADRs:**
|
||||
- [ADR-0002: Models, Schemas, and DTOs Architecture](./0002-models-schemas-dtos-architecture.md) - Detailed guidance on model patterns, DTO encapsulation, and validation strategies
|
||||
|
||||
**Related Documentation:**
|
||||
- ISA Frontend Copilot Instructions - Data-access patterns
|
||||
- Tech Stack Documentation - Architecture overview
|
||||
|
||||
854
docs/architecture/adr/0002-models-schemas-dtos-architecture.md
Normal file
854
docs/architecture/adr/0002-models-schemas-dtos-architecture.md
Normal file
@@ -0,0 +1,854 @@
|
||||
# ADR 0002: Models, Schemas, and DTOs Architecture
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Status | Draft |
|
||||
| Date | 2025-11-03 |
|
||||
| Owners | TBD |
|
||||
| Participants | TBD |
|
||||
| Related ADRs | [ADR-0001](./0001-implement-data-access-api-requests.md) |
|
||||
| Tags | architecture, data-access, models, schemas, dto, validation |
|
||||
|
||||
---
|
||||
|
||||
## Summary (Decision in One Sentence)
|
||||
Encapsulate all generated Swagger DTOs within data-access libraries, use domain-specific models even when names collide, define full Zod schemas with partial validation at service-level for request data only, and export identical cross-domain models from common/data-access.
|
||||
|
||||
## Context & Problem Statement
|
||||
|
||||
**Current Issues:**
|
||||
- Generated DTOs (`@generated/swagger/*`) directly imported in 50+ feature/UI files
|
||||
- Same interface names (e.g., `PayerDTO`, `BranchDTO`) with different properties across 10 APIs
|
||||
- Union type workarounds (`Product = CatProductDTO | CheckoutProductDTO | OmsProductDTO`) lose type safety
|
||||
- Inconsistent Zod schema coverage - some types validated, others not
|
||||
- Type compatibility issues between models and schemas with identical interfaces
|
||||
- Component-local type redefinitions instead of shared models
|
||||
- No clear pattern for partial validation (validate some fields, send all data)
|
||||
|
||||
**Example Conflicts:**
|
||||
```typescript
|
||||
// checkout-api: Minimal PayerDTO (3 properties)
|
||||
export interface PayerDTO {
|
||||
payerNumber?: string;
|
||||
payerStatus?: PayerStatus;
|
||||
payerType?: PayerType;
|
||||
}
|
||||
|
||||
// crm-api: Full PayerDTO (17 properties)
|
||||
export interface PayerDTO {
|
||||
payerNumber?: string;
|
||||
address?: AddressDTO;
|
||||
communicationDetails?: CommunicationDetailsDTO;
|
||||
// ... 14 more fields
|
||||
}
|
||||
|
||||
// Feature components use aliasing as workaround
|
||||
import { PayerDTO as CheckoutPayer } from '@generated/swagger/checkout-api';
|
||||
import { PayerDTO as CrmPayer } from '@generated/swagger/crm-api';
|
||||
```
|
||||
|
||||
**Goals:**
|
||||
- Encapsulate generated code as implementation detail
|
||||
- Eliminate type name conflicts across domains
|
||||
- Standardize validation patterns
|
||||
- Support partial validation while sending complete data
|
||||
- Improve type safety and developer experience
|
||||
|
||||
**Constraints:**
|
||||
- Must integrate with 10 existing Swagger generated clients
|
||||
- Cannot break existing feature/UI components
|
||||
- Must support domain-driven architecture
|
||||
- Validation overhead must remain minimal
|
||||
|
||||
**Scope:**
|
||||
- Model definitions and exports
|
||||
- Schema architecture and validation strategy
|
||||
- DTO encapsulation boundaries
|
||||
- Common vs domain-specific type organization
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a **four-layer type architecture** for all data-access libraries:
|
||||
|
||||
### 1. Generated Layer (Hidden)
|
||||
- **Location:** `/generated/swagger/[api-name]/`
|
||||
- **Visibility:** NEVER imported outside data-access libraries
|
||||
- **Purpose:** Implementation detail, source of truth from backend
|
||||
|
||||
### 2. Model Layer (Public API)
|
||||
- **Location:** `libs/[domain]/data-access/src/lib/models/`
|
||||
- **Pattern:** Type aliases re-exporting generated DTOs
|
||||
- **Naming:** Use domain context (e.g., `Product` in both catalogue and checkout)
|
||||
- **Rule:** Each domain has its own models, even if names collide across domains
|
||||
|
||||
### 3. Schema Layer (Validation)
|
||||
- **Location:** `libs/[domain]/data-access/src/lib/schemas/`
|
||||
- **Pattern:** Full Zod schemas defining ALL fields
|
||||
- **Validation:** Derive partial validation schemas using `.pick()` or `.partial()`
|
||||
- **Purpose:** Runtime validation + type inference
|
||||
|
||||
### 4. Common Layer (Shared Types)
|
||||
- **Location:** `libs/common/data-access/src/lib/models/` and `schemas/`
|
||||
- **Rule:** Only for models **identical across all APIs** (same name, properties, types, optionality)
|
||||
- **Examples:** `EntityStatus`, `NotificationChannel` (if truly identical)
|
||||
|
||||
### Validation Strategy
|
||||
1. **Request Validation Only:** Validate data BEFORE sending to backend
|
||||
2. **Service-Level Validation:** Perform validation in service methods
|
||||
3. **Partial Validation:** Validate only required fields, send all data
|
||||
4. **Full Schema Definition:** Define complete schemas even when partial validation used
|
||||
5. **No Response Validation:** Trust backend responses without validation
|
||||
|
||||
### Export Rules
|
||||
```typescript
|
||||
// ✅ Data-access exports
|
||||
export * from './lib/models'; // Type aliases over DTOs
|
||||
export * from './lib/schemas'; // Zod schemas
|
||||
export * from './lib/services'; // Business logic
|
||||
|
||||
// ❌ NEVER export generated code
|
||||
// export * from '@generated/swagger/catalogue-api';
|
||||
```
|
||||
|
||||
## Rationale
|
||||
|
||||
**Why Encapsulate Generated DTOs:**
|
||||
- **Single Responsibility:** Data-access owns API integration details
|
||||
- **Change Isolation:** API changes don't ripple through feature layers
|
||||
- **Clear Boundaries:** Domain logic separated from transport layer
|
||||
- **Migration Safety:** Can swap generated clients without breaking features
|
||||
|
||||
**Why Domain-Specific Models (Not Shared):**
|
||||
- **Type Safety:** Each domain gets exact DTO shape from its API
|
||||
- **No Name Conflicts:** `Payer` in checkout vs CRM have different meanings
|
||||
- **Semantic Clarity:** Same name doesn't mean same concept across domains
|
||||
- **Avoids Union Types:** Union types lose specificity and auto-completion
|
||||
|
||||
**Why Full Schemas with Partial Validation:**
|
||||
- **Documentation:** Full schema serves as reference for all available fields
|
||||
- **Flexibility:** Can derive different validation schemas (create vs update vs patch)
|
||||
- **Type Safety:** `z.infer` provides complete type information
|
||||
- **Reusability:** Pick different fields for different operations
|
||||
- **Future-Proof:** New validations can be added without schema rewrites
|
||||
|
||||
**Why Service-Level Validation:**
|
||||
- **Centralized Logic:** All API calls validated consistently
|
||||
- **Early Failure:** Errors caught before network requests
|
||||
- **Logged Context:** Validation failures logged with structured data
|
||||
- **User Feedback:** Services can map validation errors to user messages
|
||||
|
||||
**Why No Response Validation:**
|
||||
- **Performance:** No overhead on every API response
|
||||
- **Backend Trust:** Backend is source of truth, already validated
|
||||
- **Simpler Code:** Less boilerplate in services
|
||||
- **Faster Development:** Focus on request contract, not response parsing
|
||||
|
||||
**Evidence Supporting Decision:**
|
||||
- Analysis shows 50+ files importing generated DTOs (architecture violation)
|
||||
- `BranchDTO` exists in 7 APIs with subtle differences
|
||||
- Existing ADR-0001 establishes service patterns this extends
|
||||
- Current union type patterns (`Product = A | B | C`) cause type narrowing issues
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- **Clear Architecture:** Generated code hidden behind stable public API
|
||||
- **No Name Conflicts:** Domain models isolated by library boundaries
|
||||
- **Type Safety:** Each domain gets precise types from its API
|
||||
- **Validation Consistency:** All requests validated, responses trusted
|
||||
- **Developer Experience:** Auto-completion works, no aliasing needed
|
||||
- **Maintainability:** API changes isolated to data-access layer
|
||||
- **Performance:** Minimal validation overhead, no response parsing
|
||||
|
||||
### Negative
|
||||
- **Migration Effort:** 50+ files need import updates
|
||||
- **Learning Curve:** Team must understand model vs DTO distinction
|
||||
- **Schema Maintenance:** Every model needs corresponding full schema
|
||||
- **Potential Duplication:** Similar models across domains (by design)
|
||||
- **Validation Cost:** ~1-2ms overhead per validated request
|
||||
|
||||
### Neutral
|
||||
- **Code Volume:** More files (models + schemas) but better organized
|
||||
- **Common Models Rare:** Most types will be domain-specific, not common
|
||||
|
||||
### Risks & Mitigation
|
||||
- **Risk:** Developers might accidentally import generated DTOs
|
||||
- **Mitigation:** ESLint rule to prevent `@generated/swagger/*` imports outside data-access
|
||||
|
||||
- **Risk:** Unclear when model should be common vs domain-specific
|
||||
- **Mitigation:** Decision tree in documentation (see below)
|
||||
|
||||
- **Risk:** Partial validation might miss critical fields
|
||||
- **Mitigation:** Code review focus on validation schemas, tests for edge cases
|
||||
|
||||
## Detailed Design Elements
|
||||
|
||||
### Decision Tree: Where Should a Model Live?
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Is the DTO IDENTICAL in all generated APIs? │
|
||||
│ (same name, properties, types, optional/required status) │
|
||||
└──┬───────────────────────────────────────────────┬──────────┘
|
||||
│ YES │ NO
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────┐ ┌───────────────────────────────┐
|
||||
│ libs/common/data-access/ │ │ Is it used in multiple │
|
||||
│ models/[type].ts │ │ domains? │
|
||||
│ │ └───┬───────────────────────┬───┘
|
||||
│ Export once, import in all │ │ YES │ NO
|
||||
│ domain data-access libs │ │ │
|
||||
└──────────────────────────────┘ ▼ ▼
|
||||
┌──────────────────┐ ┌─────────────────┐
|
||||
│ Create separate │ │ Single domain's │
|
||||
│ model in EACH │ │ data-access │
|
||||
│ domain's │ │ library │
|
||||
│ data-access │ └─────────────────┘
|
||||
└──────────────────┘
|
||||
Example: Product exists in
|
||||
catalogue, checkout, oms
|
||||
with different shapes
|
||||
```
|
||||
|
||||
### Pattern 1: Domain-Specific Model (Most Common)
|
||||
|
||||
```typescript
|
||||
// libs/catalogue/data-access/src/lib/models/product.ts
|
||||
import { ProductDTO } from '@generated/swagger/catalogue-api';
|
||||
|
||||
/**
|
||||
* Product model for catalogue domain.
|
||||
*
|
||||
* Represents a product in the product catalogue with full details
|
||||
* including pricing, availability, and metadata.
|
||||
*/
|
||||
export type Product = ProductDTO;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/catalogue/data-access/src/lib/schemas/product.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Full Zod schema for Product entity.
|
||||
* Defines all fields available in the Product model.
|
||||
*/
|
||||
export const ProductSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
contributor: z.string().optional(),
|
||||
price: z.number().positive().optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
categoryId: z.string().optional(),
|
||||
stockQuantity: z.number().int().nonnegative().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validation schema for creating a product.
|
||||
* Validates only required fields: name must be present and valid.
|
||||
* Other fields are sent to API but not validated.
|
||||
*/
|
||||
export const CreateProductSchema = ProductSchema.pick({
|
||||
name: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Validation schema for updating a product.
|
||||
* Requires id and name, other fields optional.
|
||||
*/
|
||||
export const UpdateProductSchema = ProductSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
}).required();
|
||||
|
||||
/**
|
||||
* Inferred types from schemas
|
||||
*/
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
export type CreateProductInput = z.input<typeof CreateProductSchema>;
|
||||
export type UpdateProductInput = z.input<typeof UpdateProductSchema>;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/catalogue/data-access/src/lib/services/products.service.ts
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ProductService, ProductDTO } from '@generated/swagger/catalogue-api';
|
||||
import { CreateProductSchema, UpdateProductSchema } from '../schemas';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProductsService {
|
||||
readonly #log = logger(ProductsService);
|
||||
readonly #productService = inject(ProductService);
|
||||
|
||||
/**
|
||||
* Creates a new product.
|
||||
* Validates required fields before sending to API.
|
||||
* Sends all product data (validated and unvalidated fields).
|
||||
*/
|
||||
async createProduct(product: ProductDTO): Promise<ProductDTO | null> {
|
||||
// Validate only required fields (name)
|
||||
const validationResult = CreateProductSchema.safeParse(product);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.#log.error('Product validation failed', {
|
||||
errors: validationResult.error.format(),
|
||||
product
|
||||
});
|
||||
throw new Error(
|
||||
`Invalid product data: ${validationResult.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
this.#log.debug('Creating product', { name: product.name });
|
||||
|
||||
// Send ALL product data to API (including unvalidated fields)
|
||||
const response = await firstValueFrom(
|
||||
this.#productService.createProduct(product)
|
||||
);
|
||||
|
||||
// No response validation - trust backend
|
||||
if (response.error) {
|
||||
this.#log.error('Failed to create product', {
|
||||
error: response.message
|
||||
});
|
||||
throw new Error(response.message);
|
||||
}
|
||||
|
||||
return response.result ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing product.
|
||||
* Validates id and name are present.
|
||||
*/
|
||||
async updateProduct(
|
||||
id: string,
|
||||
product: ProductDTO
|
||||
): Promise<ProductDTO | null> {
|
||||
// Validate required fields for update (id + name)
|
||||
const validationResult = UpdateProductSchema.safeParse({ id, ...product });
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.#log.error('Product update validation failed', {
|
||||
errors: validationResult.error.format()
|
||||
});
|
||||
throw new Error(
|
||||
`Invalid product update: ${validationResult.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
this.#log.debug('Updating product', { id, name: product.name });
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.#productService.updateProduct(id, product)
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
this.#log.error('Failed to update product', {
|
||||
error: response.message,
|
||||
id
|
||||
});
|
||||
throw new Error(response.message);
|
||||
}
|
||||
|
||||
return response.result ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a product by ID.
|
||||
* No validation needed for GET requests.
|
||||
*/
|
||||
async getProduct(id: string): Promise<ProductDTO | null> {
|
||||
this.#log.debug('Fetching product', { id });
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.#productService.getProduct(id)
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
this.#log.error('Failed to fetch product', {
|
||||
error: response.message,
|
||||
id
|
||||
});
|
||||
throw new Error(response.message);
|
||||
}
|
||||
|
||||
// No response validation
|
||||
return response.result ?? null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Common Model (Identical Across All APIs)
|
||||
|
||||
```typescript
|
||||
// libs/common/data-access/src/lib/models/notification-channel.ts
|
||||
import { NotificationChannel as CheckoutNotificationChannel } from '@generated/swagger/checkout-api';
|
||||
import { NotificationChannel as CrmNotificationChannel } from '@generated/swagger/crm-api';
|
||||
import { NotificationChannel as OmsNotificationChannel } from '@generated/swagger/oms-api';
|
||||
|
||||
/**
|
||||
* NotificationChannel is identical across all APIs.
|
||||
*
|
||||
* Verification:
|
||||
* - checkout-api: type NotificationChannel = 0 | 1 | 2 | 4
|
||||
* - crm-api: type NotificationChannel = 0 | 1 | 2 | 4
|
||||
* - oms-api: type NotificationChannel = 0 | 1 | 2 | 4
|
||||
*
|
||||
* All three definitions are identical, so we export once from common.
|
||||
*/
|
||||
export type NotificationChannel =
|
||||
| CheckoutNotificationChannel
|
||||
| CrmNotificationChannel
|
||||
| OmsNotificationChannel;
|
||||
|
||||
// Alternative if truly identical (pick one as canonical):
|
||||
// export type NotificationChannel = CheckoutNotificationChannel;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/common/data-access/src/lib/schemas/notification-channel.schema.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schema for NotificationChannel enum.
|
||||
*
|
||||
* Values:
|
||||
* - 0: Email
|
||||
* - 1: SMS
|
||||
* - 2: Push Notification
|
||||
* - 4: Phone Call
|
||||
*/
|
||||
export const NotificationChannelSchema = z.union([
|
||||
z.literal(0),
|
||||
z.literal(1),
|
||||
z.literal(2),
|
||||
z.literal(4),
|
||||
]);
|
||||
|
||||
export type NotificationChannel = z.infer<typeof NotificationChannelSchema>;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Domain data-access libs re-export from common
|
||||
// libs/checkout/data-access/src/lib/models/index.ts
|
||||
export { NotificationChannel } from '@isa/common/data-access';
|
||||
|
||||
// libs/crm/data-access/src/lib/schemas/index.ts
|
||||
export { NotificationChannelSchema } from '@isa/common/data-access';
|
||||
```
|
||||
|
||||
### Pattern 3: Multiple Domain Models (Same Name, Different Structure)
|
||||
|
||||
When DTOs with the same name have different structures, keep them separate:
|
||||
|
||||
```typescript
|
||||
// libs/checkout/data-access/src/lib/models/payer.ts
|
||||
import { PayerDTO } from '@generated/swagger/checkout-api';
|
||||
|
||||
/**
|
||||
* Payer model for checkout domain.
|
||||
*
|
||||
* Minimal payer information needed during checkout flow.
|
||||
* Contains only basic identification and status.
|
||||
*/
|
||||
export type Payer = PayerDTO;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/crm/data-access/src/lib/models/payer.ts
|
||||
import { PayerDTO } from '@generated/swagger/crm-api';
|
||||
|
||||
/**
|
||||
* Payer model for CRM domain.
|
||||
*
|
||||
* Full payer entity with complete address, organization,
|
||||
* communication details, and payment settings.
|
||||
* Used for payer management and administration.
|
||||
*/
|
||||
export type Payer = PayerDTO;
|
||||
```
|
||||
|
||||
**Components import from their respective domain:**
|
||||
```typescript
|
||||
// libs/checkout/feature/cart/src/lib/cart.component.ts
|
||||
import { Payer } from '@isa/checkout/data-access'; // 3-field version
|
||||
|
||||
// libs/crm/feature/payers/src/lib/payer-details.component.ts
|
||||
import { Payer } from '@isa/crm/data-access'; // 17-field version
|
||||
```
|
||||
|
||||
### Pattern 4: Partial Validation with Full Schema
|
||||
|
||||
**Scenario:** Product has many fields, but only `name` is required for creation.
|
||||
|
||||
```typescript
|
||||
// Full schema defines ALL fields
|
||||
export const ProductSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
contributor: z.string().optional(),
|
||||
price: z.number().positive().optional(),
|
||||
description: z.string().optional(),
|
||||
categoryId: z.string().optional(),
|
||||
stockQuantity: z.number().int().nonnegative().optional(),
|
||||
imageUrl: z.string().url().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
// ... potentially 20+ more fields
|
||||
});
|
||||
|
||||
// Validation schema picks only required field
|
||||
export const CreateProductSchema = ProductSchema.pick({
|
||||
name: true,
|
||||
});
|
||||
|
||||
// Service validates partial, sends complete
|
||||
async createProduct(product: ProductDTO): Promise<ProductDTO | null> {
|
||||
// Validate: only name is checked
|
||||
CreateProductSchema.parse(product);
|
||||
|
||||
// Send: all fields (name, contributor, price, description, etc.)
|
||||
const response = await this.api.createProduct(product);
|
||||
|
||||
return response.result;
|
||||
}
|
||||
```
|
||||
|
||||
**Why not `.passthrough()`?**
|
||||
```typescript
|
||||
// ❌ Avoid this approach
|
||||
const CreateProductSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
}).passthrough();
|
||||
|
||||
// Type inference loses other fields
|
||||
type Inferred = z.infer<typeof CreateProductSchema>;
|
||||
// Result: { name: string } & { [key: string]: unknown }
|
||||
// Lost: contributor, price, description types
|
||||
|
||||
// ✅ Prefer this approach
|
||||
const CreateProductSchema = ProductSchema.pick({ name: true });
|
||||
|
||||
// Full ProductSchema defined elsewhere provides complete type
|
||||
type Product = z.infer<typeof ProductSchema>;
|
||||
// Result: { id?: string; name: string; contributor?: string; ... }
|
||||
```
|
||||
|
||||
### Export Structure
|
||||
|
||||
```typescript
|
||||
// libs/catalogue/data-access/src/index.ts
|
||||
|
||||
// Public API exports
|
||||
export * from './lib/models'; // Type aliases over DTOs
|
||||
export * from './lib/schemas'; // Zod schemas
|
||||
export * from './lib/services'; // Business logic
|
||||
export * from './lib/resources'; // Angular resources (optional)
|
||||
export * from './lib/stores'; // State management (optional)
|
||||
export * from './lib/helpers'; // Utilities (optional)
|
||||
|
||||
// ❌ NEVER export generated code
|
||||
// This would break encapsulation:
|
||||
// export * from '@generated/swagger/catalogue-api';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/catalogue/data-access/src/lib/models/index.ts
|
||||
|
||||
// Re-export all domain models
|
||||
export * from './product';
|
||||
export * from './category';
|
||||
export * from './supplier';
|
||||
export * from './inventory';
|
||||
|
||||
// May also re-export common models
|
||||
export { EntityStatus, NotificationChannel } from '@isa/common/data-access';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/catalogue/data-access/src/lib/schemas/index.ts
|
||||
|
||||
// Re-export all domain schemas
|
||||
export * from './product.schema';
|
||||
export * from './category.schema';
|
||||
export * from './supplier.schema';
|
||||
export * from './inventory.schema';
|
||||
|
||||
// May also re-export common schemas
|
||||
export { EntityStatusSchema } from '@isa/common/data-access';
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Complete Example: Order in OMS Domain
|
||||
|
||||
```typescript
|
||||
// libs/oms/data-access/src/lib/models/order.ts
|
||||
import { OrderDTO } from '@generated/swagger/oms-api';
|
||||
|
||||
export type Order = OrderDTO;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/oms/data-access/src/lib/schemas/order.schema.ts
|
||||
import { z } from 'zod';
|
||||
import { OrderItemSchema } from './order-item.schema';
|
||||
import { OrderStatusSchema } from '@isa/common/data-access';
|
||||
|
||||
export const OrderSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
orderNumber: z.string().min(1),
|
||||
customerId: z.string().uuid(),
|
||||
items: z.array(OrderItemSchema).min(1),
|
||||
status: OrderStatusSchema.optional(),
|
||||
totalAmount: z.number().nonnegative().optional(),
|
||||
shippingAddress: z.string().optional(),
|
||||
billingAddress: z.string().optional(),
|
||||
createdAt: z.string().datetime().optional(),
|
||||
updatedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export const CreateOrderSchema = OrderSchema.pick({
|
||||
customerId: true,
|
||||
items: true,
|
||||
});
|
||||
|
||||
export const UpdateOrderStatusSchema = OrderSchema.pick({
|
||||
id: true,
|
||||
status: true,
|
||||
}).required();
|
||||
|
||||
export type Order = z.infer<typeof OrderSchema>;
|
||||
export type CreateOrderInput = z.input<typeof CreateOrderSchema>;
|
||||
export type UpdateOrderStatusInput = z.input<typeof UpdateOrderStatusSchema>;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// libs/oms/data-access/src/lib/services/orders.service.ts
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { OrderService, OrderDTO } from '@generated/swagger/oms-api';
|
||||
import { CreateOrderSchema, UpdateOrderStatusSchema } from '../schemas';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrdersService {
|
||||
readonly #log = logger(OrdersService);
|
||||
readonly #orderService = inject(OrderService);
|
||||
|
||||
async createOrder(order: OrderDTO): Promise<OrderDTO | null> {
|
||||
const validationResult = CreateOrderSchema.safeParse(order);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.#log.error('Order validation failed', {
|
||||
errors: validationResult.error.format()
|
||||
});
|
||||
throw new Error(
|
||||
`Invalid order: ${validationResult.error.message}`
|
||||
);
|
||||
}
|
||||
|
||||
this.#log.debug('Creating order', {
|
||||
customerId: order.customerId,
|
||||
itemCount: order.items?.length
|
||||
});
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.#orderService.createOrder(order)
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
this.#log.error('Failed to create order', {
|
||||
error: response.message
|
||||
});
|
||||
throw new Error(response.message);
|
||||
}
|
||||
|
||||
return response.result ?? null;
|
||||
}
|
||||
|
||||
async updateOrderStatus(
|
||||
id: string,
|
||||
status: string
|
||||
): Promise<OrderDTO | null> {
|
||||
UpdateOrderStatusSchema.parse({ id, status });
|
||||
|
||||
this.#log.debug('Updating order status', { id, status });
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.#orderService.updateOrderStatus(id, status)
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
this.#log.error('Failed to update order status', {
|
||||
error: response.message,
|
||||
id
|
||||
});
|
||||
throw new Error(response.message);
|
||||
}
|
||||
|
||||
return response.result ?? null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Feature Component
|
||||
|
||||
```typescript
|
||||
// libs/oms/feature/orders/src/lib/create-order.component.ts
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { OrdersService, Order, CreateOrderInput } from '@isa/oms/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-create-order',
|
||||
template: `
|
||||
<form (ngSubmit)="submit()">
|
||||
<!-- Form fields -->
|
||||
@if (error()) {
|
||||
<div class="error">{{ error() }}</div>
|
||||
}
|
||||
<button type="submit">Create Order</button>
|
||||
</form>
|
||||
`
|
||||
})
|
||||
export class CreateOrderComponent {
|
||||
readonly #ordersService = inject(OrdersService);
|
||||
|
||||
error = signal<string | null>(null);
|
||||
|
||||
async submit() {
|
||||
try {
|
||||
// Build order data
|
||||
const orderInput: CreateOrderInput = {
|
||||
customerId: this.customerId,
|
||||
items: this.items,
|
||||
// Optional fields can be included
|
||||
shippingAddress: this.shippingAddress,
|
||||
billingAddress: this.billingAddress,
|
||||
};
|
||||
|
||||
// Service validates required fields, sends all data
|
||||
const created = await this.#ordersService.createOrder(orderInput);
|
||||
|
||||
// Navigate to order details...
|
||||
} catch (err) {
|
||||
this.error.set(err instanceof Error ? err.message : 'Failed to create order');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: New Development (Immediate)
|
||||
- All new models follow this ADR
|
||||
- All new schemas use full definition + partial validation
|
||||
- All new services validate requests only
|
||||
|
||||
### Phase 2: Incremental Migration (Ongoing)
|
||||
- When touching existing code, update imports
|
||||
- Replace generated DTO imports with data-access model imports
|
||||
- Add validation schemas for existing services
|
||||
|
||||
### Phase 3: Cleanup (Future)
|
||||
- Add ESLint rule preventing `@generated/swagger/*` imports outside data-access
|
||||
- Automated codemod to fix remaining violations
|
||||
- Remove union type workarounds
|
||||
|
||||
### Migration Checklist (Per Domain)
|
||||
|
||||
- [ ] Create `models/` folder with type aliases over generated DTOs
|
||||
- [ ] Create `schemas/` folder with full Zod schemas
|
||||
- [ ] Add partial validation schemas (`.pick()` for required fields)
|
||||
- [ ] Update services to validate before API calls
|
||||
- [ ] Export models and schemas from data-access index
|
||||
- [ ] Update feature components to import from data-access
|
||||
- [ ] Remove direct `@generated/swagger/*` imports
|
||||
- [ ] Verify no union types for different shapes
|
||||
- [ ] Move truly identical models to common/data-access
|
||||
|
||||
## Open Questions / Follow-Ups
|
||||
|
||||
### For Team Discussion
|
||||
|
||||
1. **ESLint Rule Priority:** Should we add the ESLint rule immediately or after migration?
|
||||
- Immediate: Prevents new violations
|
||||
- After migration: Less friction during transition
|
||||
|
||||
2. **Validation Error Handling:** How should services communicate validation errors to UI?
|
||||
- Throw generic Error (current approach)
|
||||
- Custom ValidationError class with structured field errors
|
||||
- Return Result<T, E> pattern instead of throwing
|
||||
|
||||
3. **Common Model Criteria:** Should we require 100% identical or allow minor differences?
|
||||
- Strict: Must be byte-for-byte identical
|
||||
- Lenient: Same semantic meaning, slight type differences OK
|
||||
|
||||
4. **Schema Generation:** Should we auto-generate Zod schemas from Swagger specs?
|
||||
- Pro: Less manual work, stays in sync
|
||||
- Con: Generated schemas might not match domain needs
|
||||
|
||||
5. **Response Validation:** Any exceptions where we SHOULD validate responses?
|
||||
- Critical paths (payments, checkout)?
|
||||
- External APIs (not our backend)?
|
||||
|
||||
### Dependent Decisions
|
||||
|
||||
- [ ] Define custom ValidationError class structure
|
||||
- [ ] Decide on ESLint rule configuration
|
||||
- [ ] Document common model approval process
|
||||
- [ ] Create code generation tooling (if desired)
|
||||
|
||||
## Decision Review & Revalidation
|
||||
|
||||
**Review Triggers:**
|
||||
- After 3 months of adoption (2025-02-03)
|
||||
- When migration >50% complete
|
||||
- If validation overhead becomes measurable performance issue
|
||||
- If new backend API patterns emerge
|
||||
|
||||
**Success Metrics:**
|
||||
- Zero `@generated/swagger/*` imports outside data-access (ESLint violations)
|
||||
- 100% of services have request validation
|
||||
- <5% of models in common/data-access (most are domain-specific)
|
||||
- Developer survey shows improved clarity (>80% satisfaction)
|
||||
|
||||
**Failure Criteria (Revert Decision):**
|
||||
- Validation overhead >10ms per request (current: ~1-2ms)
|
||||
- Common models >30% (suggests wrong criteria)
|
||||
- Excessive developer friction (>50% negative feedback)
|
||||
|
||||
## Status Log
|
||||
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| 2025-11-03 | Created (Draft) | TBD |
|
||||
|
||||
## References
|
||||
|
||||
**Related ADRs:**
|
||||
- [ADR-0001: Implement data-access API Requests](./0001-implement-data-access-api-requests.md) - Establishes service patterns this extends
|
||||
|
||||
**Existing Codebase:**
|
||||
- `/generated/swagger/` - 10 generated API clients
|
||||
- `libs/*/data-access/` - 7 existing data-access libraries
|
||||
- `libs/common/data-access/` - Shared types and utilities
|
||||
|
||||
**External Documentation:**
|
||||
- [Zod Documentation](https://zod.dev/) - Schema validation library
|
||||
- [ng-swagger-gen](https://github.com/cyclosproject/ng-swagger-gen) - OpenAPI client generator
|
||||
|
||||
**Migration Resources:**
|
||||
- Comprehensive guide: `/docs/architecture/models-schemas-dtos-guide.md`
|
||||
- Example implementations in catalogue, oms, crm data-access libraries
|
||||
|
||||
---
|
||||
> Document updates MUST reference this ADR number in commit messages: `ADR-0002:` prefix.
|
||||
> Keep this document updated through all lifecycle stages.
|
||||
Reference in New Issue
Block a user