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:
BookingServicedoesn’t know about Ryanair’s quirksRyanairAdapterisolates 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.