Merged PR 2000: open tasks

Related work items: #5309
This commit is contained in:
Lorenz Hilpert
2025-11-06 10:01:41 +00:00
committed by Nino Righi
parent 1d4c900d3a
commit 89b3d9aa60
136 changed files with 5088 additions and 4798 deletions

View File

@@ -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

View 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.