by skunxicat

Reverse Engineering as Architecture School

How working with black boxes teaches clean boundaries, and why chaos forces disciplined design

The Accidental Education

Most architects learn from greenfield projects, clean APIs, and well-documented systems. But the best architectural lessons come from the opposite: reverse engineering black boxes that actively fight you.

When you’re automating Ryanair’s booking flow or scraping airline data, you don’t get:

  • API documentation
  • Stable endpoints
  • Predictable responses
  • Error codes that make sense

What you get instead is chaos. And chaos is the best architecture teacher.

Lesson 1: Boundaries Are Everything

The Problem:

// Anti-pattern: Tightly coupled to external chaos
class BookingService {
  async book(request) {
    // Direct dependency on unpredictable system
    const session = await this.ryanairLogin(request.email);
    const search = await this.ryanairSearch(session, request.route);
    const booking = await this.ryanairBook(session, search.flights[0]);
    
    // What happens when any of this breaks?
    return booking;
  }
}

The Lesson: When external systems are unreliable, you’re forced to create clean boundaries:

// Pattern: Clean boundaries with adapters
class BookingService {
  constructor(carrierAdapter, sessionManager, resultStore) {
    this.carrier = carrierAdapter;
    this.sessions = sessionManager;
    this.results = resultStore;
  }
  
  async book(request) {
    try {
      const session = await this.sessions.get(request.carrier);
      const result = await this.carrier.book(session, request);
      await this.results.store(request.id, result);
      return result;
    } catch (error) {
      await this.handleFailure(request, error);
      throw new BookingError(error);
    }
  }
}

Why this works:

  • BookingService doesn’t know about Ryanair’s quirks
  • RyanairAdapter isolates all the chaos
  • Easy to swap carriers or mock for testing
  • Clear failure boundaries

Lesson 2: State Machines Save Your Sanity

The Problem: Airline booking flows are state machines disguised as chaos:

Login → Search → Select → Passenger → Payment → Confirmation
  ↓       ↓        ↓         ↓          ↓           ↓
Session  Results  Cart    Validation  Processing  PNR
Expired  Changed  Timeout   Failed     Failed    Success

The Lesson: Model the chaos as explicit states:

class BookingStateMachine {
  constructor() {
    this.states = {
      INIT: { next: ['AUTHENTICATING'] },
      AUTHENTICATING: { next: ['SEARCHING', 'AUTH_FAILED'] },
      SEARCHING: { next: ['SELECTING', 'NO_RESULTS'] },
      SELECTING: { next: ['BOOKING', 'SELECTION_EXPIRED'] },
      BOOKING: { next: ['COMPLETED', 'PAYMENT_FAILED'] },
      COMPLETED: { next: [] },
      // Error states
      AUTH_FAILED: { next: ['AUTHENTICATING'] },
      NO_RESULTS: { next: ['SEARCHING'] },
      SELECTION_EXPIRED: { next: ['SEARCHING'] },
      PAYMENT_FAILED: { next: ['SELECTING'] }
    };
  }
  
  async transition(currentState, action, context) {
    const validTransitions = this.states[currentState].next;
    const nextState = await this.executeAction(action, context);
    
    if (!validTransitions.includes(nextState)) {
      throw new InvalidTransitionError(currentState, nextState);
    }
    
    return nextState;
  }
}

Benefits:

  • Explicit error handling for each state
  • Clear recovery paths
  • Impossible to get into invalid states
  • Easy to visualize and debug

Lesson 3: Observability Is Not Optional

The Problem: When systems break unpredictably, you need to know exactly what happened:

// Anti-pattern: Silent failures
const result = await ryanair.search(params);
if (!result) {
  // What went wrong? No idea.
  throw new Error('Search failed');
}

The Lesson: Instrument everything when dealing with black boxes:

class InstrumentedCarrierAdapter {
  async search(session, params) {
    const traceId = generateTraceId();
    const startTime = Date.now();
    
    try {
      // Log the attempt
      Logger.info('search.started', {
        traceId,
        carrier: 'ryanair',
        route: params.route,
        sessionAge: Date.now() - session.createdAt
      });
      
      const result = await this.carrier.search(session, params);
      
      // Log success with details
      Logger.info('search.completed', {
        traceId,
        duration: Date.now() - startTime,
        resultCount: result.flights.length,
        priceRange: this.getPriceRange(result.flights)
      });
      
      return result;
      
    } catch (error) {
      // Log failure with context
      Logger.error('search.failed', {
        traceId,
        duration: Date.now() - startTime,
        errorType: error.constructor.name,
        errorMessage: error.message,
        sessionValid: await this.validateSession(session),
        lastSuccessfulSearch: session.lastSuccess
      });
      
      throw error;
    }
  }
}

What you learn:

  • Every external call needs tracing
  • Context is more important than the error message
  • Timing patterns reveal system behavior
  • Success metrics are as important as failure metrics

Lesson 4: Idempotency Is Your Friend

The Problem: External systems have unpredictable retry behavior:

// Dangerous: What if this runs twice?
const booking = await ryanair.book(session, flight);
await payment.charge(booking.price);
await email.sendConfirmation(booking.pnr);

The Lesson: Design for idempotency from day one:

class IdempotentBookingService {
  async book(requestId, params) {
    // Check if already processed
    const existing = await this.results.get(requestId);
    if (existing) {
      return existing;
    }
    
    // Use request ID as idempotency key
    const booking = await this.carrier.book(requestId, params);
    
    // Store result atomically
    await this.results.store(requestId, booking);
    
    // Safe to retry side effects
    await this.notifySuccess(requestId, booking);
    
    return booking;
  }
}

Benefits:

  • Safe retries at any level
  • No duplicate charges or emails
  • Easy to replay failed requests
  • Simplified error recovery

Lesson 5: Circuit Breakers Prevent Cascading Failures

The Problem: When external systems fail, they often fail hard:

// Anti-pattern: Keeps hammering broken system
for (const request of requests) {
  try {
    await ryanair.book(request); // Fails 100 times
  } catch (error) {
    // Logs error, continues failing
  }
}

The Lesson: Implement circuit breakers for external dependencies:

class CircuitBreakerAdapter {
  constructor(adapter, options = {}) {
    this.adapter = adapter;
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 60000;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.failures = 0;
    this.lastFailureTime = null;
  }
  
  async book(params) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime < this.resetTimeout) {
        throw new CircuitOpenError('Circuit breaker is OPEN');
      }
      this.state = 'HALF_OPEN';
    }
    
    try {
      const result = await this.adapter.book(params);
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
  
  onSuccess() {
    this.failures = 0;
    this.state = 'CLOSED';
  }
  
  onFailure() {
    this.failures++;
    this.lastFailureTime = Date.now();
    
    if (this.failures >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }
}

Lesson 6: Graceful Degradation

The Problem: All-or-nothing systems are fragile:

// Fragile: If search fails, everything fails
const flights = await ryanair.search(params);
const prices = await ryanair.getPrices(flights);
const availability = await ryanair.checkAvailability(flights);

return { flights, prices, availability };

The Lesson: Design for partial success:

class ResilientSearchService {
  async search(params) {
    const results = {
      flights: [],
      prices: null,
      availability: null,
      warnings: []
    };
    
    try {
      results.flights = await this.carrier.search(params);
    } catch (error) {
      results.warnings.push('Flight search failed');
      return results; // Return partial data
    }
    
    // Only try pricing if we have flights
    if (results.flights.length > 0) {
      try {
        results.prices = await this.carrier.getPrices(results.flights);
      } catch (error) {
        results.warnings.push('Pricing unavailable');
      }
      
      try {
        results.availability = await this.carrier.checkAvailability(results.flights);
      } catch (error) {
        results.warnings.push('Availability check failed');
      }
    }
    
    return results;
  }
}

Real-World Example: Ryanair Automation

The Challenge: Automate Ryanair booking with:

  • AWS WAF protection
  • Dynamic pricing
  • Session timeouts
  • CAPTCHA challenges
  • Unpredictable UI changes

The Architecture:

// Clean separation of concerns
class RyanairBookingSystem {
  constructor() {
    this.sessionManager = new SessionManager();
    this.wafBypass = new AWSWAFBypass();
    this.bookingEngine = new BookingEngine();
    this.stateManager = new BookingStateMachine();
    this.circuitBreaker = new CircuitBreaker();
  }
  
  async book(request) {
    const job = await this.createJob(request);
    
    try {
      // Each component handles its own chaos
      const session = await this.sessionManager.getSession();
      const wafTokens = await this.wafBypass.getTokens();
      const booking = await this.circuitBreaker.execute(() =>
        this.bookingEngine.book(session, wafTokens, request)
      );
      
      await this.completeJob(job, booking);
      return booking;
      
    } catch (error) {
      await this.handleJobFailure(job, error);
      throw error;
    }
  }
}

What this architecture gives you:

  • Each component can fail independently
  • Clear boundaries between concerns
  • Easy to test individual components
  • Graceful handling of external chaos

The Meta-Lesson

Reverse engineering teaches you to design for failure first.

When you’re working with reliable systems, you might skip:

  • Proper error boundaries
  • Circuit breakers
  • State machines
  • Comprehensive logging
  • Idempotency

When you’re working with chaos, these aren’t optional. They’re survival tools.

Why This Makes Better Architects

Traditional learning:

  • Start with perfect systems
  • Add complexity gradually
  • Learn patterns in isolation
  • Focus on happy paths

Reverse engineering learning:

  • Start with broken systems
  • Forced to handle edge cases
  • Learn patterns under pressure
  • Failure is the default case

The result: Architects who design resilient systems from day one.

Practical Applications

For any external integration:

class ExternalServiceAdapter {
  constructor(service, options = {}) {
    this.service = service;
    this.circuitBreaker = new CircuitBreaker(options.circuit);
    this.retryPolicy = new RetryPolicy(options.retry);
    this.cache = new Cache(options.cache);
    this.metrics = new Metrics(options.metrics);
  }
  
  async call(method, params, requestId) {
    // Always use request ID for idempotency
    const cacheKey = this.getCacheKey(method, params, requestId);
    
    // Check cache first
    const cached = await this.cache.get(cacheKey);
    if (cached) return cached;
    
    // Execute with circuit breaker and retries
    const result = await this.circuitBreaker.execute(() =>
      this.retryPolicy.execute(() =>
        this.service[method](params)
      )
    );
    
    // Cache successful results
    await this.cache.set(cacheKey, result);
    
    // Record metrics
    this.metrics.recordSuccess(method);
    
    return result;
  }
}

Conclusion

The best architecture lessons come from the worst systems.

When you’re forced to work with:

  • Undocumented APIs
  • Unpredictable failures
  • Changing interfaces
  • Hostile environments

You learn to build systems that are:

  • Resilient by default
  • Observable at every layer
  • Recoverable from any state
  • Testable in isolation

Chaos is the ultimate architecture teacher.


These patterns emerged from automating €27M+ in bookings through systems that actively fought back. Sometimes the best education comes from the worst APIs.