Data Transfer Object (DTO) Design
DTO Mapping Strategies
Read aloud is unavailable in this browser.
The Mapping Challenge
You've accepted the principle: domain objects and DTOs should be separate. Now you face an immediate practical question: How do you convert between them?
This seems trivial at first—just copy fields from one object to another. But as systems grow, mapping becomes a significant portion of your codebase. Consider an enterprise application with:
- 50 domain entities
- 3 DTOs per entity (create, update, response)
- 150 total DTO types
- Each requiring mapping logic in both directions
Maintaining hundreds of mappers becomes a substantial engineering challenge. Done poorly, mapping logic becomes a source of bugs, performance issues, and maintenance burden. Done well, it's invisible infrastructure that enables clean architecture.
By the end of this page, you will understand the major approaches to DTO mapping—manual, automated, and hybrid—their trade-offs, performance implications, and when to use each. You'll learn to make informed decisions about mapping strategies for different project contexts.
Manual Mapping
The most straightforward approach is writing explicit code to copy data between objects. Manual mapping means you write every field assignment yourself.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// Manual Mapper - Explicit Field-by-Field Mappingclass OrderMapper { // Domain object → Response DTO toResponseDTO(order: Order): OrderResponseDTO { return { orderId: order.id.value, orderNumber: order.orderNumber, status: this.mapStatus(order.status), createdAt: order.createdAt.toISOString(), updatedAt: order.updatedAt.toISOString(), customer: { customerId: order.customer.id.value, name: order.customer.fullName, email: order.customer.email.value, }, subtotalCents: order.subtotal.amountInCents, discountCents: order.discount.amountInCents, taxCents: order.tax.amountInCents, shippingCents: order.shippingCost.amountInCents, totalCents: order.total.amountInCents, currency: order.currency.code, items: order.items.map(item => this.toOrderItemDTO(item)), shippingAddress: this.toAddressDTO(order.shippingAddress), billingAddress: this.toAddressDTO(order.billingAddress), isEditable: order.canBeEdited(), isCancellable: order.canBeCancelled(), estimatedDeliveryDate: order.estimatedDelivery?.toISOString() ?? null, links: { self: `/orders/${order.id.value}`, customer: `/customers/${order.customer.id.value}`, cancel: order.canBeCancelled() ? `/orders/${order.id.value}/cancel` : null, trackShipment: order.tracking?.url ?? null, }, }; } // Request DTO → Create command (domain-friendly) toCreateCommand(dto: CreateOrderRequestDTO): CreateOrderCommand { return new CreateOrderCommand( new CustomerId(dto.customerId), dto.items.map(item => ({ productId: new ProductId(item.productId), quantity: Quantity.of(item.quantity), })), new PaymentMethodId(dto.paymentMethodId), dto.couponCode ? new CouponCode(dto.couponCode) : null ); } // Helper mappers private toOrderItemDTO(item: OrderItem): OrderItemDTO { return { lineItemId: item.id.value, productId: item.product.id.value, productName: item.product.name, productImageUrl: item.product.primaryImageUrl, sku: item.product.sku.value, quantity: item.quantity.value, unitPriceCents: item.unitPrice.amountInCents, lineTotalCents: item.lineTotal.amountInCents, }; } private toAddressDTO(address: Address): AddressDTO { return { line1: address.line1, line2: address.line2 ?? null, city: address.city, state: address.state, postalCode: address.postalCode.value, country: address.country.code, }; } private mapStatus(status: OrderStatus): string { // Explicit mapping allows for contract stability // even if internal enum values change switch (status) { case OrderStatus.PENDING_PAYMENT: case OrderStatus.PAYMENT_PROCESSING: return 'pending'; case OrderStatus.CONFIRMED: case OrderStatus.PREPARING: return 'processing'; case OrderStatus.SHIPPED: case OrderStatus.IN_TRANSIT: return 'shipped'; case OrderStatus.DELIVERED: return 'delivered'; case OrderStatus.CANCELLED: case OrderStatus.REFUNDED: return 'cancelled'; } }}- •Full control — Every field mapping is explicit and visible
- •No magic — Team members understand exactly what happens
- •Compile-time safety — Type errors caught when compiling
- •Custom transformations — Easy to add complex logic (like status mapping above)
- •Debuggable — Can step through every line
- •Performance — No reflection overhead
- •Tedious boilerplate — Lots of repetitive code
- •Easy to forget fields — New fields may not be mapped
- •Maintenance burden — Changes require updating mappers
- •Copy-paste errors — Similar code breeds mistakes
- •No standard structure — Each developer may do it differently
- •Time-consuming — Initial implementation takes longer
Regardless of mapping approach, write tests for your mappers. A simple test that creates a domain object with all fields populated and verifies the DTO contains expected values catches forgotten fields. For manual mappers, this is essential.
Automated Mapping Libraries
To reduce boilerplate, various libraries automate the mapping process using reflection, code generation, or convention-based matching. Popular options include:
Java/Kotlin: MapStruct, ModelMapper, Dozer, Orika C#/.NET: AutoMapper, Mapster TypeScript/JavaScript: class-transformer, automapper-ts, @automapper/core
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586
// === EXAMPLE 1: AutoMapper (@automapper/core) === // Define mapping profileimport { createMap, forMember, mapFrom, Mapper } from '@automapper/core'; function createMappingProfile(mapper: Mapper) { // Convention-based: matches same-named properties automatically createMap<Order, OrderResponseDTO>( mapper, Order, OrderResponseDTO, // Custom mappings for non-matching fields forMember( dest => dest.orderId, mapFrom(src => src.id.value) ), forMember( dest => dest.totalCents, mapFrom(src => src.total.amountInCents) ), forMember( dest => dest.customer, mapFrom(src => mapper.map(src.customer, Customer, CustomerSummaryDTO)) ), forMember( dest => dest.isEditable, mapFrom(src => src.canBeEdited()) ) );} // Usageconst dto = mapper.map(order, Order, OrderResponseDTO); // === EXAMPLE 2: class-transformer === import { Expose, Transform, Type, plainToInstance, instanceToPlain } from 'class-transformer'; // Decorate the DTO class with transformation rulesclass OrderResponseDTO { @Expose({ name: 'id' }) // Map from 'id' property orderId: string; @Expose() orderNumber: string; @Transform(({ obj }) => obj.total.amountInCents) totalCents: number; @Type(() => OrderItemDTO) // Handle nested objects items: OrderItemDTO[]; @Transform(({ obj }) => obj.canBeEdited()) isEditable: boolean;} // Usageconst dto = plainToInstance(OrderResponseDTO, order, { excludeExtraneousValues: true }); // === EXAMPLE 3: MapStruct (Java - compile-time) ===// Note: This generates implementation at compile time - no runtime overhead // Java interface@Mapperpublic interface OrderMapper { @Mapping(source = "id.value", target = "orderId") @Mapping(source = "total.amountInCents", target = "totalCents") @Mapping(target = "isEditable", expression = "java(order.canBeEdited())") OrderResponseDTO toDTO(Order order); // Reverse mapping @Mapping(source = "orderId", target = "id", qualifiedByName = "toOrderId") Order toEntity(CreateOrderRequestDTO dto); @Named("toOrderId") default OrderId toOrderId(String value) { return new OrderId(value); }} // Generated at compile time - as fast as manual code!| Type | Examples | Mechanism | Performance | Setup Effort |
|---|---|---|---|---|
| Reflection-based | ModelMapper, AutoMapper | Runtime reflection to match properties | Slower (reflection) | Low (convention-based) |
| Decorator-based | class-transformer | Decorators/annotations guide transformation | Moderate | Medium (decoration) |
| Code Generation | MapStruct (Java) | Generates code at compile time | Fast (no reflection) | Medium (interface defs) |
| Builder-based | @automapper/core | Fluent API builds mapping rules | Moderate | Medium (fluent config) |
Automated mappers reduce boilerplate but introduce 'magic' that can be hard to debug. When a field isn't mapping correctly, diagnosing the issue requires understanding the library's internals. For complex mappings with business logic, the configuration can become as complex as manual code.
Factory and Builder Patterns for DTOs
For complex DTOs with many optional fields or conditional logic, factory methods and builders provide cleaner solutions than long constructor calls:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// === FACTORY METHOD PATTERN === class OrderResponseDTO { // Private constructor - can't create directly private constructor( public readonly orderId: string, public readonly orderNumber: string, public readonly status: string, // ... many more fields ) {} // Static factory method static fromOrder(order: Order): OrderResponseDTO { return new OrderResponseDTO( order.id.value, order.orderNumber, OrderStatusMapper.toApiStatus(order.status), // ... explicit mapping ); } // Factory for test data static forTesting(overrides: Partial<OrderResponseDTO> = {}): OrderResponseDTO { return Object.assign( new OrderResponseDTO( 'test-order-123', 'ORD-2024-0001', 'pending', // ... default test values ), overrides ); }} // Usageconst dto = OrderResponseDTO.fromOrder(order); // === BUILDER PATTERN === class OrderResponseDTOBuilder { private dto: Partial<OrderResponseDTO> = {}; withOrder(order: Order): this { this.dto.orderId = order.id.value; this.dto.orderNumber = order.orderNumber; this.dto.createdAt = order.createdAt.toISOString(); return this; } withStatus(status: OrderStatus): this { this.dto.status = OrderStatusMapper.toApiStatus(status); return this; } withPricing(order: Order): this { this.dto.subtotalCents = order.subtotal.amountInCents; this.dto.discountCents = order.discount.amountInCents; this.dto.taxCents = order.tax.amountInCents; this.dto.shippingCents = order.shippingCost.amountInCents; this.dto.totalCents = order.total.amountInCents; this.dto.currency = order.currency.code; return this; } withItems(items: OrderItem[]): this { this.dto.items = items.map(item => OrderItemDTO.fromItem(item)); return this; } withHypermediaLinks(order: Order): this { this.dto.links = { self: `/orders/${order.id.value}`, cancel: order.canBeCancelled() ? `/orders/${order.id.value}/cancel` : null, // ... other links }; return this; } // Conditional inclusions withCustomerIfPublic(customer: Customer, includeEmail: boolean): this { this.dto.customer = { customerId: customer.id.value, name: customer.fullName, email: includeEmail ? customer.email.value : undefined, }; return this; } build(): OrderResponseDTO { // Validate required fields if (!this.dto.orderId) throw new Error('orderId is required'); if (!this.dto.orderNumber) throw new Error('orderNumber is required'); return this.dto as OrderResponseDTO; }} // Usage - clear, flexible, and self-documentingconst dto = new OrderResponseDTOBuilder() .withOrder(order) .withStatus(order.status) .withPricing(order) .withItems(order.items) .withCustomerIfPublic(order.customer, user.canSeeEmails()) .withHypermediaLinks(order) .build();When to use each pattern:
| Pattern | Use When... |
|---|---|
| Factory Method | Simple transformation with clear inputs |
| Builder | Many optional fields, conditional logic, or multiple variations |
| Constructor | Very simple DTOs with few fields |
| Mapper Class | Need to inject dependencies (repositories, formatters) |
Builders shine when different API consumers need different data. An admin user might get withInternalMetadata() while a public user doesn't. The builder pattern makes these variations explicit and composable rather than buried in conditional logic.
Nested Object and Collection Mapping
Real-world DTOs often contain nested objects and collections. These require special attention:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// === HANDLING NESTED OBJECTS === class OrderMapper { constructor( private customerMapper: CustomerMapper, private addressMapper: AddressMapper, private itemMapper: OrderItemMapper ) {} toDetailDTO(order: Order): OrderDetailDTO { return { orderId: order.id.value, orderNumber: order.orderNumber, // Delegate nested mapping to specialized mappers customer: this.customerMapper.toSummaryDTO(order.customer), shippingAddress: this.addressMapper.toDTO(order.shippingAddress), billingAddress: this.addressMapper.toDTO(order.billingAddress), // Map collection with specialized mapper items: order.items.map(item => this.itemMapper.toDTO(item)), // Map another collection statusHistory: order.statusHistory.map(entry => ({ status: entry.status.toString(), changedAt: entry.timestamp.toISOString(), changedBy: entry.actor?.name ?? 'System', reason: entry.reason ?? null, })), }; } // Summary DTO doesn't include deep nesting toSummaryDTO(order: Order): OrderSummaryDTO { return { orderId: order.id.value, orderNumber: order.orderNumber, status: order.status.toString(), totalCents: order.total.amountInCents, itemCount: order.items.length, createdAt: order.createdAt.toISOString(), // No nested customer, address, or items - just summary data }; }} // === HANDLING COLLECTIONS (N+1 awareness) === class OrderListMapper { constructor( private orderMapper: OrderMapper, private customerCache: Map<string, CustomerSummaryDTO> = new Map() ) {} // Batch mapping to avoid N+1 problems toSummaryDTOs(orders: Order[]): OrderSummaryDTO[] { // Pre-fetch all needed data in one batch const customerIds = [...new Set(orders.map(o => o.customerId.value))]; const customers = this.customerRepository.findByIds(customerIds); // Build lookup cache customers.forEach(c => { this.customerCache.set(c.id.value, this.customerMapper.toSummaryDTO(c)); }); // Now map with cache hits, not queries return orders.map(order => ({ ...this.orderMapper.toSummaryDTO(order), customer: this.customerCache.get(order.customerId.value)!, })); }} // === HANDLING OPTIONAL NESTED OBJECTS === class OrderMapper { toDTO(order: Order): OrderResponseDTO { return { // ... other fields ... // Optional nested object - explicitly handle null/undefined tracking: order.shipment ? { carrier: order.shipment.carrier.name, trackingNumber: order.shipment.trackingNumber, trackingUrl: order.shipment.trackingUrl, estimatedDelivery: order.shipment.eta?.toISOString() ?? null, } : null, // Optional collection - return empty array, not null promoCodesApplied: order.appliedPromoCodes ? order.appliedPromoCodes.map(p => p.code) : [], }; }}When mapping collections with nested objects, naive approaches trigger database queries for each item. If you loop through 100 orders and lazy-load each order's customer, you make 100 customer queries. Pre-fetch data in batches before mapping, or ensure objects are already loaded.
Collection Mapping Guidelines:
- Empty over null — Return
[]for empty collections, notnull. Simpler for consumers. - Limit depth — Summary DTOs should have minimal nesting; detail DTOs can go deeper.
- Batch fetch — Load all nested data upfront before mapping collections.
- Consider pagination — For large nested collections, return counts and links instead of full data.
Bidirectional Mapping Challenges
Mapping from domain to DTO (for responses) is relatively straightforward. Mapping from DTO to domain (for requests) is often more complex due to:
- Data enrichment — DTOs don't contain all the data domain objects need
- Validation — Domain objects enforce invariants that DTOs don't
- Reference resolution — DTO contains IDs; domain needs actual objects
- Defaults — Domain objects may have defaults that DTOs don't specify
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
// === REQUEST DTO TO DOMAIN === // The request DTO - what the client sendsinterface CreateOrderRequestDTO { customerId: string; items: Array<{ productId: string; quantity: number; }>; shippingAddressId: string; billingAddressId: string | null; // Optional - defaults to shipping couponCode: string | null;} class OrderCreationMapper { constructor( private customerRepository: CustomerRepository, private productRepository: ProductRepository, private addressRepository: AddressRepository, private pricingService: PricingService ) {} // This is NOT a simple field copy! async toCreateCommand( dto: CreateOrderRequestDTO, requestingUserId: UserId ): Promise<CreateOrderCommand> { // 1. Resolve references to actual domain objects const customer = await this.customerRepository.findById( new CustomerId(dto.customerId) ); if (!customer) { throw new CustomerNotFoundError(dto.customerId); } // 2. Validate authorization if (!customer.canBeAccessedBy(requestingUserId)) { throw new UnauthorizedError('Cannot create order for this customer'); } // 3. Resolve and validate products const productIds = dto.items.map(i => new ProductId(i.productId)); const products = await this.productRepository.findByIds(productIds); const missingProducts = productIds.filter( id => !products.some(p => p.id.equals(id)) ); if (missingProducts.length > 0) { throw new ProductsNotFoundError(missingProducts); } // 4. Build domain value objects const orderItems = dto.items.map(itemDto => { const product = products.find(p => p.id.value === itemDto.productId)!; // Validate product is orderable if (!product.isAvailable) { throw new ProductUnavailableError(product.id); } // Check stock if (product.stockQuantity < itemDto.quantity) { throw new InsufficientStockError(product.id, itemDto.quantity); } return OrderItem.create( product, Quantity.of(itemDto.quantity), this.pricingService.getPriceFor(product, customer) ); }); // 5. Resolve addresses const shippingAddress = await this.resolveAddress( dto.shippingAddressId, customer ); const billingAddress = dto.billingAddressId ? await this.resolveAddress(dto.billingAddressId, customer) : shippingAddress; // Default to shipping address // 6. Handle optional fields let coupon: Coupon | null = null; if (dto.couponCode) { coupon = await this.validateAndResolveCoupon(dto.couponCode, customer); } // 7. Build the command (still not the Order entity!) return new CreateOrderCommand( customer, orderItems, shippingAddress, billingAddress, coupon ); } private async resolveAddress( addressId: string, customer: Customer ): Promise<Address> { const address = await this.addressRepository.findById( new AddressId(addressId) ); if (!address) { throw new AddressNotFoundError(addressId); } if (!address.belongsTo(customer)) { throw new UnauthorizedError('Address does not belong to customer'); } return address; }}Key observations:
-
Request mapping requires dependencies — Unlike response mapping (pure transformation), request mapping often needs repositories, services, and context.
-
Mapping includes validation — The mapper verifies that references exist, user has permission, products are available, etc.
-
The result is often a Command, not an Entity — Request DTOs map to command objects that the domain layer uses to create entities. The mapper doesn't create the entity directly.
-
Defaults are applied —
billingAddressdefaults toshippingAddress; this business rule lives in the mapper.
For complex creation scenarios, map request DTOs to Command objects (part of CQRS pattern), not directly to entities. Commands encapsulate all validated, resolved data needed for the operation. The domain service then uses the command to create the entity with full control over invariants.
Performance Considerations
Mapping overhead is often dismissed as negligible, but at scale, it matters. Here are the performance considerations:
| Approach | Relative Speed | Memory Impact | Notes |
|---|---|---|---|
| Manual mapping | Fastest | Minimal | Direct field access, no reflection |
| Compile-time generation (MapStruct) | Fast | Minimal | Generated code is like manual |
| Expression-based (AutoMapper) | Moderate | Moderate | Expression compilation caching helps |
| Reflection-based (ModelMapper) | Slower | Higher | Reflection on every call, but improves with caching |
| Serialization round-trip | Slowest | Highest | JSON.stringify/parse as mapping—don't do this |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// === PERFORMANCE OPTIMIZATIONS === // 1. AVOID: Mapping inside loops with dependenciesasync function mapOrdersBad(orders: Order[]): Promise<OrderDTO[]> { const dtos: OrderDTO[] = []; for (const order of orders) { // N+1 problem: fetches customer for each order const customer = await customerRepo.findById(order.customerId); dtos.push(mapToDTO(order, customer)); } return dtos;} // PREFER: Batch fetch, then mapasync function mapOrdersGood(orders: Order[]): Promise<OrderDTO[]> { // Single batch fetch const customerIds = orders.map(o => o.customerId); const customers = await customerRepo.findByIds(customerIds); const customerMap = new Map(customers.map(c => [c.id.value, c])); // Now map without async return orders.map(order => mapToDTO(order, customerMap.get(order.customerId.value)!) );} // 2. CACHE: Reuse mapper instancesclass MapperRegistry { private static orderMapper: OrderMapper | null = null; static getOrderMapper(): OrderMapper { if (!this.orderMapper) { this.orderMapper = new OrderMapper(/* dependencies */); } return this.orderMapper; }} // 3. LAZY COMPUTED FIELDS: Only compute expensive fields when neededclass OrderResponseDTO { private _computedSummary: string | null = null; get summary(): string { if (!this._computedSummary) { this._computedSummary = this.computeExpensiveSummary(); } return this._computedSummary; } // Don't include in toJSON if not accessed toJSON() { return { orderId: this.orderId, // ... other fields // summary only included if pre-computed ...(this._computedSummary && { summary: this._computedSummary }), }; }} // 4. PROJECTION: Don't map what you don't needclass OrderRepository { // Instead of mapping full entities async findAllOrders(): Promise<Order[]> { return this.repository.find(); // Full ORM entities } // Query only needed columns async findOrderSummaries(): Promise<OrderSummaryDTO[]> { return this.repository .createQueryBuilder('order') .select([ 'order.id AS orderId', 'order.orderNumber AS orderNumber', 'order.status AS status', 'order.totalCents AS totalCents', 'order.createdAt AS createdAt', ]) .getRawMany(); // Direct to DTO shape }}For read-heavy APIs returning lists of simple DTOs, consider querying directly into the DTO shape (database projection) rather than loading full entities and mapping. This skips both ORM hydration and mapping overhead. Many ORMs support this with getRawMany() or similar methods.
Testing Mapping Logic
Mappers contain logic that can break. Automated tests catch mapping errors before they reach production:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
// === COMPREHENSIVE MAPPER TESTS === describe('OrderMapper', () => { let mapper: OrderMapper; let testOrder: Order; beforeEach(() => { mapper = new OrderMapper(); testOrder = OrderBuilder.create() .withAllFieldsPopulated() // Every field has a value .build(); }); describe('toResponseDTO', () => { it('maps all basic fields correctly', () => { const dto = mapper.toResponseDTO(testOrder); expect(dto.orderId).toBe(testOrder.id.value); expect(dto.orderNumber).toBe(testOrder.orderNumber); expect(dto.status).toBeDefined(); expect(dto.createdAt).toBe(testOrder.createdAt.toISOString()); }); it('maps monetary values as cents', () => { const dto = mapper.toResponseDTO(testOrder); expect(dto.subtotalCents).toBe(testOrder.subtotal.amountInCents); expect(dto.totalCents).toBe(testOrder.total.amountInCents); expect(typeof dto.totalCents).toBe('number'); // Verify no floating point conversion issues expect(Number.isInteger(dto.totalCents)).toBe(true); }); it('maps nested items correctly', () => { const dto = mapper.toResponseDTO(testOrder); expect(dto.items).toHaveLength(testOrder.items.length); dto.items.forEach((itemDto, index) => { const sourceItem = testOrder.items[index]; expect(itemDto.productId).toBe(sourceItem.product.id.value); expect(itemDto.quantity).toBe(sourceItem.quantity.value); }); }); it('handles null optional fields gracefully', () => { const orderWithoutShipment = OrderBuilder.create() .withShipment(null) .build(); const dto = mapper.toResponseDTO(orderWithoutShipment); expect(dto.tracking).toBeNull(); expect(dto.links.trackShipment).toBeNull(); }); it('computes derived fields correctly', () => { const pendingOrder = OrderBuilder.create() .withStatus(OrderStatus.PENDING) .build(); const dto = mapper.toResponseDTO(pendingOrder); expect(dto.isEditable).toBe(true); expect(dto.isCancellable).toBe(true); }); it('maps all status values to valid API statuses', () => { // Test every internal status maps to a valid API status const allStatuses = Object.values(OrderStatus); const validApiStatuses = ['pending', 'processing', 'shipped', 'delivered', 'cancelled']; allStatuses.forEach(internalStatus => { const order = OrderBuilder.create() .withStatus(internalStatus) .build(); const dto = mapper.toResponseDTO(order); expect(validApiStatuses).toContain(dto.status); }); }); }); describe('toSummaryDTO', () => { it('excludes detailed fields present in response DTO', () => { const dto = mapper.toSummaryDTO(testOrder); // Summary should NOT have these expect((dto as any).items).toBeUndefined(); expect((dto as any).shippingAddress).toBeUndefined(); expect((dto as any).statusHistory).toBeUndefined(); }); it('includes computed summary fields', () => { const dto = mapper.toSummaryDTO(testOrder); expect(dto.itemCount).toBe(testOrder.items.length); }); }); // Regression test: ensure new fields are mapped it('should fail if Order entity has unmapped fields', () => { // This test uses reflection/schema to detect unmapped fields const entityFields = Object.keys(testOrder); const dtoFields = Object.keys(mapper.toResponseDTO(testOrder)); const expectedMappings = [ 'id -> orderId', 'orderNumber -> orderNumber', // Document all expected mappings ]; // If this fails, a new field was added to Order but not mapped entityFields.forEach(field => { if (!INTENTIONALLY_UNMAPPED.includes(field)) { expect(expectedMappings.some(m => m.includes(field))).toBe(true); } }); });});Key things to test in mappers: (1) Every field is mapped correctly, (2) Null/optional handling works, (3) Nested objects/collections are fully mapped, (4) Derived/computed fields produce expected values, (5) All enum values are covered. Consider property-based testing for edge cases.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.