/** * RateSearchService * * Domain service implementing the rate search business logic * * Business Rules: * - Query multiple carriers in parallel * - Cache results for 15 minutes * - Handle carrier timeouts gracefully (5s max) * - Return results even if some carriers fail */ import { RateQuote } from '../entities/rate-quote.entity'; import { SearchRatesPort, RateSearchInput, RateSearchOutput } from '../ports/in/search-rates.port'; import { CarrierConnectorPort } from '../ports/out/carrier-connector.port'; import { CachePort } from '../ports/out/cache.port'; import { RateQuoteRepository } from '../ports/out/rate-quote.repository'; import { PortRepository } from '../ports/out/port.repository'; import { CarrierRepository } from '../ports/out/carrier.repository'; import { PortNotFoundException } from '../exceptions/port-not-found.exception'; import { v4 as uuidv4 } from 'uuid'; export class RateSearchService implements SearchRatesPort { private static readonly CACHE_TTL_SECONDS = 15 * 60; // 15 minutes constructor( private readonly carrierConnectors: CarrierConnectorPort[], private readonly cache: CachePort, private readonly rateQuoteRepository: RateQuoteRepository, private readonly portRepository: PortRepository, private readonly carrierRepository: CarrierRepository ) {} async execute(input: RateSearchInput): Promise { const searchId = uuidv4(); const searchedAt = new Date(); // Validate ports exist await this.validatePorts(input.origin, input.destination); // Generate cache key const cacheKey = this.generateCacheKey(input); // Check cache first const cachedResults = await this.cache.get(cacheKey); if (cachedResults) { return cachedResults; } // Filter carriers if preferences specified const connectorsToQuery = this.filterCarrierConnectors(input.carrierPreferences); // Query all carriers in parallel with Promise.allSettled const carrierResults = await Promise.allSettled( connectorsToQuery.map(connector => this.queryCarrier(connector, input)) ); // Process results const quotes: RateQuote[] = []; const carrierResultsSummary: RateSearchOutput['carrierResults'] = []; for (let i = 0; i < carrierResults.length; i++) { const result = carrierResults[i]; const connector = connectorsToQuery[i]; const carrierName = connector.getCarrierName(); if (result.status === 'fulfilled') { const carrierQuotes = result.value; quotes.push(...carrierQuotes); carrierResultsSummary.push({ carrierName, status: 'success', resultCount: carrierQuotes.length, }); } else { // Handle error const error = result.reason; carrierResultsSummary.push({ carrierName, status: error.name === 'CarrierTimeoutException' ? 'timeout' : 'error', resultCount: 0, errorMessage: error.message, }); } } // Save rate quotes to database if (quotes.length > 0) { await this.rateQuoteRepository.saveMany(quotes); } // Build output const output: RateSearchOutput = { quotes, searchId, searchedAt, totalResults: quotes.length, carrierResults: carrierResultsSummary, }; // Cache results await this.cache.set(cacheKey, output, RateSearchService.CACHE_TTL_SECONDS); return output; } private async validatePorts(originCode: string, destinationCode: string): Promise { const [origin, destination] = await Promise.all([ this.portRepository.findByCode(originCode), this.portRepository.findByCode(destinationCode), ]); if (!origin) { throw new PortNotFoundException(originCode); } if (!destination) { throw new PortNotFoundException(destinationCode); } } private generateCacheKey(input: RateSearchInput): string { const parts = [ 'rate-search', input.origin, input.destination, input.containerType, input.mode, input.departureDate.toISOString().split('T')[0], input.quantity || 1, input.isHazmat ? 'hazmat' : 'standard', ]; return parts.join(':'); } private filterCarrierConnectors(carrierPreferences?: string[]): CarrierConnectorPort[] { if (!carrierPreferences || carrierPreferences.length === 0) { return this.carrierConnectors; } return this.carrierConnectors.filter(connector => carrierPreferences.includes(connector.getCarrierCode()) ); } private async queryCarrier( connector: CarrierConnectorPort, input: RateSearchInput ): Promise { return connector.searchRates({ origin: input.origin, destination: input.destination, containerType: input.containerType, mode: input.mode, departureDate: input.departureDate, quantity: input.quantity, weight: input.weight, volume: input.volume, isHazmat: input.isHazmat, imoClass: input.imoClass, }); } }