30 KiB
ADR 0002: Models, Schemas, and DTOs Architecture
| Field | Value |
|---|---|
| Status | Draft |
| Date | 2025-11-03 |
| Owners | TBD |
| Participants | TBD |
| Related ADRs | ADR-0001 |
| 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:
// 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.,
Productin 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/andschemas/ - Rule: Only for models identical across all APIs (same name, properties, types, optionality)
- Examples:
EntityStatus,NotificationChannel(if truly identical)
Validation Strategy
- Request Validation Only: Validate data BEFORE sending to backend
- Service-Level Validation: Perform validation in service methods
- Partial Validation: Validate only required fields, send all data
- Full Schema Definition: Define complete schemas even when partial validation used
- No Response Validation: Trust backend responses without validation
Export Rules
// ✅ 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:
Payerin 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.inferprovides 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)
BranchDTOexists 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
- Mitigation: ESLint rule to prevent
-
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)
// 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;
// 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>;
// 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)
// 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;
// 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>;
// 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:
// 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;
// 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:
// 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.
// 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()?
// ❌ 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
// 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';
// 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';
// 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
// libs/oms/data-access/src/lib/models/order.ts
import { OrderDTO } from '@generated/swagger/oms-api';
export type Order = OrderDTO;
// 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>;
// 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
// 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
-
ESLint Rule Priority: Should we add the ESLint rule immediately or after migration?
- Immediate: Prevents new violations
- After migration: Less friction during transition
-
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
-
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
-
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
-
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 - Establishes service patterns this extends
Existing Codebase:
/generated/swagger/- 10 generated API clientslibs/*/data-access/- 7 existing data-access librarieslibs/common/data-access/- Shared types and utilities
External Documentation:
- Zod Documentation - Schema validation library
- 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.