Skip to main content

ADR-0032: Adapter Pattern for Channel Connectors

Status

Accepted - 2025-01-26


Context

TVL Platform integrates with multiple channels (Hostaway, Airbnb, VRBO) with different APIs, authentication, and data formats.


Decision

Adapter Pattern with shared ChannelConnector interface for all channel integrations.

Rationale

  1. Polymorphism: Treat all channels uniformly
  2. Testability: Mock adapters for unit tests
  3. Extensibility: Add new channels without changing core logic
  4. Separation of Concerns: Channel-specific logic isolated

Alternatives Considered

Alternative 1: Direct API Calls

Rejected - Tight coupling, hard to test, duplicated logic across channels

Alternative 2: API Gateway Pattern

Rejected - Overkill for MVP, adds latency, complex routing

Alternative 3: Strategy Pattern

Rejected - Similar to Adapter, but less semantic fit for external systems


Implementation

1. Channel Connector Interface

// src/integrations/ChannelConnector.ts
export interface ChannelConnector {
// Authentication
authenticate(credentials: ChannelCredentials): Promise<void>;
refreshToken(): Promise<void>;

// Property Management
listProperties(): Promise<Property[]>;
getProperty(externalId: string): Promise<Property>;
createProperty(data: PropertyData): Promise<Property>;
updateProperty(externalId: string, data: Partial<PropertyData>): Promise<Property>;

// Booking Management
listBookings(filters: BookingFilters): Promise<Booking[]>;
getBooking(externalId: string): Promise<Booking>;
createBooking(data: BookingData): Promise<Booking>;
cancelBooking(externalId: string): Promise<void>;

// Availability
getAvailability(propertyId: string, dateRange: DateRange): Promise<Availability[]>;
updateAvailability(propertyId: string, availability: Availability[]): Promise<void>;
}

2. Hostaway Adapter

// src/integrations/hostaway/HostawayConnector.ts
import axios from 'axios';
import { ChannelConnector } from '../ChannelConnector';

export class HostawayConnector implements ChannelConnector {
private apiKey: string;
private apiSecret: string;
private baseURL = 'https://api.hostaway.com/v1';

constructor(credentials: HostawayCredentials) {
this.apiKey = credentials.apiKey;
this.apiSecret = credentials.apiSecret;
}

async authenticate() {
const response = await axios.post(`${this.baseURL}/accessTokens`, {
grant_type: 'client_credentials',
client_id: this.apiKey,
client_secret: this.apiSecret,
});
this.accessToken = response.data.access_token;
}

async listProperties(): Promise<Property[]> {
const response = await axios.get(`${this.baseURL}/listings`, {
headers: { Authorization: `Bearer ${this.accessToken}` },
});

// Transform Hostaway format → TVL format
return response.data.result.map((listing: any) => ({
id: listing.id,
name: listing.name,
address: listing.address,
unitCount: listing.bedrooms,
}));
}

async createBooking(data: BookingData): Promise<Booking> {
const response = await axios.post(
`${this.baseURL}/reservations`,
{
listingId: data.propertyId,
guestName: data.guestName,
checkInDate: data.checkIn,
checkOutDate: data.checkOut,
totalPrice: data.totalCents / 100,
},
{ headers: { Authorization: `Bearer ${this.accessToken}` } }
);

return {
id: response.data.result.id,
guestName: response.data.result.guestName,
checkIn: response.data.result.checkInDate,
checkOut: response.data.result.checkOutDate,
totalCents: response.data.result.totalPrice * 100,
};
}
}

3. Airbnb Adapter

// src/integrations/airbnb/AirbnbConnector.ts
export class AirbnbConnector implements ChannelConnector {
private accessToken: string;
private baseURL = 'https://api.airbnb.com/v2';

async listProperties(): Promise<Property[]> {
const response = await axios.get(`${this.baseURL}/listings`, {
headers: { 'X-Airbnb-OAuth-Token': this.accessToken },
});

// Transform Airbnb format → TVL format
return response.data.listings.map((listing: any) => ({
id: listing.id.toString(),
name: listing.name,
address: `${listing.city}, ${listing.state}`,
unitCount: listing.bedrooms,
}));
}
}

4. Connector Factory

// src/integrations/ConnectorFactory.ts
export class ConnectorFactory {
static create(channel: string, credentials: any): ChannelConnector {
switch (channel) {
case 'hostaway':
return new HostawayConnector(credentials);
case 'airbnb':
return new AirbnbConnector(credentials);
case 'vrbo':
return new VrboConnector(credentials);
default:
throw new Error(`Unknown channel: ${channel}`);
}
}
}

// Usage
const connector = ConnectorFactory.create('hostaway', {
apiKey: process.env.HOSTAWAY_API_KEY,
apiSecret: process.env.HOSTAWAY_API_SECRET,
});

const properties = await connector.listProperties();

Testing Strategy

Mock Adapter for Tests

// src/integrations/__mocks__/MockConnector.ts
export class MockConnector implements ChannelConnector {
async listProperties(): Promise<Property[]> {
return [
{ id: '1', name: 'Test Property', address: '123 Main St', unitCount: 3 },
];
}

async createBooking(data: BookingData): Promise<Booking> {
return { id: 'mock-123', ...data };
}
}

// Test
describe('Booking Service', () => {
it('should create booking via connector', async () => {
const connector = new MockConnector();
const booking = await bookingService.create(connector, bookingData);
expect(booking.id).toBe('mock-123');
});
});

Consequences

Positive

  • Uniform Interface: All channels use same API
  • Testability: Easy to mock connectors
  • Extensibility: Add channels without changing core
  • Type Safety: TypeScript enforces interface compliance

Negative

  • Interface Changes: Breaking changes affect all adapters
  • Lowest Common Denominator: Interface limited to shared features

Mitigations

  • Version connector interface (v1, v2) for breaking changes
  • Use optional methods for channel-specific features
  • Document adapter-specific quirks in code comments

Validation Checklist

  • ChannelConnector interface defined
  • Adapters for Hostaway, Airbnb, VRBO
  • Connector factory for instantiation
  • Mock connector for tests
  • Error handling for API failures

References