Migrating to Event-Driven Architecture
A practical guide for planning and executing a migration from traditional request-response systems to event-driven architecture, covering assessment frameworks, migration strategies, risk management, and organizational change.
Migrating to event-driven architecture is one of the most consequential decisions an engineering organization can make. Done well, it unlocks scalability, resilience, and agility that monolithic or request-response architectures cannot match. Done poorly, it creates a distributed monolith that is harder to debug, harder to deploy, and harder to reason about than the system it replaced. The difference between success and failure lies not in the technology chosen but in how the migration is planned, executed, and sustained.
This article provides a practical framework for organizations considering or actively pursuing a migration to event-driven architecture, with concrete TypeScript examples and lessons learned from real-world migrations using platforms like Starburst.
Assessing Readiness: Is Your Organization Prepared
Before writing a single line of event-driven code, you need an honest assessment of your starting point. Not every system benefits from event-driven architecture, and not every organization is ready for the operational complexity it introduces.
The assessment should cover three dimensions: technical readiness, organizational readiness, and business alignment.
// Migration readiness assessment framework
interface ReadinessAssessment {
technical: TechnicalReadiness;
organizational: OrganizationalReadiness;
businessAlignment: BusinessAlignment;
overallScore: number;
recommendation: "proceed" | "proceed-with-caution" | "not-ready";
risks: Risk[];
}
interface TechnicalReadiness {
currentArchitecture: "monolith" | "modular-monolith" | "microservices";
hasMessageBrokerExperience: boolean;
hasAsyncProcessingPatterns: boolean;
monitoringMaturity: "basic" | "intermediate" | "advanced";
testingMaturity: "basic" | "intermediate" | "advanced";
cicdMaturity: "basic" | "intermediate" | "advanced";
score: number; // 0-100
}
interface OrganizationalReadiness {
teamSize: number;
distributedSystemsExperience: "none" | "some" | "extensive";
onCallPractices: "none" | "basic" | "mature";
crossTeamCommunication: "siloed" | "collaborative" | "highly-integrated";
appetiteForChange: "resistant" | "cautious" | "enthusiastic";
score: number; // 0-100
}
interface BusinessAlignment {
realTimeRequirements: "none" | "some" | "critical";
scalabilityPressure: "low" | "medium" | "high";
independentDeploymentNeed: "low" | "medium" | "high";
budgetAvailable: boolean;
executiveSponsor: boolean;
score: number; // 0-100
}
interface Risk {
category: "technical" | "organizational" | "business";
description: string;
severity: "low" | "medium" | "high";
mitigation: string;
}
function assessReadiness(
technical: TechnicalReadiness,
organizational: OrganizationalReadiness,
business: BusinessAlignment
): ReadinessAssessment {
const overallScore =
technical.score * 0.4 +
organizational.score * 0.35 +
business.score * 0.25;
const risks: Risk[] = [];
// Identify key risks
if (!technical.hasMessageBrokerExperience) {
risks.push({
category: "technical",
description: "No experience with message brokers or event streaming",
severity: "high",
mitigation:
"Invest in training and start with a proof of concept " +
"before committing to production migration",
});
}
if (technical.monitoringMaturity === "basic") {
risks.push({
category: "technical",
description:
"Basic monitoring is insufficient for event-driven systems",
severity: "high",
mitigation:
"Upgrade monitoring and observability tooling before " +
"migrating production workloads",
});
}
if (organizational.distributedSystemsExperience === "none") {
risks.push({
category: "organizational",
description:
"Team lacks distributed systems experience",
severity: "high",
mitigation:
"Hire or train for distributed systems skills; " +
"consider engaging consultants for initial phases",
});
}
if (!business.executiveSponsor) {
risks.push({
category: "business",
description:
"No executive sponsor for the migration",
severity: "medium",
mitigation:
"Secure executive sponsorship before beginning; " +
"migrations without top-level support frequently stall",
});
}
let recommendation: "proceed" | "proceed-with-caution" | "not-ready";
if (overallScore >= 70 && !risks.some((r) => r.severity === "high")) {
recommendation = "proceed";
} else if (overallScore >= 45) {
recommendation = "proceed-with-caution";
} else {
recommendation = "not-ready";
}
return {
technical,
organizational,
businessAlignment: business,
overallScore: Math.round(overallScore),
recommendation,
risks,
};
}A common mistake is to focus exclusively on technical readiness while ignoring organizational factors. Event-driven architecture changes how teams communicate, how incidents are investigated, and how features are delivered. If the organization is not prepared for these changes, the migration will encounter resistance that no amount of elegant code can overcome.
The Strangler Fig Strategy
The most successful migration strategy for moving to event-driven architecture is the strangler fig pattern. Rather than rewriting the entire system at once, you gradually replace functionality by routing events through the new system while the old system continues to operate.
// Strangler fig implementation: Event bridge between old and new systems
interface LegacySystem {
processOrder(order: LegacyOrder): Promise<LegacyResult>;
getOrder(orderId: string): Promise<LegacyOrder>;
}
interface EventDrivenSystem {
publishEvent(event: DomainEvent): Promise<void>;
handleCommand(command: Command): Promise<CommandResult>;
}
class StranglerFigProxy {
private routingTable = new Map<string, "legacy" | "new" | "both">();
private featureFlags: FeatureFlagService;
constructor(
private legacy: LegacySystem,
private newSystem: EventDrivenSystem,
featureFlags: FeatureFlagService
) {
this.featureFlags = featureFlags;
}
async processOrder(order: LegacyOrder): Promise<LegacyResult> {
const strategy = await this.getRoutingStrategy("order-processing");
switch (strategy) {
case "legacy":
return this.legacy.processOrder(order);
case "new":
return this.processOrderViaNewSystem(order);
case "both":
return this.processOrderWithComparison(order);
}
}
private async processOrderViaNewSystem(
order: LegacyOrder
): Promise<LegacyResult> {
// Transform legacy format to event-driven command
const command: Command = {
type: "CreateOrder",
payload: {
customerId: order.customer_id,
items: order.line_items.map((item) => ({
productId: item.product_id,
quantity: item.qty,
unitPrice: item.price,
})),
shippingAddress: this.transformAddress(order.ship_to),
},
metadata: {
commandId: crypto.randomUUID(),
userId: order.created_by,
timestamp: new Date(),
correlationId: order.order_ref,
},
};
const result = await this.newSystem.handleCommand(command);
// Transform back to legacy format for upstream consumers
return {
success: result.success,
order_id: result.aggregateId ?? "",
message: result.error ?? "Order processed successfully",
};
}
private async processOrderWithComparison(
order: LegacyOrder
): Promise<LegacyResult> {
// Run both systems in parallel
const [legacyResult, newResult] = await Promise.allSettled([
this.legacy.processOrder(order),
this.processOrderViaNewSystem(order),
]);
// Compare results (asynchronously, do not block the response)
this.compareResults(order, legacyResult, newResult).catch(
(error) => console.error("Comparison failed:", error)
);
// Return the legacy result (we trust it until the new system is verified)
if (legacyResult.status === "fulfilled") {
return legacyResult.value;
}
throw legacyResult.reason;
}
private async compareResults(
order: LegacyOrder,
legacyResult: PromiseSettledResult<LegacyResult>,
newResult: PromiseSettledResult<LegacyResult>
): Promise<void> {
const comparison: ResultComparison = {
orderId: order.order_ref,
timestamp: new Date(),
legacySuccess:
legacyResult.status === "fulfilled" &&
legacyResult.value.success,
newSystemSuccess:
newResult.status === "fulfilled" &&
newResult.value.success,
match: false,
discrepancies: [],
};
if (
legacyResult.status === "fulfilled" &&
newResult.status === "fulfilled"
) {
comparison.match =
legacyResult.value.success === newResult.value.success;
if (!comparison.match) {
comparison.discrepancies.push(
`Legacy: success=${legacyResult.value.success}, ` +
`New: success=${newResult.value.success}`
);
}
} else {
comparison.discrepancies.push(
`Legacy status: ${legacyResult.status}, ` +
`New status: ${newResult.status}`
);
}
// Log comparison for analysis
console.log("Migration comparison:", JSON.stringify(comparison));
}
private async getRoutingStrategy(
feature: string
): Promise<"legacy" | "new" | "both"> {
return this.featureFlags.getStrategy(feature);
}
private transformAddress(legacyAddress: any): any {
return {
street: legacyAddress.street_1,
city: legacyAddress.city,
state: legacyAddress.state,
postalCode: legacyAddress.zip,
country: legacyAddress.country_code,
};
}
}
interface LegacyOrder {
order_ref: string;
customer_id: string;
created_by: string;
line_items: Array<{
product_id: string;
qty: number;
price: number;
}>;
ship_to: any;
}
interface LegacyResult {
success: boolean;
order_id: string;
message: string;
}
interface FeatureFlagService {
getStrategy(feature: string): Promise<"legacy" | "new" | "both">;
}
interface ResultComparison {
orderId: string;
timestamp: Date;
legacySuccess: boolean;
newSystemSuccess: boolean;
match: boolean;
discrepancies: string[];
}The "both" routing strategy is the key to a safe migration. By running both systems in parallel and comparing results, you can verify that the new system produces correct output before routing traffic to it exclusively. This is sometimes called "dark launching" or "shadow mode."
Planning the Migration in Phases
A successful migration unfolds in phases, each with clear success criteria and rollback plans.
Phase 1: Foundation. Establish the event infrastructure --- the message broker, event store, monitoring, and basic tooling. Deploy it alongside the existing system without routing any production traffic to it. Duration: four to eight weeks.
Phase 2: Shadow mode. Begin publishing events from the existing system into the new infrastructure. Build consumers that process these events and compare their output with the legacy system. This phase validates both the infrastructure and the event design without any risk to production. Duration: four to twelve weeks.
Phase 3: Incremental migration. Begin routing selected functionality through the event-driven system. Start with low-risk, low-traffic operations and gradually expand. Use feature flags to control the rollout and enable instant rollback. Duration: three to twelve months, depending on system complexity.
Phase 4: Completion and decommissioning. Once all functionality has been migrated and the new system has proven stable, decommission the legacy components. This phase is often underestimated --- legacy systems have a way of persisting long after they should have been retired.
// Migration phase tracker
interface MigrationPlan {
phases: MigrationPhase[];
currentPhase: number;
startDate: Date;
estimatedCompletionDate: Date;
}
interface MigrationPhase {
name: string;
description: string;
startDate: Date;
estimatedDurationWeeks: number;
successCriteria: SuccessCriterion[];
rollbackPlan: string;
status: "not-started" | "in-progress" | "completed" | "blocked";
blockers: string[];
}
interface SuccessCriterion {
description: string;
metric: string;
targetValue: number;
currentValue: number;
met: boolean;
}
class MigrationTracker {
constructor(private plan: MigrationPlan) {}
getCurrentPhase(): MigrationPhase {
return this.plan.phases[this.plan.currentPhase];
}
isPhaseComplete(): boolean {
const phase = this.getCurrentPhase();
return phase.successCriteria.every((c) => c.met);
}
advancePhase(): boolean {
if (!this.isPhaseComplete()) {
return false;
}
const current = this.getCurrentPhase();
current.status = "completed";
if (this.plan.currentPhase < this.plan.phases.length - 1) {
this.plan.currentPhase++;
this.plan.phases[this.plan.currentPhase].status = "in-progress";
return true;
}
return false; // Migration complete
}
updateMetric(
criterionDescription: string,
value: number
): void {
const phase = this.getCurrentPhase();
for (const criterion of phase.successCriteria) {
if (criterion.description === criterionDescription) {
criterion.currentValue = value;
criterion.met = value >= criterion.targetValue;
break;
}
}
}
generateStatusReport(): MigrationStatusReport {
const phase = this.getCurrentPhase();
const completedPhases = this.plan.phases.filter(
(p) => p.status === "completed"
).length;
return {
overallProgress: completedPhases / this.plan.phases.length,
currentPhase: phase.name,
phaseProgress:
phase.successCriteria.filter((c) => c.met).length /
phase.successCriteria.length,
blockers: phase.blockers,
criteriaStatus: phase.successCriteria.map((c) => ({
description: c.description,
target: c.targetValue,
current: c.currentValue,
met: c.met,
})),
estimatedCompletionDate: this.plan.estimatedCompletionDate,
};
}
}
interface MigrationStatusReport {
overallProgress: number;
currentPhase: string;
phaseProgress: number;
blockers: string[];
criteriaStatus: Array<{
description: string;
target: number;
current: number;
met: boolean;
}>;
estimatedCompletionDate: Date;
}Managing Data During Migration
One of the most challenging aspects of migration is keeping data consistent between the old and new systems during the transition period. Events that occur in the legacy system must be reflected in the new system, and vice versa.
// Bidirectional event bridge for migration
class MigrationEventBridge {
private syncedEvents = new Set<string>();
constructor(
private legacyDb: DatabasePool,
private eventStore: EventStore,
private transformer: EventTransformer
) {}
// Capture changes from legacy system and publish as events
async syncFromLegacy(
changeLog: LegacyChangeLogEntry[]
): Promise<SyncResult> {
let synced = 0;
let skipped = 0;
let failed = 0;
for (const change of changeLog) {
const eventId = `legacy-${change.id}`;
if (this.syncedEvents.has(eventId)) {
skipped++;
continue;
}
try {
const event = this.transformer.fromLegacy(change);
await this.eventStore.append(
event.aggregateId,
[event],
0 // During migration, we relax version checks
);
this.syncedEvents.add(eventId);
synced++;
} catch (error) {
failed++;
console.error(
`Failed to sync legacy change ${change.id}:`,
error
);
}
}
return { synced, skipped, failed };
}
// Apply events to legacy database for backward compatibility
async syncToLegacy(events: StoredEvent[]): Promise<SyncResult> {
let synced = 0;
let skipped = 0;
let failed = 0;
for (const event of events) {
if (this.syncedEvents.has(event.eventId)) {
skipped++;
continue;
}
try {
const legacyOperations = this.transformer.toLegacy(event);
for (const operation of legacyOperations) {
await this.legacyDb.query(operation.sql, operation.params);
}
this.syncedEvents.add(event.eventId);
synced++;
} catch (error) {
failed++;
console.error(
`Failed to sync event ${event.eventId} to legacy:`,
error
);
}
}
return { synced, skipped, failed };
}
}
interface LegacyChangeLogEntry {
id: string;
table_name: string;
operation: "INSERT" | "UPDATE" | "DELETE";
row_data: Record<string, unknown>;
changed_at: Date;
}
interface EventTransformer {
fromLegacy(change: LegacyChangeLogEntry): StoredEvent;
toLegacy(event: StoredEvent): Array<{
sql: string;
params: unknown[];
}>;
}
interface SyncResult {
synced: number;
skipped: number;
failed: number;
}Organizational Change Management
Technology migrations fail when the organization does not change with the technology. Event-driven architecture requires different mental models, different debugging techniques, and different operational practices.
Training is non-negotiable. Every engineer who will work with the new system needs to understand event-driven concepts: eventual consistency, idempotency, event ordering, and distributed tracing. This is not something that can be picked up on the job --- it requires dedicated learning time.
Incident response processes must evolve. In a monolithic system, an incident is usually localized to one codebase and one database. In an event-driven system, an incident may span multiple services, message brokers, and event stores. Runbooks must be updated, and on-call engineers must be trained on the new topology.
Team boundaries may need to shift. Event-driven architecture works best when team boundaries align with event boundaries. If two teams need to coordinate changes to the same event, you have a coupling problem that will slow you down. Consider reorganizing teams around event domains.
Communication overhead increases during migration. When two systems coexist, every change must be evaluated for its impact on both systems. Establish clear communication channels and decision frameworks for migration-related questions.
Practical Tips for a Successful Migration
Do not migrate everything. Some parts of your system are perfectly fine as request-response. Event-driven architecture shines for asynchronous workflows, data synchronization, and real-time processing. Do not force it where it does not fit.
Invest heavily in observability from day one. Distributed tracing, event flow visualization, and consumer lag monitoring are not luxuries --- they are necessities. Without them, you will be flying blind.
Maintain the ability to rollback at every step. The strangler fig pattern's greatest strength is that the old system remains available throughout the migration. Never remove the old system until the new system has proven itself over an extended period.
Celebrate incremental wins. Migrations are long, and morale can flag. When the first feature is successfully running on the new event-driven system, recognize it. When the first incident is resolved faster because of event replay, share the story. These moments build confidence and momentum.
Budget for the unexpected. Migrations always take longer than planned. Build a 30 to 50 percent buffer into your timeline and budget. The alternative --- rushing the migration to meet an aggressive deadline --- almost always leads to quality compromises that haunt the organization for years.
Conclusion
Migrating to event-driven architecture is a journey, not a destination. It requires careful assessment, phased execution, robust tooling, and organizational commitment. The strangler fig pattern provides a safe migration path that lets you validate the new system incrementally while the old system continues to serve production traffic.
The investment is significant, but so are the returns. Organizations that successfully complete this migration gain scalability, resilience, and agility that are difficult to achieve with traditional architectures. With platforms like Starburst providing the event processing infrastructure, teams can focus on the migration strategy and domain modeling that determine success, rather than building low-level plumbing from scratch.
Related Articles
Monitoring Event-Driven Systems at Scale
A practical guide to building comprehensive monitoring and observability for event-driven systems, covering metrics, distributed tracing, alerting strategies, and operational dashboards for maintaining healthy event processing pipelines.
Real-Time Data Processing: Business Impact and ROI
An exploration of the business value of real-time data processing, covering measurable ROI, competitive advantages, and practical frameworks for justifying investment in event-driven infrastructure.
Event Replay for Debugging and Recovery
A comprehensive guide to using event replay as a powerful debugging and recovery tool in event-driven systems, with TypeScript implementations for selective replay, time-travel debugging, and disaster recovery strategies.