<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ legacy code - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ legacy code - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 07 Sep 2026 23:54:03 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/legacy-code/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Refactor a Legacy Application Before Migrating It ]]>
                </title>
                <description>
                    <![CDATA[ The moment a team decides to migrate a legacy application, there's usually pressure to start moving code. Move the database, the API, or the UI. Move the application to a new framework, runtime, cloud ]]>
                </description>
                <link>https://www.freecodecamp.org/news/refactor-legacy-application-before-migration/</link>
                <guid isPermaLink="false">6a9f3503813f6309fdfcd6e3</guid>
                
                    <category>
                        <![CDATA[ legacy code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ refactoring ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hugo Teijiz ]]>
                </dc:creator>
                <pubDate>Mon, 07 Sep 2026 22:04:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d9d0b4b3-b9f4-4f86-98ba-ec079ac68284.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The moment a team decides to migrate a legacy application, there's usually pressure to start moving code.</p>
<p>Move the database, the API, or the UI. Move the application to a new framework, runtime, cloud provider, or architecture.</p>
<p>That sounds reasonable, but there's a problem.</p>
<p>If the current system mixes business rules, persistence, infrastructure, external integrations, and orchestration inside the same modules, migration becomes much harder than it needs to be.</p>
<p>You're not just moving software. You're trying to move several responsibilities that have become entangled over years of development.</p>
<p>This is why I often prefer to refactor <strong>before</strong> migrating. Not to make the legacy system beautiful or redesign everything. And definitely not to turn the preparation phase into another rewrite.</p>
<p>The goal is much narrower: change the structure enough that important behavior can move independently.</p>
<p>In the <a href="https://www.freecodecamp.org/news/modernize-legacy-applications-with-ai/">previous</a> <a href="https://www.freecodecamp.org/news/understand-a-legacy-codebase-with-ai/">steps</a> of this workflow, we first tried to understand the codebase and then used characterization tests to protect the behavior we were about to change.</p>
<p>Now you'll learn how you can start changing the structure.</p>
<p>In this tutorial, I'll show you how to prepare a legacy application for migration by:</p>
<ul>
<li><p>choosing a migration boundary,</p>
</li>
<li><p>separating business rules from infrastructure,</p>
</li>
<li><p>introducing seams,</p>
</li>
<li><p>isolating side effects,</p>
</li>
<li><p>creating adapters around external systems,</p>
</li>
<li><p>reducing dependency direction problems,</p>
</li>
<li><p>extracting cohesive application behavior,</p>
</li>
<li><p>using characterization tests throughout the refactor,</p>
</li>
<li><p>using AI without letting it redesign the system blindly,</p>
</li>
<li><p>and knowing when the application is ready to start migrating.</p>
</li>
</ul>
<p>The examples use TypeScript, but the process applies to most languages and architectures.</p>
<p>The objective is not:</p>
<pre><code class="language-text">legacy application
↓
perfect architecture
</code></pre>
<p>Instead, it's:</p>
<pre><code class="language-text">legacy application
↓
migration-friendly structure
↓
incremental migration
</code></pre>
<p>That difference can save a lot of unnecessary work.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with:</p>
<ul>
<li><p>reading an existing codebase</p>
</li>
<li><p>TypeScript or a similar language</p>
</li>
<li><p>unit and integration testing</p>
</li>
<li><p>dependency injection</p>
</li>
<li><p>interfaces and adapters</p>
</li>
<li><p>basic software architecture</p>
</li>
<li><p>incremental refactoring</p>
</li>
</ul>
<p>You should also have some behavioral protection around the capability you plan to modify.</p>
<p>That may include:</p>
<ul>
<li><p>characterization tests</p>
</li>
<li><p>integration tests</p>
</li>
<li><p>contract tests</p>
</li>
</ul>
<p>or another reliable way to verify existing behavior.</p>
<p>Refactoring without that protection is possible. But it's also much harder to distinguish a structural improvement from an accidental behavioral change.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-migration-problems-often-start-before-the-migration">Why Migration Problems Often Start Before the Migration</a></p>
</li>
<li><p><a href="#heading-choose-a-migration-boundary-before-refactoring">Choose a Migration Boundary Before Refactoring</a></p>
</li>
<li><p><a href="#heading-dont-refactor-the-entire-application">Don't Refactor the Entire Application</a></p>
</li>
<li><p><a href="#heading-separate-business-rules-from-infrastructure">Separate Business Rules from Infrastructure</a></p>
</li>
<li><p><a href="#heading-introduce-seams-around-hard-dependencies">Introduce Seams Around Hard Dependencies</a></p>
</li>
<li><p><a href="#heading-isolate-side-effects-from-decision-logic">Isolate Side Effects from Decision Logic</a></p>
</li>
<li><p><a href="#heading-put-external-systems-behind-adapters">Put External Systems Behind Adapters</a></p>
</li>
<li><p><a href="#heading-improve-dependency-direction-without-rebuilding-everything">Improve Dependency Direction Without Rebuilding Everything</a></p>
</li>
<li><p><a href="#heading-extract-a-cohesive-application-boundary">Extract a Cohesive Application Boundary</a></p>
</li>
<li><p><a href="#heading-keep-behavioral-tests-running-during-the-refactor">Keep Behavioral Tests Running During the Refactor</a></p>
</li>
<li><p><a href="#heading-how-to-use-ai-during-structural-refactoring">How to Use AI During Structural Refactoring</a></p>
</li>
<li><p><a href="#heading-dont-ask-the-ai-to-design-the-target-architecture-too-early">Don't Ask AI to Design the Target Architecture Too Early</a></p>
</li>
<li><p><a href="#heading-how-to-know-when-a-capability-is-ready-to-migrate">How to Know When a Capability Is Ready to Migrate</a></p>
</li>
<li><p><a href="#heading-a-practical-pre-migration-refactoring-workflow">A Practical Pre-Migration Refactoring Workflow</a></p>
</li>
<li><p><a href="#heading-what-not-to-refactor-before-migration">What Not to Refactor Before Migration</a></p>
</li>
<li><p><a href="#heading-refactoring-is-preparation-not-the-migration">Refactoring Is Preparation, Not the Migration</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-migration-problems-often-start-before-the-migration">Why Migration Problems Often Start Before the Migration</h2>
<p>Imagine you need to migrate an order-processing application.</p>
<p>You inspect the main service and find something like this:</p>
<pre><code class="language-typescript">async function processOrder(orderId: string) {
  const connection = await mysql.getConnection();

  const [rows] = await connection.query(
    "SELECT * FROM orders WHERE id = ?",
    [orderId]
  );

  const order = rows[0];

  if (!order) {
    throw new Error("Order not found");
  }

  if (order.customer_type === "PREMIUM") {
    order.total = order.total * 0.9;
  }

  if (
    order.country === "AR" &amp;&amp;
    order.payment_method === "TRANSFER"
  ) {
    order.total -= 500;
  }

  await connection.query(
    "UPDATE orders SET total = ?, status = ? WHERE id = ?",
    [order.total, "PROCESSED", order.id]
  );

  await paymentProvider.createPayment({
    orderId: order.id,
    amount: order.total,
  });

  await eventBus.publish("order.processed", {
    id: order.id,
    total: order.total,
  });

  await emailClient.send({
    to: order.customer_email,
    template: "order-processed",
  });

  return order;
}
</code></pre>
<p>Suppose the migration goal is:</p>
<pre><code class="language-text">MySQL       → PostgreSQL
Old runtime → New runtime
Legacy API  → New service
</code></pre>
<p>The obvious temptation is to begin translating this function into the target stack.</p>
<p>But what exactly are you migrating?</p>
<p>The function contains:</p>
<pre><code class="language-text">database access
business rules
state transition
payment integration
event publication
email delivery
application orchestration
</code></pre>
<p>Changing the database now risks affecting pricing, while changing the payment client risks affecting persistence. And moving the function into another service means moving all of its dependencies at once.</p>
<p>The migration difficulty is partly caused by the current structure. So before migrating, you'll want to create enough separation that those concerns can move independently.</p>
<h2 id="heading-choose-a-migration-boundary-before-refactoring">Choose a Migration Boundary Before Refactoring</h2>
<p>Don't begin with:</p>
<blockquote>
<p>Let's clean up the application.</p>
</blockquote>
<p>Begin with:</p>
<blockquote>
<p>What do we want to migrate first?</p>
</blockquote>
<p>Suppose you decide the first capability will be Process Order. That gives your refactor a boundary.</p>
<p>Now you can map:</p>
<pre><code class="language-text">Input:
orderId

Business behavior:
load order
calculate adjustments
mark as processed

Side effects:
persist order
create payment
publish event
send email

Output:
processed order
</code></pre>
<p>This is much more useful than deciding to refactor:</p>
<pre><code class="language-text">src/services/
</code></pre>
<p>because a folder isn't necessarily a business boundary.</p>
<p>Migration works better when you can reason about capabilities.</p>
<p>For example:</p>
<pre><code class="language-text">Process Order
Cancel Order
Generate Invoice
Register Customer
Renew Subscription
</code></pre>
<p>Each can potentially become a migration unit.</p>
<h2 id="heading-dont-refactor-the-entire-application">Don't Refactor the Entire Application</h2>
<p>Once you start identifying architectural problems, it becomes tempting to fix all of them.</p>
<p>You may notice:</p>
<pre><code class="language-text">circular dependencies
duplicated repositories
global configuration
large services
static helpers
direct database access
inconsistent error handling
mixed domain models
</code></pre>
<p>All of those may deserve attention, but the migration doesn't require all technical debt to disappear.</p>
<p>Suppose your target is the order-processing capability. A useful rule is to refactor only what prevents this capability from moving safely.</p>
<p>For example:</p>
<pre><code class="language-text">Problem:
Order processing calls MySQL directly.

Relevant?
Yes.

Problem:
The reporting module uses inconsistent date formatting.

Relevant?
Probably not.

Problem:
Order processing calls the payment SDK directly.

Relevant?
Yes.

Problem:
The admin UI contains duplicated CSS.

Relevant?
No.
</code></pre>
<p>This prevents preparation from becoming an open-ended cleanup project.</p>
<p>Legacy modernization needs scope discipline.</p>
<h2 id="heading-separate-business-rules-from-infrastructure">Separate Business Rules from Infrastructure</h2>
<p>The most valuable structural change is often separating business behavior from technology-specific details.</p>
<p>Take this code:</p>
<pre><code class="language-typescript">async function processOrder(orderId: string) {
  const order = await mysqlOrders.find(orderId);

  if (order.customerType === "PREMIUM") {
    order.total *= 0.9;
  }

  if (
    order.country === "AR" &amp;&amp;
    order.paymentMethod === "TRANSFER"
  ) {
    order.total -= 500;
  }

  await mysqlOrders.update(order);

  await stripe.createPayment({
    orderId: order.id,
    amount: order.total,
  });
}
</code></pre>
<p>The pricing behavior itself doesn't need MySQL or Stripe.</p>
<p>You can extract it:</p>
<pre><code class="language-typescript">type Order = {
  id: string;
  total: number;
  customerType: "STANDARD" | "PREMIUM";
  country: string;
  paymentMethod: "CARD" | "TRANSFER";
};

function calculateOrderTotal(order: Order): number {
  let total = order.total;

  if (order.customerType === "PREMIUM") {
    total *= 0.9;
  }

  if (
    order.country === "AR" &amp;&amp;
    order.paymentMethod === "TRANSFER"
  ) {
    total -= 500;
  }

  return Math.max(total, 0);
}
</code></pre>
<p>Now:</p>
<pre><code class="language-text">pricing behavior
</code></pre>
<p>is no longer coupled to:</p>
<pre><code class="language-text">MySQL
Stripe
</code></pre>
<p>This doesn't require a complete domain-driven redesign. It's simply a useful separation.</p>
<p>The next migration step can replace infrastructure while leaving this behavior unchanged.</p>
<h2 id="heading-introduce-seams-around-hard-dependencies">Introduce Seams Around Hard Dependencies</h2>
<p>Legacy code often contains dependencies that can't easily be replaced in tests or migration code.</p>
<p>For example:</p>
<pre><code class="language-typescript">class OrderService {
  async process(orderId: string) {
    const client = new LegacyDatabaseClient();

    const order = await client.findOrder(orderId);

    // ...
  }
}
</code></pre>
<p>The database dependency is created inside the method.</p>
<p>That makes substitution difficult.</p>
<p>A small preparatory refactor can introduce a seam:</p>
<pre><code class="language-typescript">interface OrderRepository {
  findById(id: string): Promise&lt;Order | null&gt;;
  save(order: Order): Promise&lt;void&gt;;
}
</code></pre>
<p>Then:</p>
<pre><code class="language-typescript">class OrderService {
  constructor(
    private readonly orders: OrderRepository
  ) {}

  async process(orderId: string) {
    const order = await this.orders.findById(orderId);

    if (!order) {
      throw new Error("Order not found");
    }

    // existing behavior
  }
}
</code></pre>
<p>Now the existing MySQL implementation can satisfy the interface:</p>
<pre><code class="language-typescript">class MySqlOrderRepository implements OrderRepository {
  async findById(id: string) {
    // existing MySQL behavior
  }

  async save(order: Order) {
    // existing MySQL behavior
  }
}
</code></pre>
<p>Later, the migration can introduce:</p>
<pre><code class="language-typescript">class PostgresOrderRepository implements OrderRepository {
  // new implementation
}
</code></pre>
<p>Notice what we didn't change: we didn't change the business behavior. We changed the <strong>replaceability of a dependency</strong>.</p>
<p>That's exactly the kind of refactoring that helps migration.</p>
<h2 id="heading-isolate-side-effects-from-decision-logic">Isolate Side Effects from Decision Logic</h2>
<p>Another useful separation is between:</p>
<pre><code class="language-text">deciding
</code></pre>
<p>and:</p>
<pre><code class="language-text">performing
</code></pre>
<p>Suppose cancellation currently looks like this:</p>
<pre><code class="language-typescript">async function cancelOrder(order: Order) {
  if (order.status === "SHIPPED") {
    throw new Error("Cannot cancel shipped order");
  }

  order.status = "CANCELLED";

  await orders.save(order);
  await inventory.release(order.id);
  await payment.refund(order.id);
  await audit.log("ORDER_CANCELLED", order.id);
}
</code></pre>
<p>There are two different responsibilities here.</p>
<p>The business decision:</p>
<pre><code class="language-text">Can this order be cancelled?
What should its new state be?
</code></pre>
<p>And the operational effects:</p>
<pre><code class="language-text">persist
release inventory
refund
audit
</code></pre>
<p>You could first extract the decision:</p>
<pre><code class="language-typescript">function cancelOrderState(order: Order): Order {
  if (order.status === "SHIPPED") {
    throw new Error("Cannot cancel shipped order");
  }

  return {
    ...order,
    status: "CANCELLED",
  };
}
</code></pre>
<p>Then orchestration remains:</p>
<pre><code class="language-typescript">async function cancelOrder(order: Order) {
  const cancelled = cancelOrderState(order);

  await orders.save(cancelled);
  await inventory.release(cancelled.id);
  await payment.refund(cancelled.id);
  await audit.log("ORDER_CANCELLED", cancelled.id);

  return cancelled;
}
</code></pre>
<p>The behavior is still the same, but now the state transition can be tested and migrated independently.</p>
<p>That matters if the target architecture changes how side effects are executed.</p>
<p>For example, the future version might use:</p>
<pre><code class="language-text">transactional outbox
event-driven workflow
queue
workflow engine
</code></pre>
<p>You don't need to introduce those mechanisms yet. You only need to stop the current decision logic from depending directly on them.</p>
<h2 id="heading-put-external-systems-behind-adapters">Put External Systems Behind Adapters</h2>
<p>External SDKs often leak deeply into legacy code.</p>
<p>For example:</p>
<pre><code class="language-typescript">const result = await stripe.paymentIntents.create({
  amount: order.total,
  currency: "usd",
  metadata: {
    orderId: order.id,
  },
});
</code></pre>
<p>If dozens of application modules depend directly on the Stripe SDK, replacing or relocating payment processing becomes difficult.</p>
<p>Create an application-level boundary instead:</p>
<pre><code class="language-typescript">type PaymentRequest = {
  orderId: string;
  amount: number;
};

type PaymentResult = {
  paymentId: string;
};

interface PaymentGateway {
  charge(
    request: PaymentRequest
  ): Promise&lt;PaymentResult&gt;;
}
</code></pre>
<p>The Stripe adapter contains the provider-specific details:</p>
<pre><code class="language-typescript">class StripePaymentGateway implements PaymentGateway {
  async charge(
    request: PaymentRequest
  ): Promise&lt;PaymentResult&gt; {
    const result =
      await stripe.paymentIntents.create({
        amount: request.amount,
        currency: "usd",
        metadata: {
          orderId: request.orderId,
        },
      });

    return {
      paymentId: result.id,
    };
  }
}
</code></pre>
<p>The application now knows about:</p>
<pre><code class="language-text">PaymentGateway
</code></pre>
<p>instead of:</p>
<pre><code class="language-text">Stripe SDK
</code></pre>
<p>This is useful for migration because provider-specific code is localized.</p>
<p>The same pattern works for:</p>
<pre><code class="language-text">email providers
message brokers
cloud storage
ERP integrations
CRM APIs
identity providers
search engines
</code></pre>
<p>The adapter isn't valuable because interfaces are fashionable. It's valuable because it creates a boundary you can move.</p>
<h2 id="heading-improve-dependency-direction-without-rebuilding-everything">Improve Dependency Direction Without Rebuilding Everything</h2>
<p>Legacy systems often have dependency relationships such as:</p>
<pre><code class="language-text">business logic
    ↓
database SDK
    ↓
framework utilities
</code></pre>
<p>That makes infrastructure difficult to replace.</p>
<p>You don't necessarily need to implement full Clean Architecture. You only need to improve dependency direction where migration requires it.</p>
<p>For example:</p>
<p>Before:</p>
<pre><code class="language-text">OrderService
   ↓
MySQL
</code></pre>
<p>After:</p>
<pre><code class="language-text">OrderService
   ↓
OrderRepository
   ↑
MySqlOrderRepository
</code></pre>
<p>The application depends on an abstraction. The infrastructure implements it.</p>
<p>The same can happen with payments:</p>
<pre><code class="language-text">OrderService
   ↓
PaymentGateway
   ↑
StripePaymentGateway
</code></pre>
<p>and messaging:</p>
<pre><code class="language-text">OrderService
   ↓
OrderEvents
   ↑
KafkaOrderEvents
</code></pre>
<p>Now replacing infrastructure no longer requires rewriting the application service. That's the important outcome.</p>
<h2 id="heading-extract-a-cohesive-application-boundary">Extract a Cohesive Application Boundary</h2>
<p>After several small refactors, the capability may start to look like this:</p>
<pre><code class="language-typescript">interface OrderRepository {
  findById(id: string): Promise&lt;Order | null&gt;;
  save(order: Order): Promise&lt;void&gt;;
}

interface PaymentGateway {
  charge(request: {
    orderId: string;
    amount: number;
  }): Promise&lt;void&gt;;
}

interface OrderEvents {
  processed(order: Order): Promise&lt;void&gt;;
}

class ProcessOrder {
  constructor(
    private readonly orders: OrderRepository,
    private readonly payments: PaymentGateway,
    private readonly events: OrderEvents
  ) {}

  async execute(orderId: string) {
    const order = await this.orders.findById(orderId);

    if (!order) {
      throw new Error("Order not found");
    }

    const total = calculateOrderTotal(order);

    const processed: Order = {
      ...order,
      total,
      status: "PROCESSED",
    };

    await this.orders.save(processed);

    await this.payments.charge({
      orderId: processed.id,
      amount: processed.total,
    });

    await this.events.processed(processed);

    return processed;
  }
}
</code></pre>
<p>This isn't necessarily the final architecture. That's important.</p>
<p>We aren't claiming:</p>
<blockquote>
<p>This is how the application should look forever.</p>
</blockquote>
<p>We're just saying:</p>
<blockquote>
<p>This capability now has boundaries that make migration easier.</p>
</blockquote>
<p>The infrastructure can change independently.</p>
<p>The business rules are testable. The orchestration is visible. And the external contracts are explicit.</p>
<p>That's enough to start considering migration.</p>
<h2 id="heading-keep-behavioral-tests-running-during-the-refactor">Keep Behavioral Tests Running During the Refactor</h2>
<p>This is where the characterization tests from the previous step become useful.</p>
<p>Suppose the original behavior was protected with:</p>
<pre><code class="language-typescript">it("preserves premium order processing behavior", async () =&gt; {
  const result = await processOrder("order-1");

  expect(result.total).toBe(9000);
  expect(result.status).toBe("PROCESSED");

  expect(payment.charge).toHaveBeenCalledWith({
    orderId: "order-1",
    amount: 9000,
  });

  expect(events.processed).toHaveBeenCalled();
});
</code></pre>
<p>Now you can change:</p>
<pre><code class="language-text">direct database access
</code></pre>
<p>into:</p>
<pre><code class="language-text">repository
</code></pre>
<p>and run the test.</p>
<p>Then change:</p>
<pre><code class="language-text">direct payment SDK
</code></pre>
<p>into:</p>
<pre><code class="language-text">payment adapter
</code></pre>
<p>and run the test.</p>
<p>Then extract:</p>
<pre><code class="language-text">pricing logic
</code></pre>
<p>and run the test.</p>
<p>The rhythm becomes:</p>
<pre><code class="language-text">small structural change
↓
test
↓
small structural change
↓
test
↓
small structural change
↓
test
</code></pre>
<p>This matters because structural refactoring is much easier to reason about when behavioral changes aren't happening at the same time.</p>
<p>If a test fails after one small change, the possible cause is narrow.</p>
<p>If a test fails after a two-week rewrite, the possible cause is almost everything.</p>
<h2 id="heading-how-to-use-ai-during-structural-refactoring">How to Use AI During Structural Refactoring</h2>
<p>AI can help a lot during this phase.</p>
<p>But the useful prompts are different from:</p>
<pre><code class="language-text">Refactor this application using Clean Architecture.
</code></pre>
<p>Instead, give the model a constrained transformation.</p>
<p>For example:</p>
<pre><code class="language-text">This service currently accesses MySQL directly.

I want to introduce an OrderRepository seam without
changing observable behavior.

Tasks:

1. identify every database operation used by this service,
2. propose the smallest repository interface needed,
3. move existing database calls behind an adapter,
4. preserve return values, errors, and call order where relevant,
5. do not change business rules,
6. do not introduce additional abstractions.

Explain every structural change before generating code.
</code></pre>
<p>That gives AI a much narrower job.</p>
<p>Another useful request is:</p>
<pre><code class="language-text">Compare the implementation before and after this refactor.

Identify any observable behavior that may have changed.

Check specifically:

- exceptions,
- return values,
- side effects,
- ordering of side effects,
- null handling,
- transaction boundaries,
- retry behavior.

Do not assume equivalence because the code looks similar.
</code></pre>
<p>This is where AI can be valuable as a second reviewer.</p>
<p>It can inspect differences faster than you can manually scan large changes. But the tests still provide stronger evidence.</p>
<h2 id="heading-dont-ask-ai-to-design-the-target-architecture-too-early">Don't Ask AI to Design the Target Architecture Too Early</h2>
<p>AI is very good at recognizing common architecture patterns. But that can also be dangerous.</p>
<p>Give a model a large legacy service and ask:</p>
<pre><code class="language-text">How should this be modernized?
</code></pre>
<p>and you may receive:</p>
<pre><code class="language-text">microservices
event-driven architecture
CQRS
repository pattern
domain events
message broker
API gateway
distributed cache
</code></pre>
<p>All of those are legitimate technologies or patterns, but none of them are automatically justified.</p>
<p>Before choosing a target architecture, you need constraints.</p>
<p>For example:</p>
<pre><code class="language-text">deployment frequency
team size
transactional requirements
latency
failure tolerance
data ownership
integration boundaries
operational maturity
traffic
cost
regulatory requirements
</code></pre>
<p>A monolith with good boundaries may be a better target than microservices. A synchronous workflow may be better than event-driven processing. And a database migration may not require changing the domain model.</p>
<p>Architecture should follow constraints, not pattern recognition.</p>
<p>Use AI to evaluate options. Don't let the presence of a familiar pattern become the reason to adopt it.</p>
<h2 id="heading-how-to-know-when-a-capability-is-ready-to-migrate">How to Know When a Capability Is Ready to Migrate</h2>
<p>At some point, you have to stop refactoring. And that decision matters.</p>
<p>You don't need perfect code. A capability is usually much closer to migration-ready when you can answer these questions clearly.</p>
<h3 id="heading-can-i-describe-its-inputs">Can I Describe its Inputs?</h3>
<p>For example:</p>
<pre><code class="language-text">orderId
customer
request payload
event
</code></pre>
<h3 id="heading-can-i-describe-its-outputs">Can I Describe its Outputs?</h3>
<p>For example:</p>
<pre><code class="language-text">processed order
HTTP response
event
database change
</code></pre>
<h3 id="heading-are-its-important-business-rules-visible">Are its Important Business Rules Visible?</h3>
<p>They don't have to be perfect, but you should know where they live.</p>
<h3 id="heading-are-external-dependencies-explicit">Are External Dependencies Explicit?</h3>
<p>For example:</p>
<pre><code class="language-text">OrderRepository
PaymentGateway
OrderEvents
EmailSender
</code></pre>
<h3 id="heading-can-infrastructure-be-substituted">Can Infrastructure Be Substituted?</h3>
<p>If replacing MySQL requires changing pricing logic, the boundary is probably not ready.</p>
<h3 id="heading-are-important-behaviors-protected">Are Important Behaviors Protected?</h3>
<p>You should have enough tests to detect accidental changes.</p>
<h3 id="heading-do-you-know-the-side-effects">Do You Know the Side Effects?</h3>
<p>For example:</p>
<pre><code class="language-text">persist order
create payment
publish event
send email
</code></pre>
<h3 id="heading-are-major-unknowns-documented">Are Major Unknowns Documented?</h3>
<p>Some uncertainty may remain. But it shouldn't be invisible.</p>
<p>If you can answer those questions, you probably have enough structure to begin migrating that capability.</p>
<h2 id="heading-a-practical-pre-migration-refactoring-workflow">A Practical Pre-Migration Refactoring Workflow</h2>
<p>Here's the workflow I would use.</p>
<h3 id="heading-1-choose-one-capability">1. Choose One Capability</h3>
<p>Don't refactor the whole application.</p>
<p>Pick:</p>
<pre><code class="language-text">Process Order
Generate Invoice
Renew Subscription
</code></pre>
<h3 id="heading-2-confirm-behavioral-protection">2. Confirm Behavioral Protection</h3>
<p>Before structural changes, make sure critical behavior has tests.</p>
<p>Capture:</p>
<pre><code class="language-text">outputs
state transitions
side effects
errors
contracts
</code></pre>
<h3 id="heading-3-identify-migration-blockers">3. Identify Migration Blockers</h3>
<p>Look for coupling such as:</p>
<pre><code class="language-text">direct database access
provider SDKs
global state
framework-specific objects
static dependencies
shared mutable state
</code></pre>
<h3 id="heading-4-extract-pure-business-logic-where-possible">4. Extract Pure Business Logic Where Possible</h3>
<p>Move calculations and decisions away from infrastructure.</p>
<p>For example:</p>
<pre><code class="language-text">calculate price
validate transition
choose status
calculate commission
</code></pre>
<h3 id="heading-5-introduce-seams">5. Introduce Seams</h3>
<p>Create minimal boundaries around:</p>
<pre><code class="language-text">database
payments
events
email
storage
external APIs
</code></pre>
<p>Don't create abstractions without a migration reason.</p>
<h3 id="heading-6-localize-infrastructure">6. Localize Infrastructure</h3>
<p>Move technology-specific behavior into adapters.</p>
<p>For example:</p>
<pre><code class="language-text">MySqlOrderRepository
StripePaymentGateway
KafkaOrderEvents
SendGridEmailSender
</code></pre>
<h3 id="heading-7-make-orchestration-visible">7. Make Orchestration Visible</h3>
<p>Aim for a capability where the sequence is understandable:</p>
<pre><code class="language-text">load
↓
decide
↓
persist
↓
perform side effects
↓
return
</code></pre>
<h3 id="heading-8-run-behavioral-tests-after-every-step">8. Run Behavioral Tests After Every Step</h3>
<p>Don't batch ten refactors together. Keep the changes small.</p>
<h3 id="heading-9-compare-before-and-after">9. Compare Before and After</h3>
<p>Check:</p>
<pre><code class="language-text">inputs
outputs
errors
side effects
data shapes
ordering
transactions
</code></pre>
<h3 id="heading-10-stop-when-migration-becomes-possible">10. Stop When Migration Becomes Possible</h3>
<p>Don't continue refactoring because the code could still be cleaner. It always could.</p>
<p>The objective is migration readiness.</p>
<h2 id="heading-what-not-to-refactor-before-migration">What Not to Refactor Before Migration</h2>
<p>There are several things I would usually avoid changing during this phase unless they directly block migration.</p>
<h3 id="heading-naming-everywhere">Naming Everywhere</h3>
<p>You may dislike hundreds of old names. But renaming everything produces large diffs with little migration value.</p>
<h3 id="heading-formatting-the-entire-repository">Formatting the Entire Repository</h3>
<p>Same problem. Noise makes behavioral changes harder to review.</p>
<h3 id="heading-replacing-every-pattern">Replacing Every Pattern</h3>
<p>A legacy system may contain:</p>
<pre><code class="language-text">singletons
service locators
static utilities
large classes
</code></pre>
<p>Some may remain temporarily. Fix the ones crossing your migration boundary.</p>
<h3 id="heading-rewriting-stable-algorithms">Rewriting Stable Algorithms</h3>
<p>If an old calculation is ugly but protected and isolated, it may be safer to move it first and improve it later.</p>
<h3 id="heading-fixing-every-discovered-bug">Fixing Every Discovered Bug</h3>
<p>This one is especially important.</p>
<p>If you discover a bug while preparing a migration, record it. Then decide whether fixing it belongs in the same change.</p>
<p>Mixing:</p>
<pre><code class="language-text">structural refactor
+
behavioral correction
+
platform migration
</code></pre>
<p>makes failures much harder to understand.</p>
<p>Sometimes the right answer is:</p>
<pre><code class="language-text">preserve bug
migrate
fix bug intentionally afterward
</code></pre>
<p>That sounds uncomfortable. But accidental behavior changes during migration can be much more dangerous.</p>
<h2 id="heading-refactoring-is-preparation-not-the-migration">Refactoring Is Preparation, Not the Migration</h2>
<p>It's easy for pre-migration refactoring to become an endless architecture project.</p>
<p>You start with:</p>
<blockquote>
<p>We need to isolate the database.</p>
</blockquote>
<p>Then:</p>
<blockquote>
<p>We should redesign the domain model.</p>
</blockquote>
<p>Then:</p>
<blockquote>
<p>Maybe we should introduce events.</p>
</blockquote>
<p>Then:</p>
<blockquote>
<p>If we're doing that, maybe this should become a microservice.</p>
</blockquote>
<p>Months later, nothing has migrated. The refactor has become the project.</p>
<p>That's a failure mode, too. The objective should remain concrete.</p>
<p>Before:</p>
<pre><code class="language-text">ProcessOrder
├── MySQL
├── pricing rules
├── Stripe
├── Kafka
├── email
└── framework internals
</code></pre>
<p>After:</p>
<pre><code class="language-text">ProcessOrder
├── OrderRepository
├── pricing rules
├── PaymentGateway
├── OrderEvents
└── EmailSender
</code></pre>
<p>That may be enough.</p>
<p>Now you have choices.</p>
<p>You can migrate:</p>
<pre><code class="language-text">MySQL → PostgreSQL
</code></pre>
<p>without redesigning pricing.</p>
<p>You can replace:</p>
<pre><code class="language-text">Stripe adapter
</code></pre>
<p>without changing order orchestration.</p>
<p>You can move:</p>
<pre><code class="language-text">ProcessOrder
</code></pre>
<p>into another runtime while preserving its contracts.</p>
<p>The refactor created options. That's the value.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Legacy migrations become risky when several types of change happen at once.</p>
<p>You change:</p>
<pre><code class="language-text">behavior
architecture
infrastructure
runtime
data
deployment
</code></pre>
<p>and then try to understand which change caused the failure.</p>
<p>A safer approach is to reduce that uncertainty before migration begins.</p>
<p>First understand the capability, then characterize its behavior, and then change its structure without intentionally changing what it does.</p>
<p>Create boundaries around dependencies. Separate business decisions from infrastructure. Localize external systems. Keep side effects visible. Run behavioral tests after every structural change. And stop refactoring when the capability becomes movable.</p>
<p>The sequence becomes:</p>
<pre><code class="language-text">Understand
↓
Characterize
↓
Refactor
↓
Migrate
</code></pre>
<p>AI can make the refactoring phase dramatically faster.</p>
<p>It can identify dependencies, extract interfaces, move calls behind adapters, compare implementations, and review large diffs.</p>
<p>But faster refactoring doesn't remove the need for architectural judgment. It makes that judgment more important.</p>
<p>Because the goal isn't to produce the cleanest version of the legacy system. The goal is to create <strong>just enough structure to move it safely</strong>.</p>
<p>And once you can change the infrastructure without changing the behavior, migration stops looking like a rewrite.</p>
<p>It starts looking like a sequence of controlled changes.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Characterization Tests Before Refactoring Legacy Code ]]>
                </title>
                <description>
                    <![CDATA[ The first thing many engineers want to do when they inherit legacy code is improve it. You find a function that's difficult to understand. Or you see duplicated logic, deeply nested conditions, databa ]]>
                </description>
                <link>https://www.freecodecamp.org/news/characterization-tests-before-refactoring-legacy-code/</link>
                <guid isPermaLink="false">6a958d8401db3f18f07d0b54</guid>
                
                    <category>
                        <![CDATA[ Software Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ refactoring ]]>
                    </category>
                
                    <category>
                        <![CDATA[ legacy code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hugo Teijiz ]]>
                </dc:creator>
                <pubDate>Mon, 31 Aug 2026 14:19:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/50ec1fe6-8e1f-4c42-a8ad-fd52ea0d089d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first thing many engineers want to do when they inherit legacy code is improve it.</p>
<p>You find a function that's difficult to understand. Or you see duplicated logic, deeply nested conditions, database calls mixed with business rules, and dependencies that make testing almost impossible.</p>
<p>You know the code could be better, so you start cleaning it up.</p>
<p>Then something breaks. Not because the new implementation is obviously wrong. It breaks because the old implementation was doing something nobody knew it was doing.</p>
<p>That's one of the most common risks in legacy modernization.</p>
<p>Before changing code, you need a way to answer a simple question:</p>
<blockquote>
<p>Did I preserve the behavior that already mattered?</p>
</blockquote>
<p>That is where characterization tests become useful.</p>
<p>A characterization test doesn't begin by asking what the software <strong>should</strong> do. It begins by documenting what the software <strong>does today</strong>.</p>
<p>That distinction matters.</p>
<p>In a greenfield application, tests usually express intended behavior. But in a legacy application, you may first need tests that capture existing behavior so you can change the implementation without accidentally changing its observable results.</p>
<p>In this tutorial, I'll show you how to use characterization tests as a safety net before refactoring legacy code.</p>
<p>We'll look at how to:</p>
<ul>
<li><p>identify behavior worth protecting,</p>
</li>
<li><p>choose useful test boundaries,</p>
</li>
<li><p>capture current outputs,</p>
</li>
<li><p>deal with side effects,</p>
</li>
<li><p>handle databases and external systems,</p>
</li>
<li><p>use AI to accelerate test discovery,</p>
</li>
<li><p>avoid freezing implementation details,</p>
</li>
<li><p>decide what not to characterize,</p>
</li>
<li><p>and turn characterization tests into a foundation for safer refactoring.</p>
</li>
</ul>
<p>The examples use TypeScript and Vitest, but the approach applies to most languages and testing frameworks.</p>
<p>The goal isn't to preserve every line of legacy behavior forever. The goal is to make behavior visible before you start changing the code that produces it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with:</p>
<ul>
<li><p>TypeScript or a similar programming language</p>
</li>
<li><p>unit and integration testing</p>
</li>
<li><p>dependency injection</p>
</li>
<li><p>mocks and test doubles</p>
</li>
<li><p>basic refactoring techniques</p>
</li>
<li><p>reading an unfamiliar codebase</p>
</li>
</ul>
<p>It also helps if you've already mapped the capability you want to change.</p>
<p>Before writing characterization tests, you should have some idea of:</p>
<ul>
<li><p>where the behavior starts</p>
</li>
<li><p>what state it changes</p>
</li>
<li><p>which external systems it touches</p>
</li>
<li><p>which outputs may be consumed elsewhere</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-characterization-tests-actually-protect">What Characterization Tests Actually Protect</a></p>
</li>
<li><p><a href="#heading-start-with-behavior-not-implementation">Start with Behavior, Not Implementation</a></p>
</li>
<li><p><a href="#heading-choose-one-capability-before-writing-tests">Choose One Capability Before Writing Tests</a></p>
</li>
<li><p><a href="#heading-find-the-smallest-useful-test-boundary">Find the Smallest Useful Test Boundary</a></p>
</li>
<li><p><a href="#heading-capture-existing-behavior-before-improving-it">Capture Existing Behavior Before Improving It</a></p>
</li>
<li><p><a href="#heading-characterize-edge-cases-you-dont-yet-understand">Characterize Edge Cases You Don't Yet Understand</a></p>
</li>
<li><p><a href="#heading-test-side-effects-not-just-return-values">Test Side Effects, Not Just Return Values</a></p>
</li>
<li><p><a href="#heading-how-to-characterize-code-that-depends-on-a-database">How to Characterize Code That Depends on a Database</a></p>
</li>
<li><p><a href="#heading-how-to-handle-external-services">How to Handle External Services</a></p>
</li>
<li><p><a href="#heading-how-to-use-ai-to-discover-characterization-tests">How to Use AI to Discover Characterization Tests</a></p>
</li>
<li><p><a href="#heading-dont-let-ai-invent-expected-behavior">Don't Let AI Invent Expected Behavior</a></p>
</li>
<li><p><a href="#heading-avoid-testing-implementation-details">Avoid Testing Implementation Details</a></p>
</li>
<li><p><a href="#heading-when-a-characterization-test-reveals-a-bug">When a Characterization Test Reveals a Bug</a></p>
</li>
<li><p><a href="#heading-how-much-behavior-should-you-characterize">How Much Behavior Should You Characterize</a></p>
</li>
<li><p><a href="#heading-use-characterization-tests-during-the-refactor">Use Characterization Tests During the Refactor</a></p>
</li>
<li><p><a href="#heading-a-practical-characterization-testing-workflow">A Practical Characterization Testing Workflow</a></p>
</li>
<li><p><a href="#heading-what-characterization-tests-cant-tell-you">What Characterization Tests Can't Tell You</a></p>
</li>
<li><p><a href="#heading-characterization-tests-are-temporary-knowledge-infrastructure">Characterization Tests Are Temporary Knowledge Infrastructure</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-characterization-tests-actually-protect">What Characterization Tests Actually Protect</h2>
<p>Suppose you inherit this function:</p>
<pre><code class="language-typescript">type Customer = {
  id: string;
  type: "STANDARD" | "PREMIUM";
};

type Order = {
  id: string;
  customer: Customer;
  subtotal: number;
  country: string;
  paymentMethod: "CARD" | "TRANSFER";
};

async function processOrder(order: Order) {
  let total = order.subtotal;

  if (order.customer.type === "PREMIUM") {
    total = total * 0.9;
  }

  if (order.country === "AR" &amp;&amp; order.paymentMethod === "TRANSFER") {
    total = total - 500;
  }

  if (total &lt; 0) {
    total = 0;
  }

  await ordersRepository.save({
    ...order,
    total,
    status: "PROCESSED",
  });

  await eventBus.publish("order.processed", {
    orderId: order.id,
    total,
  });

  return total;
}
</code></pre>
<p>There are several things you may want to refactor here.</p>
<p>For example, the pricing rules could move to another module. Persistence could be isolated. The event publisher could sit behind an interface. And the function could return an object rather than a primitive.</p>
<p>Those may all be good decisions, but before making them, you should ask: What behavior currently matters?</p>
<p>For this function, observable behavior includes at least:</p>
<ul>
<li><p>premium customers receive a 10% discount</p>
</li>
<li><p>Argentine transfers receive another adjustment</p>
</li>
<li><p>totals can't become negative</p>
</li>
<li><p>the order is persisted with a specific status</p>
</li>
<li><p>an event is published</p>
</li>
<li><p>the event contains the calculated total</p>
</li>
<li><p>the function returns that total</p>
</li>
</ul>
<p>A characterization test gives you a baseline for those behaviors.</p>
<p>For example:</p>
<pre><code class="language-typescript">import { describe, expect, it, vi } from "vitest";

describe("processOrder", () =&gt; {
  it("applies the existing premium customer behavior", async () =&gt; {
    const save = vi.spyOn(ordersRepository, "save");
    const publish = vi.spyOn(eventBus, "publish");

    const order: Order = {
      id: "order-1",
      customer: {
        id: "customer-1",
        type: "PREMIUM",
      },
      subtotal: 10000,
      country: "US",
      paymentMethod: "CARD",
    };

    const result = await processOrder(order);

    expect(result).toBe(9000);

    expect(save).toHaveBeenCalledWith(
      expect.objectContaining({
        id: "order-1",
        total: 9000,
        status: "PROCESSED",
      })
    );

    expect(publish).toHaveBeenCalledWith("order.processed", {
      orderId: "order-1",
      total: 9000,
    });
  });
});
</code></pre>
<p>This test isn't saying that a 10% discount is the best pricing model.</p>
<p>It's saying:</p>
<blockquote>
<p>This is what the system currently does.</p>
</blockquote>
<p>That's the contract you need to understand before changing it.</p>
<h2 id="heading-start-with-behavior-not-implementation">Start with Behavior, Not Implementation</h2>
<p>A common mistake is to write tests around the structure you're planning to create.</p>
<p>Suppose you want to refactor the previous code into:</p>
<pre><code class="language-text">OrderProcessor
PricingPolicy
OrdersRepository
OrderEventPublisher
</code></pre>
<p>You may be tempted to write tests for those future classes first.</p>
<p>But those classes don't describe the existing system. They describe your proposed design.</p>
<p>Characterization tests should begin at the current observable boundary.</p>
<p>Instead of asking:</p>
<blockquote>
<p>How should <code>PricingPolicy</code> work?</p>
</blockquote>
<p>ask:</p>
<blockquote>
<p>Given this input, what does <code>processOrder()</code> currently produce?</p>
</blockquote>
<p>That difference helps prevent your new architecture from redefining behavior accidentally.</p>
<p>The sequence should be:</p>
<pre><code class="language-text">Observe existing behavior
↓
Capture it
↓
Refactor implementation
↓
Run characterization tests
↓
Verify behavior remains stable
</code></pre>
<p>Not:</p>
<pre><code class="language-text">Design new architecture
↓
Write tests for new architecture
↓
Assume it matches the old system
</code></pre>
<p>The second workflow tests your design. The first protects the migration.</p>
<h2 id="heading-choose-one-capability-before-writing-tests">Choose One Capability Before Writing Tests</h2>
<p>Don't start by trying to characterize an entire legacy application. Instead, pick one business capability.</p>
<p>For example:</p>
<pre><code class="language-text">Approve Order
Generate Invoice
Renew Subscription
Register Customer
Calculate Commission
Cancel Reservation
</code></pre>
<p>Then trace that capability through the system.</p>
<p>Suppose you choose:</p>
<blockquote>
<p>Generate Invoice</p>
</blockquote>
<p>You discover this path:</p>
<pre><code class="language-text">POST /orders/:id/invoice
        ↓
InvoiceController.generate()
        ↓
InvoiceService.generate()
        ↓
TaxCalculator.calculate()
        ↓
InvoiceRepository.save()
        ↓
PdfGenerator.create()
        ↓
EmailService.send()
</code></pre>
<p>That becomes the scope of your investigation.</p>
<p>Now ask: Which behaviors matter if I refactor this capability?</p>
<p>Perhaps:</p>
<pre><code class="language-text">tax calculation
invoice numbering
database state
PDF fields
email recipient
email attachment
error behavior
</code></pre>
<p>Those are candidates for characterization.</p>
<p>This is more useful than trying to increase test coverage across the repository indiscriminately.</p>
<p>Coverage isn't the goal. Behavioral confidence is.</p>
<h2 id="heading-find-the-smallest-useful-test-boundary">Find the Smallest Useful Test Boundary</h2>
<p>Characterization tests can exist at different levels.</p>
<p>You might test:</p>
<pre><code class="language-text">function
service
module
API endpoint
background job
complete workflow
</code></pre>
<p>The right boundary is usually the smallest one that still captures meaningful behavior.</p>
<p>Suppose the logic you want to refactor lives inside:</p>
<pre><code class="language-typescript">class InvoiceService {
  async generate(orderId: string) {
    // 300 lines of legacy behavior
  }
}
</code></pre>
<p>If <code>generate()</code> coordinates tax calculation, persistence, numbering, and external calls, testing a small internal helper may not protect enough behavior.</p>
<p>Testing the whole production stack may be too slow and difficult.</p>
<p>A service-level characterization test may be the useful compromise.</p>
<p>For example:</p>
<pre><code class="language-typescript">describe("InvoiceService.generate", () =&gt; {
  it("preserves the existing invoice calculation", async () =&gt; {
    const service = createInvoiceService();

    const invoice = await service.generate("order-123");

    expect(invoice.subtotal).toBe(10000);
    expect(invoice.tax).toBe(2100);
    expect(invoice.total).toBe(12100);
  });
});
</code></pre>
<p>You don't want to ask what's the smallest unit you can test. You want to ask what's the smallest boundary that gives you confidence during this refactor.</p>
<p>Those aren't always the same thing.</p>
<h2 id="heading-capture-existing-behavior-before-improving-it">Capture Existing Behavior Before Improving It</h2>
<p>Legacy code often contains behavior that looks suspicious.</p>
<p>Consider:</p>
<pre><code class="language-typescript">function calculateDiscount(amount: number) {
  if (amount &gt; 10000) {
    return amount * 0.15;
  }

  if (amount &gt; 5000) {
    return amount * 0.1;
  }

  return 0;
}
</code></pre>
<p>You run a few examples and discover:</p>
<pre><code class="language-text">5000  -&gt; 0
5001  -&gt; 500.1
10000 -&gt; 1000
10001 -&gt; 1500.15
</code></pre>
<p>You might think:</p>
<blockquote>
<p><code>5000</code> should probably receive the 10% discount.</p>
</blockquote>
<p>Maybe. But that's not what the current code does.</p>
<p>A characterization test could record:</p>
<pre><code class="language-typescript">describe("calculateDiscount", () =&gt; {
  it.each([
    [5000, 0],
    [5001, 500.1],
    [10000, 1000],
    [10001, 1500.15],
  ])(
    "returns the existing discount for amount %d",
    (amount, expected) =&gt; {
      expect(calculateDiscount(amount)).toBe(expected);
    }
  );
});
</code></pre>
<p>This creates a behavioral boundary around the existing implementation.</p>
<p>Later, if the business confirms that <code>5000</code> should receive a discount, you can intentionally change:</p>
<pre><code class="language-typescript">if (amount &gt; 5000)
</code></pre>
<p>to:</p>
<pre><code class="language-typescript">if (amount &gt;= 5000)
</code></pre>
<p>and update the relevant test.</p>
<p>The important part is that the change becomes explicit.</p>
<p>Without the test, it could happen accidentally during an unrelated refactor.</p>
<h2 id="heading-characterize-edge-cases-you-dont-yet-understand">Characterize Edge Cases You Don't Yet Understand</h2>
<p>The obvious cases aren't always the risky ones. Legacy systems often fail at boundaries.</p>
<p>Look for values such as:</p>
<pre><code class="language-text">0
-1
null
empty string
maximum value
minimum value
exact threshold values
unknown status
duplicate identifiers
missing related records
</code></pre>
<p>Suppose you find:</p>
<pre><code class="language-typescript">function normalizeBalance(balance?: number) {
  if (!balance) {
    return 0;
  }

  return Math.round(balance * 100) / 100;
}
</code></pre>
<p>That means:</p>
<pre><code class="language-text">undefined -&gt; 0
0         -&gt; 0
</code></pre>
<p>But also potentially:</p>
<pre><code class="language-text">NaN -&gt; 0
</code></pre>
<p>because <code>NaN</code> is falsy.</p>
<p>Is that intentional? You may not know yet.</p>
<p>You can characterize it:</p>
<pre><code class="language-typescript">describe("normalizeBalance", () =&gt; {
  it("returns zero for undefined", () =&gt; {
    expect(normalizeBalance(undefined)).toBe(0);
  });

  it("returns zero for zero", () =&gt; {
    expect(normalizeBalance(0)).toBe(0);
  });

  it("returns zero for NaN in the current implementation", () =&gt; {
    expect(normalizeBalance(Number.NaN)).toBe(0);
  });
});
</code></pre>
<p>The name matters.</p>
<p>Notice that I wrote:</p>
<blockquote>
<p>in the current implementation</p>
</blockquote>
<p>I'm not pretending that behavior is correct. I'm just documenting it.</p>
<p>That distinction becomes important when a test describes questionable behavior.</p>
<h2 id="heading-test-side-effects-not-just-return-values">Test Side Effects, Not Just Return Values</h2>
<p>A return value is only one kind of behavior.</p>
<p>Legacy functions frequently produce side effects.</p>
<p>Consider:</p>
<pre><code class="language-typescript">async function cancelOrder(order: Order) {
  order.status = "CANCELLED";

  await orders.save(order);
  await inventory.release(order.id);
  await audit.log("ORDER_CANCELLED", order.id);

  return order;
}
</code></pre>
<p>A weak characterization test might only check:</p>
<pre><code class="language-typescript">expect(result.status).toBe("CANCELLED");
</code></pre>
<p>But a refactor could still accidentally remove:</p>
<pre><code class="language-text">inventory.release()
audit.log()
</code></pre>
<p>and the test would continue passing.</p>
<p>A stronger characterization test captures observable side effects:</p>
<pre><code class="language-typescript">it("preserves cancellation side effects", async () =&gt; {
  const save = vi.spyOn(orders, "save");
  const release = vi.spyOn(inventory, "release");
  const log = vi.spyOn(audit, "log");

  const order = {
    id: "order-1",
    status: "APPROVED",
  } as Order;

  await cancelOrder(order);

  expect(save).toHaveBeenCalled();

  expect(release).toHaveBeenCalledWith("order-1");

  expect(log).toHaveBeenCalledWith(
    "ORDER_CANCELLED",
    "order-1"
  );
});
</code></pre>
<p>This doesn't mean every internal call deserves an assertion.</p>
<p>The question is whether the call produces observable behavior that matters outside the implementation.</p>
<h2 id="heading-how-to-characterize-code-that-depends-on-a-database">How to Characterize Code That Depends on a Database</h2>
<p>Database-heavy legacy code can be difficult to test.</p>
<p>Suppose you have:</p>
<pre><code class="language-typescript">async function activateCustomer(customerId: string) {
  const customer = await db.customers.findById(customerId);

  if (!customer) {
    throw new Error("Customer not found");
  }

  await db.customers.update(customerId, {
    status: "ACTIVE",
    activatedAt: new Date(),
  });

  return db.customers.findById(customerId);
}
</code></pre>
<p>You have several options.</p>
<h3 id="heading-use-an-integration-test">Use an Integration Test</h3>
<p>If the database behavior itself matters, run against a disposable test database.</p>
<p>For example:</p>
<pre><code class="language-typescript">it("activates an existing customer", async () =&gt; {
  await seedCustomer({
    id: "customer-1",
    status: "PENDING",
  });

  const result = await activateCustomer("customer-1");

  expect(result?.status).toBe("ACTIVE");
  expect(result?.activatedAt).toBeTruthy();
});
</code></pre>
<p>This gives high confidence, but the test may be slower.</p>
<h3 id="heading-introduce-a-seam">Introduce a Seam</h3>
<p>If database access makes testing impractical, you may need a very small structural change before characterization.</p>
<p>For example:</p>
<pre><code class="language-typescript">type CustomerRepository = {
  findById(id: string): Promise&lt;Customer | null&gt;;
  update(
    id: string,
    data: Partial&lt;Customer&gt;
  ): Promise&lt;void&gt;;
};
</code></pre>
<p>Then:</p>
<pre><code class="language-typescript">async function activateCustomer(
  customerId: string,
  customers: CustomerRepository
) {
  // existing behavior
}
</code></pre>
<p>This is a useful concept from legacy-code work: create a <strong>seam</strong>, a place where behavior can be observed or replaced without rewriting the system.</p>
<p>The key is to keep this preparatory change mechanical.</p>
<p>Don't redesign the business logic while creating the test boundary.</p>
<p>First make it testable. Then characterize it. Then refactor.</p>
<h2 id="heading-how-to-handle-external-services">How to Handle External Services</h2>
<p>Legacy code frequently talks directly to:</p>
<pre><code class="language-text">payment providers
email services
ERPs
CRMs
message brokers
cloud storage
third-party APIs
</code></pre>
<p>You usually don't want characterization tests repeatedly calling those systems.</p>
<p>Instead, capture the interaction at the boundary.</p>
<p>Suppose:</p>
<pre><code class="language-typescript">async function chargeOrder(order: Order) {
  const response = await stripe.charge({
    amount: order.total,
    currency: "usd",
    customerId: order.customerId,
  });

  await orders.markPaid(order.id, response.id);

  return response.id;
}
</code></pre>
<p>You can characterize the request:</p>
<pre><code class="language-typescript">it("sends the existing payment payload", async () =&gt; {
  const charge = vi
    .spyOn(stripe, "charge")
    .mockResolvedValue({
      id: "payment-123",
    });

  const markPaid = vi.spyOn(orders, "markPaid");

  const order = {
    id: "order-1",
    total: 5000,
    customerId: "customer-1",
  } as Order;

  await chargeOrder(order);

  expect(charge).toHaveBeenCalledWith({
    amount: 5000,
    currency: "usd",
    customerId: "customer-1",
  });

  expect(markPaid).toHaveBeenCalledWith(
    "order-1",
    "payment-123"
  );
});
</code></pre>
<p>That protects the external contract without hitting the external system.</p>
<p>But be careful. If the provider behavior itself matters, mocks alone may not be enough.</p>
<p>You might also need:</p>
<ul>
<li><p>provider sandbox tests</p>
</li>
<li><p>contract tests</p>
</li>
<li><p>integration tests</p>
</li>
<li><p>schema validation</p>
</li>
</ul>
<p>Characterization testing doesn't eliminate the need for those layers.</p>
<h2 id="heading-how-to-use-ai-to-discover-characterization-tests">How to Use AI to Discover Characterization Tests</h2>
<p>AI is particularly useful when you are staring at a large legacy function and trying to understand what deserves a test.</p>
<p>Suppose you have a 400-line service.</p>
<p>Instead of asking:</p>
<pre><code class="language-text">Write unit tests for this class.
</code></pre>
<p>use a more investigative prompt:</p>
<pre><code class="language-text">Analyze this class without changing it.

Identify observable behaviors that could change during refactoring.

Group them into:

1. returned values,
2. state changes,
3. persistence effects,
4. external calls,
5. emitted events,
6. exceptions,
7. boundary conditions.

For every proposed characterization test:

- reference the relevant source code,
- explain what behavior the test would protect,
- distinguish observed behavior from inferred behavior.

Do not invent expected values.
</code></pre>
<p>That final instruction matters: you want AI to help identify <strong>what to observe</strong>. You don't want it inventing what the software should do.</p>
<p>Another useful prompt is:</p>
<pre><code class="language-text">Review the existing test suite for this capability.

Compare the behaviors covered by tests with the
observable behaviors in the implementation.

List behavior that appears unprotected.

Do not generate tests yet.
</code></pre>
<p>This is often more valuable than immediately asking for test code.</p>
<p>First identify the gaps, and then decide which gaps matter.</p>
<h2 id="heading-dont-let-ai-invent-expected-behavior">Don't Let AI Invent Expected Behavior</h2>
<p>This is probably the most important rule when combining AI with characterization testing, and it's worth talking a bit more about.</p>
<p>Suppose AI reads:</p>
<pre><code class="language-typescript">if (customer.age &gt; 65) {
  discount = 0.2;
}
</code></pre>
<p>It may generate:</p>
<pre><code class="language-typescript">expect(calculateDiscount(65)).toBe(0.2);
</code></pre>
<p>because it assumes the intended business rule is:</p>
<blockquote>
<p>Customers aged 65 or older receive a discount.</p>
</blockquote>
<p>But that's not what the code says.</p>
<p>The existing behavior is:</p>
<pre><code class="language-text">65 -&gt; no discount
66 -&gt; discount
</code></pre>
<p>The expected values in characterization tests should come from evidence.</p>
<p>Useful evidence includes:</p>
<ul>
<li><p>running the current system</p>
</li>
<li><p>existing tests</p>
</li>
<li><p>fixtures</p>
</li>
<li><p>production-safe observations</p>
</li>
<li><p>documented examples</p>
</li>
<li><p>database state</p>
</li>
<li><p>historical behavior</p>
</li>
</ul>
<p>Don't derive expectations solely from what seems reasonable.</p>
<p>A better AI instruction is:</p>
<pre><code class="language-text">For each candidate test, tell me how I can obtain
the expected result from the current implementation.

Do not propose the expected result yourself unless it
can be directly derived from executable behavior
or an existing test.
</code></pre>
<p>This turns AI into an assistant for experiment design rather than an authority on business rules.</p>
<h2 id="heading-avoid-testing-implementation-details">Avoid Testing Implementation Details</h2>
<p>Characterization tests can become harmful if they freeze the current code structure.</p>
<p>Suppose the implementation is:</p>
<pre><code class="language-typescript">async function processOrder(order: Order) {
  validateOrder(order);
  calculatePrice(order);
  reserveInventory(order);
  saveOrder(order);
}
</code></pre>
<p>A brittle test might assert:</p>
<pre><code class="language-typescript">expect(validateOrder).toHaveBeenCalledBefore(calculatePrice);
expect(calculatePrice).toHaveBeenCalledBefore(reserveInventory);
expect(reserveInventory).toHaveBeenCalledBefore(saveOrder);
</code></pre>
<p>Maybe that order matters. Maybe it doesn't.</p>
<p>If consumers only care about:</p>
<pre><code class="language-text">correct total
inventory reserved
order persisted
</code></pre>
<p>then asserting the exact sequence unnecessarily constrains the refactor.</p>
<p>Prefer protecting externally meaningful behavior.</p>
<p>For example:</p>
<pre><code class="language-typescript">expect(savedOrder.total).toBe(9000);
expect(inventory.reserve).toHaveBeenCalledWith(
  "product-1",
  2
);
expect(repository.save).toHaveBeenCalled();
</code></pre>
<p>Characterization tests should create a safety net. They shouldn't turn the legacy implementation into a specification of every internal decision.</p>
<h2 id="heading-when-a-characterization-test-reveals-a-bug">When a Characterization Test Reveals a Bug</h2>
<p>Eventually you'll encounter behavior that appears clearly wrong.</p>
<p>For example:</p>
<pre><code class="language-typescript">function calculateFee(amount: number) {
  if (amount === 0) {
    return 100;
  }

  return amount * 0.02;
}
</code></pre>
<p>You confirm that zero-value transactions are charged a fixed fee. Everyone agrees this looks suspicious.</p>
<p>What should the characterization test do?</p>
<p>First, separate two questions:</p>
<ol>
<li><p>What does the system do today?</p>
</li>
<li><p>What should the system do?</p>
</li>
</ol>
<p>The characterization test answers the first.</p>
<pre><code class="language-typescript">it("currently charges 100 for a zero-value transaction", () =&gt; {
  expect(calculateFee(0)).toBe(100);
});
</code></pre>
<p>Then investigate whether this is intentional business behavior, a historical workaround, or an actual defect.</p>
<p>If the business confirms it is a bug, create a separate change.</p>
<p>For example:</p>
<pre><code class="language-typescript">it("does not charge a fee for a zero-value transaction", () =&gt; {
  expect(calculateFee(0)).toBe(0);
});
</code></pre>
<p>Then modify the production code.</p>
<p>This may sound overly formal for a small condition. But it creates a clean distinction between:</p>
<pre><code class="language-text">behavior discovered during refactoring
</code></pre>
<p>and:</p>
<pre><code class="language-text">behavior intentionally changed
</code></pre>
<p>That distinction becomes extremely valuable in large migrations.</p>
<h2 id="heading-how-much-behavior-should-you-characterize">How Much Behavior Should You Characterize</h2>
<p>You don't need to characterize everything. Trying to preserve every observed detail can create another form of paralysis.</p>
<p>Prioritize behavior with high change risk or high business impact.</p>
<p>I usually look first at:</p>
<ul>
<li><p>financial calculations</p>
</li>
<li><p>state transitions</p>
</li>
<li><p>authentication and authorization</p>
</li>
<li><p>external contracts</p>
</li>
<li><p>queue and event payloads</p>
</li>
<li><p>data transformations</p>
</li>
<li><p>retry behavior</p>
</li>
<li><p>idempotency</p>
</li>
<li><p>regulatory rules</p>
</li>
<li><p>critical error handling</p>
</li>
</ul>
<p>You may care less about:</p>
<ul>
<li><p>internal helper naming</p>
</li>
<li><p>private method structure</p>
</li>
<li><p>log wording that nobody consumes</p>
</li>
<li><p>temporary object shapes</p>
</li>
<li><p>implementation-specific call sequences</p>
</li>
</ul>
<p>A useful question is: If this behavior changed during refactoring, could somebody outside this function notice?</p>
<p>If the answer is yes, it's probably worth considering.</p>
<h2 id="heading-use-characterization-tests-during-the-refactor">Use Characterization Tests During the Refactor</h2>
<p>Once the characterization suite exists, keep the refactor small.</p>
<p>Suppose you begin with:</p>
<pre><code class="language-typescript">async function processOrder(order: Order) {
  // validation
  // pricing
  // inventory
  // persistence
  // event publishing
}
</code></pre>
<p>You might first extract pricing:</p>
<pre><code class="language-typescript">function calculateOrderTotal(order: Order) {
  let total = order.subtotal;

  if (order.customer.type === "PREMIUM") {
    total *= 0.9;
  }

  if (
    order.country === "AR" &amp;&amp;
    order.paymentMethod === "TRANSFER"
  ) {
    total -= 500;
  }

  return Math.max(total, 0);
}
</code></pre>
<p>Run the characterization suite. If everything still passes, continue.</p>
<p>Next isolate inventory and run it again.</p>
<p>Then persistence. Run it again.</p>
<p>This gives you a migration rhythm:</p>
<pre><code class="language-text">small structural change
↓
run tests
↓
observe
↓
continue
</code></pre>
<p>If something fails, the search space is small.</p>
<p>Compare that with rewriting 2,000 lines and then discovering 47 broken tests.</p>
<p>Small changes turn failures into useful feedback, while Large changes turn failures into archaeology.</p>
<p>Again.</p>
<h2 id="heading-a-practical-characterization-testing-workflow">A Practical Characterization Testing Workflow</h2>
<p>Here is the workflow I would use on an unfamiliar legacy capability.</p>
<h3 id="heading-1-map-the-capability">1. Map the Capability</h3>
<p>Identify:</p>
<pre><code class="language-text">entry point
business logic
state changes
side effects
external contracts
outputs
</code></pre>
<p>Don't refactor yet.</p>
<h3 id="heading-2-find-existing-tests">2. Find Existing Tests</h3>
<p>Search for tests that already describe the capability.</p>
<p>Look for:</p>
<pre><code class="language-text">happy paths
boundary cases
errors
historical bugs
integration behavior
</code></pre>
<p>Don't duplicate useful tests unnecessarily.</p>
<h3 id="heading-3-list-observable-behaviors">3. List Observable Behaviors</h3>
<p>Create a table such as:</p>
<table>
<thead>
<tr>
<th>Behavior</th>
<th>Evidence</th>
<th>Protected?</th>
</tr>
</thead>
<tbody><tr>
<td>Premium discount</td>
<td>Code + production example</td>
<td>No</td>
</tr>
<tr>
<td>Order event</td>
<td>Code</td>
<td>Yes</td>
</tr>
<tr>
<td>Transfer adjustment</td>
<td>Code</td>
<td>No</td>
</tr>
<tr>
<td>Negative total clamp</td>
<td>Code</td>
<td>No</td>
</tr>
<tr>
<td>Save status</td>
<td>Existing integration test</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>Now you know where the risk is.</p>
<h3 id="heading-4-pick-the-test-boundary">4. Pick the Test Boundary</h3>
<p>Decide whether the useful boundary is:</p>
<pre><code class="language-text">function
service
module
endpoint
job
workflow
</code></pre>
<p>Choose based on confidence, not test ideology.</p>
<h3 id="heading-5-capture-current-behavior">5. Capture Current Behavior</h3>
<p>Run the existing system.</p>
<p>Use real observable outputs when possible.</p>
<p>Don't guess expectations.</p>
<h3 id="heading-6-add-critical-edge-cases">6. Add Critical Edge Cases</h3>
<p>Test:</p>
<pre><code class="language-text">thresholds
empty values
nulls
errors
duplicate operations
retry scenarios
</code></pre>
<p>especially around logic you intend to change.</p>
<h3 id="heading-7-capture-side-effects">7. Capture Side Effects</h3>
<p>Protect meaningful:</p>
<pre><code class="language-text">writes
events
messages
external calls
state transitions
</code></pre>
<p>not only function return values.</p>
<h3 id="heading-8-mark-uncertain-behavior">8. Mark Uncertain Behavior</h3>
<p>Use test names or documentation that clearly distinguishes:</p>
<pre><code class="language-text">confirmed business rule
</code></pre>
<p>from:</p>
<pre><code class="language-text">current observed behavior
</code></pre>
<h3 id="heading-9-refactor-incrementally">9. Refactor Incrementally</h3>
<p>Make one structural change.</p>
<p>Run the suite.</p>
<p>Repeat.</p>
<h3 id="heading-10-replace-characterization-with-intent-where-appropriate">10. Replace Characterization with Intent Where Appropriate</h3>
<p>As understanding improves, some characterization tests can evolve into true specification tests.</p>
<p>Instead of:</p>
<pre><code class="language-text">currently returns 0 for this input
</code></pre>
<p>you may eventually be able to say:</p>
<pre><code class="language-text">does not apply a discount below the premium threshold
</code></pre>
<p>That transition is useful. It means the system is becoming understood rather than merely preserved.</p>
<h2 id="heading-what-characterization-tests-cant-tell-you">What Characterization Tests Can't Tell You</h2>
<p>Characterization tests are powerful, but they have an important limitation.</p>
<p>They tell you what happened for the cases you observed. They don't automatically tell you why.</p>
<p>Suppose the test says:</p>
<pre><code class="language-text">Argentine transfer orders receive a 500-unit adjustment.
</code></pre>
<p>The test can protect that behavior.</p>
<p>It can't tell you whether the adjustment exists because of:</p>
<ul>
<li><p>a tax rule</p>
</li>
<li><p>a banking fee</p>
</li>
<li><p>an old promotion</p>
</li>
<li><p>a customer-specific workaround</p>
</li>
<li><p>a bug nobody removed</p>
</li>
</ul>
<p>For that, you still need other evidence:</p>
<ul>
<li><p>documentation</p>
</li>
<li><p>Git history</p>
</li>
<li><p>production telemetry</p>
</li>
<li><p>domain experts</p>
</li>
<li><p>incident records</p>
</li>
<li><p>external system contracts</p>
</li>
</ul>
<p>This is why characterization testing belongs after codebase understanding, not instead of it.</p>
<p>You first discover the behavior. Then you protect it. Then you continue investigating what it means.</p>
<h2 id="heading-characterization-tests-are-temporary-knowledge-infrastructure">Characterization Tests Are Temporary Knowledge Infrastructure</h2>
<p>There's another way I think about these tests.</p>
<p>Legacy systems contain knowledge that's often trapped inside implementation details. A characterization test moves some of that knowledge into an executable form.</p>
<p>Before:</p>
<pre><code class="language-text">Nobody knows what changing this condition will break.
</code></pre>
<p>After:</p>
<pre><code class="language-text">Changing this condition causes these four observable behaviors to change.
</code></pre>
<p>That's already progress.</p>
<p>The test suite becomes part of your understanding of the system. It creates a bridge between:</p>
<pre><code class="language-text">what the code currently does
</code></pre>
<p>and:</p>
<pre><code class="language-text">what we eventually want the system to do
</code></pre>
<p>You don't have to keep every characterization test forever.</p>
<p>Some will become proper specification tests.</p>
<p>Some will disappear when obsolete behavior is intentionally removed.</p>
<p>Some will remain as regression tests.</p>
<p>Their first job is simpler: <strong>make change safer while understanding is still incomplete.</strong></p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>AI makes refactoring legacy code faster.</p>
<p>It can explain functions, generate candidate abstractions, extract interfaces, suggest module boundaries, and rewrite large sections of code in seconds.</p>
<p>That makes characterization testing more important, not less.</p>
<p>When the cost of producing a new implementation decreases, the risk shifts toward verifying that the new implementation still preserves the behavior that matters.</p>
<p>Before asking:</p>
<pre><code class="language-text">How should I refactor this?
</code></pre>
<p>ask:</p>
<pre><code class="language-text">What does this do today?
</code></pre>
<p>Then:</p>
<pre><code class="language-text">Which of those behaviors matter?
</code></pre>
<p>Then:</p>
<pre><code class="language-text">How can I prove they still work after the change?
</code></pre>
<p>That is what characterization tests give you.</p>
<p>They don't tell you that legacy behavior is correct. They give you evidence that it exists.</p>
<p>And once that evidence is executable, you can refactor with much more confidence.</p>
<p>The sequence becomes:</p>
<pre><code class="language-text">Understand
↓
Characterize
↓
Refactor
↓
Verify
</code></pre>
<p>AI can accelerate every step in that workflow. But the engineering judgment remains in deciding what behavior deserves to survive, what behavior should change, and when you have enough evidence to safely make that distinction.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Understand a Legacy Codebase Using AI Before Changing it ]]>
                </title>
                <description>
                    <![CDATA[ The first thing many engineers want to do when they inherit a legacy codebase is change it. And I understand the impulse. You open a class that's 1,500 lines long. There are database calls mixed with  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/understand-a-legacy-codebase-with-ai/</link>
                <guid isPermaLink="false">6a888892029633fd14697876</guid>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                    <category>
                        <![CDATA[ legacy code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ refactoring ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hugo Teijiz ]]>
                </dc:creator>
                <pubDate>Fri, 21 Aug 2026 17:19:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7d94c780-37eb-4bd6-a1e2-da6e25bdfdcb.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first thing many engineers want to do when they inherit a legacy codebase is change it. And I understand the impulse.</p>
<p>You open a class that's 1,500 lines long. There are database calls mixed with business rules, configuration values scattered across the repository, methods nobody wants to touch, and comments that refer to systems that disappeared years ago.</p>
<p>Then an AI coding assistant offers to explain the whole thing.</p>
<p>So you ask:</p>
<blockquote>
<p>Refactor this class.</p>
</blockquote>
<p>But that's usually too early.</p>
<p>One of the lessons I've learned from working with legacy systems is that code can be ugly and still contain important knowledge.</p>
<p>A strange condition may encode a business exception. A duplicated calculation may exist because two processes that look identical aren't actually identical. A database column with a terrible name may still be part of an external contract.</p>
<p>And a method nobody understands may be the only thing preventing a production incident that happened eight years ago from happening again.</p>
<p>AI makes it much easier to read unfamiliar software, and that's valuable. But it also makes it much easier to change software before you understand it.</p>
<p>In this tutorial, I'll show you how to use AI for something I believe should happen before refactoring or migration: <strong>codebase archaeology.</strong></p>
<p>You'll learn how to use AI to help you:</p>
<ul>
<li><p>map a repository,</p>
</li>
<li><p>identify entry points,</p>
</li>
<li><p>trace dependencies,</p>
</li>
<li><p>separate business rules from infrastructure,</p>
</li>
<li><p>find hidden side effects,</p>
</li>
<li><p>inspect data flow,</p>
</li>
<li><p>discover implicit contracts,</p>
</li>
<li><p>detect duplicated behavior,</p>
</li>
<li><p>build a dependency map,</p>
</li>
<li><p>identify areas of uncertainty,</p>
</li>
<li><p>and turn those findings into a modernization plan.</p>
</li>
</ul>
<p>The examples use TypeScript, but the process works with most languages and stacks.</p>
<p>The goal isn't to ask AI what the code means and trust the answer. The goal is to use AI to reduce the amount of time you spend looking for the right questions.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with:</p>
<ul>
<li><p>reading an existing codebase</p>
</li>
<li><p>TypeScript or a similar object-oriented language</p>
</li>
<li><p>basic software architecture</p>
</li>
<li><p>dependency injection</p>
</li>
<li><p>unit and integration testing</p>
</li>
<li><p>using an AI coding assistant that can inspect repository files</p>
</li>
</ul>
<p>You don't need a specific AI provider, as the workflow matters more than the model.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-understanding-has-to-come-before-refactoring">Why Understanding Has to Come Before Refactoring</a></p>
</li>
<li><p><a href="#heading-how-to-start-with-the-repository-not-the-classes">How to Start with the Repository, Not the Classes</a></p>
</li>
<li><p><a href="#heading-how-to-find-the-real-entry-points">How to Find the Real Entry Points</a></p>
</li>
<li><p><a href="#heading-how-to-trace-a-business-capability-through-the-codebase">How to Trace a Business Capability Through the Codebase</a></p>
</li>
<li><p><a href="#heading-how-to-separate-business-rules-from-infrastructure">How to Separate Business Rules from Infrastructure</a></p>
</li>
<li><p><a href="#heading-how-to-find-hidden-side-effects">How to Find Hidden Side Effects</a></p>
</li>
<li><p><a href="#heading-how-to-discover-implicit-contracts">How to Discover Implicit Contracts</a></p>
</li>
<li><p><a href="#heading-how-to-use-ai-to-find-duplicated-business-rules">How to Use AI to Find Duplicated Business Rules</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-lightweight-dependency-map">How to Build a Lightweight Dependency Map</a></p>
</li>
<li><p><a href="#heading-how-to-mark-what-you-still-do-not-understand">How to Mark What You Still Do Not Understand</a></p>
</li>
<li><p><a href="#heading-how-to-validate-ai-findings-against-the-system">How to Validate AI Findings Against the System</a></p>
</li>
<li><p><a href="#heading-how-to-turn-codebase-understanding-into-a-migration-plan">How to Turn Codebase Understanding into a Migration Plan</a></p>
</li>
<li><p><a href="#heading-a-practical-codebase-archaeology-workflow">A Practical Codebase Archaeology Workflow</a></p>
</li>
<li><p><a href="#heading-what-i-would-not-ask-ai-to-do-first">What I Would Not Ask AI to Do First</a></p>
</li>
<li><p><a href="#heading-the-most-useful-ai-output-is-sometimes-a-question">The Most Useful AI Output Is Sometimes a Question</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-understanding-has-to-come-before-refactoring">Why Understanding Has to Come Before Refactoring</h2>
<p>Legacy code often creates a false sense of urgency.</p>
<p>You see something obviously coupled or duplicated and immediately want to clean it up.</p>
<p>Consider this function:</p>
<pre><code class="language-typescript">async function approveOrder(order: Order) {
  if (order.total &gt; 10000 &amp;&amp; !order.customer.verified) {
    throw new Error("Manual verification required");
  }

  if (
    order.customer.country === "AR" &amp;&amp;
    order.paymentMethod === "TRANSFER"
  ) {
    order.status = "PENDING";
  } else {
    order.status = "APPROVED";
  }

  await orders.save(order);

  if (order.status === "APPROVED") {
    await billing.createInvoice(order);
  }

  await audit.log({
    action: "ORDER_APPROVAL",
    orderId: order.id,
    status: order.status,
  });

  return order;
}
</code></pre>
<p>At first glance, there are several clear refactoring opportunities:</p>
<ul>
<li><p>You could extract validation.</p>
</li>
<li><p>You could isolate status calculation.</p>
</li>
<li><p>You could move billing behind an interface.</p>
</li>
<li><p>You could create an approval policy.</p>
</li>
</ul>
<p>All of those ideas may be reasonable, but there are questions you should answer first:</p>
<ul>
<li><p>Why is <code>10000</code> important?</p>
</li>
<li><p>Why does an Argentine bank transfer remain pending?</p>
</li>
<li><p>Does invoice creation have to happen after persistence?</p>
</li>
<li><p>Is <code>ORDER_APPROVAL</code> consumed by another system?</p>
</li>
<li><p>Can orders transition from <code>PENDING</code> to <code>APPROVED</code> somewhere else?</p>
</li>
<li><p>Does anything depend on the exact exception message?</p>
</li>
</ul>
<p>You can't answer those questions from syntax alone.</p>
<p>That's where understanding begins.</p>
<p>Instead of asking your AI tool:</p>
<pre><code class="language-text">Refactor this function using clean architecture.
</code></pre>
<p>start with:</p>
<pre><code class="language-text">Analyze this function without changing it.

Identify:

1. explicit business rules,
2. likely business rules that need confirmation,
3. side effects,
4. external dependencies,
5. state transitions,
6. magic values,
7. assumptions that cannot be proven from this file alone.

Do not propose a refactor yet.
</code></pre>
<p>That last line is important: <strong>Do not propose a refactor yet.</strong></p>
<p>You want the model in investigation mode, not solution mode.</p>
<h2 id="heading-how-to-start-with-the-repository-not-the-classes">How to Start with the Repository, Not the Classes</h2>
<p>When I approach an unfamiliar legacy system, I don't start by reading every file. I start by trying to understand the shape of the application.</p>
<p>A repository already contains architectural clues.</p>
<p>Look for directories such as:</p>
<pre><code class="language-text">src/
controllers/
services/
repositories/
models/
jobs/
workers/
scripts/
migrations/
config/
integrations/
tests/
</code></pre>
<p>But don't assume the directory names describe the real architecture.</p>
<p>A directory called <code>services</code> can contain business logic, infrastructure, orchestration, and random utility functions.</p>
<p>A directory called <code>models</code> might contain database entities rather than domain models.</p>
<p>A folder called <code>utils</code> can hide half the application's business logic.</p>
<p>Use the structure as evidence, not truth.</p>
<p>A useful first AI request is:</p>
<pre><code class="language-text">Inspect the repository structure.

Do not analyze individual implementation details yet.

Identify:

- application entry points,
- major modules,
- database technologies,
- external integrations,
- background processing,
- scheduled tasks,
- authentication mechanisms,
- configuration sources,
- tests,
- likely architectural boundaries.

For each conclusion, reference the files or directories
that support it.

Mark anything uncertain explicitly.
</code></pre>
<p>The requirement to reference files matters. Without it, AI can give you a perfectly reasonable architecture that doesn't actually exist.</p>
<p>You want something closer to:</p>
<pre><code class="language-text">HTTP API
Evidence:
- src/server.ts
- src/routes/orders.ts
- src/routes/customers.ts

Background processing
Evidence:
- src/workers/paymentWorker.ts
- src/queues/index.ts

Scheduled jobs
Evidence:
- src/jobs/reconcileInvoices.ts
- src/cron.ts
</code></pre>
<p>Now you have a map you can verify.</p>
<h2 id="heading-how-to-find-the-real-entry-points">How to Find the Real Entry Points</h2>
<p>Web applications often have an obvious HTTP entry point. But legacy systems frequently have several more.</p>
<p>A business operation may begin from:</p>
<ul>
<li><p>an API request,</p>
</li>
<li><p>a scheduled job,</p>
</li>
<li><p>a queue consumer,</p>
</li>
<li><p>a database trigger,</p>
</li>
<li><p>a CLI script,</p>
</li>
<li><p>a file import,</p>
</li>
<li><p>an email handler,</p>
</li>
<li><p>a webhook,</p>
</li>
<li><p>or another application calling the database directly.</p>
</li>
</ul>
<p>If you only analyze controllers, you may miss half the system.</p>
<p>Suppose you search for order creation and find:</p>
<pre><code class="language-text">POST /orders
</code></pre>
<p>It would be easy to assume that all orders enter through that endpoint.</p>
<p>Then you discover:</p>
<pre><code class="language-text">jobs/importMarketplaceOrders.ts
workers/retryFailedOrders.ts
scripts/migratePendingOrders.ts
integrations/shopify/webhook.ts
</code></pre>
<p>Now the same business object has four additional entry paths.</p>
<p>This changes how you think about refactoring.</p>
<p>Ask AI:</p>
<pre><code class="language-text">Find every location that can create, modify,
approve, cancel, or persist an Order.

Include:

- HTTP endpoints,
- background workers,
- scheduled jobs,
- scripts,
- imports,
- webhooks,
- direct repository calls.

Group the results by operation.

For every result, include the file path and
the relevant function or class.
</code></pre>
<p>Then verify those results with repository search.</p>
<p>For example:</p>
<pre><code class="language-bash">rg "orders\.save|orders\.insert|createOrder|approveOrder" src
</code></pre>
<p>AI should accelerate search, not replace it.</p>
<h2 id="heading-how-to-trace-a-business-capability-through-the-codebase">How to Trace a Business Capability Through the Codebase</h2>
<p>Understanding individual files isn't enough.</p>
<p>What usually matters is understanding a <strong>business capability</strong>.</p>
<p>For example:</p>
<blockquote>
<p>Create an order.</p>
</blockquote>
<p>That capability may travel through several layers:</p>
<pre><code class="language-text">HTTP Request
     ↓
Controller
     ↓
Application Service
     ↓
Pricing
     ↓
Inventory
     ↓
Persistence
     ↓
Payment
     ↓
Notification
</code></pre>
<p>The code may not be organized that cleanly, and that's precisely why tracing the capability is useful.</p>
<p>Choose one real workflow and ask:</p>
<pre><code class="language-text">Trace the "Create Order" capability from its entry point
until all observable side effects are complete.

For each step, show:

- file,
- function or class,
- input,
- output,
- state change,
- external call,
- error behavior.

Do not summarize multiple steps into one.
</code></pre>
<p>You want a sequence that you can inspect.</p>
<p>For example:</p>
<pre><code class="language-text">1. POST /orders
   src/routes/orders.ts

2. OrdersController.create()
   src/controllers/OrdersController.ts

3. OrderService.create()
   src/services/OrderService.ts

4. calculatePrice()
   src/services/pricing.ts

5. inventory.reserve()
   src/integrations/inventory.ts

6. ordersRepository.save()
   src/repositories/orders.ts

7. paymentQueue.publish()
   src/queues/payment.ts
</code></pre>
<p>This becomes far more useful than a generic explanation of the architecture.</p>
<p>Now you can ask questions such as:</p>
<ul>
<li><p>Where does the transaction actually begin?</p>
</li>
<li><p>What happens if payment publishing fails?</p>
</li>
<li><p>Is inventory reservation reversible?</p>
</li>
<li><p>Can the order be saved twice?</p>
</li>
<li><p>Which steps are synchronous?</p>
</li>
<li><p>Which failures are retried?</p>
</li>
</ul>
<p>Those are modernization questions.</p>
<h2 id="heading-how-to-separate-business-rules-from-infrastructure">How to Separate Business Rules from Infrastructure</h2>
<p>One of the most useful things you can do during codebase archaeology is identify where business behavior lives.</p>
<p>Legacy applications frequently mix it with infrastructure.</p>
<p>Consider:</p>
<pre><code class="language-typescript">async function saveCustomer(customer: Customer) {
  if (
    customer.type === "ENTERPRISE" &amp;&amp;
    customer.creditLimit &lt; 50000
  ) {
    throw new Error("Invalid enterprise credit limit");
  }

  const connection = await mysql.getConnection();

  await connection.query(
    "INSERT INTO customers (...) VALUES (...)",
    [...]
  );

  await redis.del(`customer:${customer.id}`);

  await eventBus.publish(
    "customer.updated",
    customer
  );
}
</code></pre>
<p>There's at least one business rule:</p>
<pre><code class="language-text">Enterprise customers must have a credit limit &gt;= 50000.
</code></pre>
<p>And several infrastructure concerns:</p>
<pre><code class="language-text">MySQL
Redis
Event bus
</code></pre>
<p>Ask AI to classify the code:</p>
<pre><code class="language-text">Classify each responsibility in this function as one of:

- business rule,
- application orchestration,
- persistence,
- caching,
- messaging,
- logging,
- validation,
- unknown.

Explain why.

Do not move or rewrite any code.
</code></pre>
<p>The <code>unknown</code> category is useful. You don't want the model to force every line into a clean architectural theory.</p>
<p>Some code really is ambiguous until you inspect more context.</p>
<h2 id="heading-how-to-find-hidden-side-effects">How to Find Hidden Side Effects</h2>
<p>Side effects are one of the biggest sources of migration risk.</p>
<p>A function called:</p>
<pre><code class="language-typescript">updateCustomer()
</code></pre>
<p>may do much more than update a customer.</p>
<p>It may:</p>
<ul>
<li><p>write to the database</p>
</li>
<li><p>invalidate cache</p>
</li>
<li><p>emit an event</p>
</li>
<li><p>send an email</p>
</li>
<li><p>update analytics</p>
</li>
<li><p>write an audit record</p>
</li>
<li><p>schedule another job</p>
</li>
</ul>
<p>If you refactor the function and preserve only its return value, you can break production behavior without any compiler error.</p>
<p>A useful investigation prompt is:</p>
<pre><code class="language-text">List every observable side effect produced directly
or indirectly by this function.

For each one, identify:

- the side effect,
- where it happens,
- whether it is synchronous or asynchronous,
- whether failure propagates,
- whether it appears retryable,
- whether it is idempotent,
- whether it can be safely repeated.

Mark uncertain answers as unknown.
</code></pre>
<p>That last property, idempotency, matters a lot.</p>
<p>Suppose a worker does this:</p>
<pre><code class="language-typescript">await chargeCard(order);
await markOrderAsPaid(order);
</code></pre>
<p>If the worker crashes between those two lines and retries, what happens? You may charge the customer twice. And that's not visible from the function name.</p>
<p>Understanding retry semantics is part of understanding the codebase.</p>
<h2 id="heading-how-to-discover-implicit-contracts">How to Discover Implicit Contracts</h2>
<p>Not every contract is declared with an interface. Legacy applications contain many implicit contracts.</p>
<p>For example:</p>
<pre><code class="language-typescript">return {
  status: "ok",
  value: customer.balance.toFixed(2),
};
</code></pre>
<p>Some external consumer may depend on:</p>
<pre><code class="language-json">{
  "status": "ok",
  "value": "100.00"
}
</code></pre>
<p>Changing <code>value</code> from a string to a number can look like an improvement:</p>
<pre><code class="language-json">{
  "status": "ok",
  "value": 100
}
</code></pre>
<p>It can also break a client.</p>
<p>Look for contracts in:</p>
<ul>
<li><p>API responses,</p>
</li>
<li><p>events,</p>
</li>
<li><p>database structures,</p>
</li>
<li><p>CSV exports,</p>
</li>
<li><p>filenames,</p>
</li>
<li><p>environment variables,</p>
</li>
<li><p>error messages,</p>
</li>
<li><p>queue payloads,</p>
</li>
<li><p>and webhook bodies.</p>
</li>
</ul>
<p>Ask:</p>
<pre><code class="language-text">Identify outputs from this module that could be consumed
outside the module.

Include:

- HTTP responses,
- emitted events,
- queue messages,
- files,
- database records,
- exceptions,
- logs used for automated processing.

For each output, explain what evidence suggests that it
may be an external or implicit contract.
</code></pre>
<p>The wording matters:</p>
<blockquote>
<p>what evidence suggests</p>
</blockquote>
<p>not:</p>
<blockquote>
<p>tell me which contracts exist</p>
</blockquote>
<p>because you may not be able to prove the consumer from the current repository.</p>
<h2 id="heading-how-to-use-ai-to-find-duplicated-business-rules">How to Use AI to Find Duplicated Business Rules</h2>
<p>Duplicated code is easy to detect. Duplicated <strong>business meaning</strong> is harder.</p>
<p>You may find:</p>
<pre><code class="language-typescript">if (customer.type === "PREMIUM") {
  discount = total * 0.1;
}
</code></pre>
<p>in one module.</p>
<p>And elsewhere:</p>
<pre><code class="language-typescript">if (account.plan === "GOLD") {
  price = price * 0.9;
}
</code></pre>
<p>Those might represent the same business rule, or they might not.</p>
<p>AI is useful for identifying candidates.</p>
<p>Ask:</p>
<pre><code class="language-text">Search the repository for business rules related to
customer discounts.

Group implementations that appear semantically related,
even if variable names differ.

For each group:

- list file locations,
- describe the apparent rule,
- highlight differences,
- do not assume the rules should be unified.
</code></pre>
<p>That final instruction is important.</p>
<p>Duplication is sometimes accidental.</p>
<p>Sometimes it represents two domains that evolved independently.</p>
<p>Don't let an AI assistant turn:</p>
<pre><code class="language-text">similar
</code></pre>
<p>into:</p>
<pre><code class="language-text">must be merged
</code></pre>
<p>without evidence.</p>
<h2 id="heading-how-to-build-a-lightweight-dependency-map">How to Build a Lightweight Dependency Map</h2>
<p>At some point, you need to understand which parts of the system depend on which others.</p>
<p>You don't need a perfect enterprise architecture diagram. A lightweight dependency map is enough to start.</p>
<p>For example:</p>
<pre><code class="language-text">Orders
 ├── Customers
 ├── Inventory
 ├── Payments
 ├── Notifications
 └── Database

Payments
 ├── Payment Provider
 ├── Audit
 └── Database
</code></pre>
<p>Ask AI to extract module-level dependencies:</p>
<pre><code class="language-text">Build a module dependency map from the repository.

Only include dependencies supported by imports,
constructor dependencies, explicit calls, or configuration.

Output:

Module A -&gt; Module B

For each dependency, provide at least one source file
that demonstrates it.

Do not infer dependencies from names alone.
</code></pre>
<p>You can then compare the result with automated tools.</p>
<p>For JavaScript or TypeScript projects, dependency analysis tools can help you find:</p>
<ul>
<li><p>circular dependencies</p>
</li>
<li><p>cross-module imports</p>
</li>
<li><p>high fan-in</p>
</li>
<li><p>high fan-out</p>
</li>
</ul>
<p>AI is useful for explaining why those dependencies may matter. Static analysis is better at proving that they exist.</p>
<p>Use both.</p>
<h2 id="heading-how-to-mark-what-you-still-do-not-understand">How to Mark What You Still Do Not Understand</h2>
<p>This is one of the most important parts of the process.</p>
<p>A useful system map doesn't only contain answers. It also contains uncertainty.</p>
<p>I like keeping an explicit list such as:</p>
<pre><code class="language-markdown">## Open Questions

- Why is the enterprise credit threshold 50,000?
- Is `ORDER_APPROVAL` consumed outside this repository?
- Can marketplace orders bypass inventory validation?
- Is `customer.balance` allowed to be negative?
- What process transitions PENDING orders to APPROVED?
- Is `legacy_customer_id` still used by another system?
</code></pre>
<p>You can ask AI to generate this list:</p>
<pre><code class="language-text">Based on everything analyzed so far, list the questions
that can't be answered safely from the repository.

Focus on questions that would matter during:

- refactoring,
- migration,
- schema changes,
- interface changes,
- removal of code.

Do not answer the questions.
</code></pre>
<p>I like this prompt because it does the opposite of what we normally ask AI to do. It asks the model to identify where it should <strong>not</strong> pretend to know.</p>
<p>A modernization plan should include those unknowns.</p>
<h2 id="heading-how-to-validate-ai-findings-against-the-system">How to Validate AI Findings Against the System</h2>
<p>AI-generated explanations can sound convincing even when they're incomplete. So every important finding should have another source of evidence.</p>
<p>I use a simple hierarchy.</p>
<h3 id="heading-repository-search">Repository Search</h3>
<p>If AI says a function is called only once, search for it.</p>
<pre><code class="language-bash">rg "approveOrder" .
</code></pre>
<h3 id="heading-tests">Tests</h3>
<p>Tests often reveal assumptions that implementation code doesn't explain.</p>
<p>Look for:</p>
<pre><code class="language-text">expected errors
special values
boundary cases
fixture data
historical behavior
</code></pre>
<h3 id="heading-database-schema">Database Schema</h3>
<p>The schema may reveal key things like:</p>
<ul>
<li><p>nullable fields</p>
</li>
<li><p>foreign keys</p>
</li>
<li><p>defaults</p>
</li>
<li><p>legacy columns</p>
</li>
<li><p>constraints</p>
</li>
<li><p>status values</p>
</li>
</ul>
<h3 id="heading-logs-and-observability">Logs and Observability</h3>
<p>Production telemetry can tell you whether a supposedly unused path is still active.</p>
<h3 id="heading-version-history">Version History</h3>
<p>Git history can sometimes answer questions that source code can't.</p>
<p>For example:</p>
<pre><code class="language-bash">git log -S "Manual verification required" --all
</code></pre>
<p>or:</p>
<pre><code class="language-bash">git blame src/orders/approveOrder.ts
</code></pre>
<p>The commit that introduced a strange condition may contain the explanation.</p>
<p>This is an area where AI can help summarize history:</p>
<pre><code class="language-text">Review the commits that changed this function.

Build a timeline of behavior changes.

For each change, include:

- commit,
- date,
- behavior changed,
- stated reason if available.

Do not infer a reason if the commit history does not provide one.
</code></pre>
<p>That can save a surprising amount of time.</p>
<h2 id="heading-how-to-turn-codebase-understanding-into-a-migration-plan">How to Turn Codebase Understanding into a Migration Plan</h2>
<p>Once you understand one capability, you can begin making decisions. But not before.</p>
<p>Suppose your investigation produces this:</p>
<pre><code class="language-text">Create Order

Business rules:
- active customer required
- premium customers receive 10% discount
- inventory must be available

Side effects:
- order persisted
- inventory reserved
- payment queued
- confirmation email sent

External contracts:
- POST /orders response
- payment queue payload
- order.created event

Unknowns:
- retry semantics for inventory reservation
- whether event consumers require exact field names
</code></pre>
<p>Now you can decide what to protect.</p>
<p>For example:</p>
<pre><code class="language-text">Protect first:
- pricing behavior
- API response
- payment payload
- event schema
</code></pre>
<p>Then decide what can be refactored.</p>
<pre><code class="language-text">Candidate boundaries:
- pricing policy
- inventory gateway
- payment publisher
- notification service
</code></pre>
<p>Then decide what needs investigation.</p>
<pre><code class="language-text">Block migration until understood:
- inventory retry behavior
- event consumers
</code></pre>
<p>That's already a migration plan.</p>
<p>Notice what AI did not do: it didn't decide the target architecture.</p>
<p>It helped make the current architecture observable enough for you to make that decision.</p>
<h2 id="heading-a-practical-codebase-archaeology-workflow">A Practical Codebase Archaeology Workflow</h2>
<p>If I had to reduce this process to something repeatable, I would use these steps.</p>
<h3 id="heading-1-map-the-repository">1. Map the Repository</h3>
<p>Identify:</p>
<ul>
<li><p>entry points</p>
</li>
<li><p>modules</p>
</li>
<li><p>persistence</p>
</li>
<li><p>integrations</p>
</li>
<li><p>workers</p>
</li>
<li><p>jobs</p>
</li>
<li><p>tests</p>
</li>
<li><p>configuration</p>
</li>
</ul>
<p>Don't refactor anything.</p>
<h3 id="heading-2-choose-one-capability">2. Choose One Capability</h3>
<p>Pick something concrete:</p>
<pre><code class="language-text">Create Order
Approve Loan
Generate Invoice
Register Customer
Cancel Subscription
</code></pre>
<p>Avoid trying to understand the whole product at once.</p>
<h3 id="heading-3-trace-it-end-to-end">3. Trace It End to End</h3>
<p>Follow:</p>
<pre><code class="language-text">input
↓
business logic
↓
state changes
↓
external calls
↓
output
</code></pre>
<p>Record every file involved.</p>
<h3 id="heading-4-extract-business-rules">4. Extract Business Rules</h3>
<p>Separate:</p>
<ul>
<li><p>explicit rules</p>
</li>
<li><p>likely rules</p>
</li>
<li><p>infrastructure behavior</p>
</li>
<li><p>unknowns</p>
</li>
</ul>
<h3 id="heading-5-identify-side-effects">5. Identify Side Effects</h3>
<p>Find:</p>
<ul>
<li><p>writes</p>
</li>
<li><p>messages</p>
</li>
<li><p>emails</p>
</li>
<li><p>jobs</p>
</li>
<li><p>cache changes</p>
</li>
<li><p>external calls</p>
</li>
</ul>
<h3 id="heading-6-discover-contracts">6. Discover Contracts</h3>
<p>Look for:</p>
<ul>
<li><p>APIs</p>
</li>
<li><p>event schemas</p>
</li>
<li><p>database assumptions</p>
</li>
<li><p>exported files</p>
</li>
<li><p>error behavior</p>
</li>
</ul>
<h3 id="heading-7-map-dependencies">7. Map Dependencies</h3>
<p>Document:</p>
<pre><code class="language-text">module -&gt; module
</code></pre>
<p>and identify coupling.</p>
<h3 id="heading-8-record-unknowns">8. Record Unknowns</h3>
<p>Don't hide uncertainty. Create an explicit list.</p>
<h3 id="heading-9-verify">9. Verify</h3>
<p>Use:</p>
<ul>
<li><p>repository search</p>
</li>
<li><p>tests</p>
</li>
<li><p>schema</p>
</li>
<li><p>logs</p>
</li>
<li><p>Git history</p>
</li>
<li><p>production telemetry</p>
</li>
</ul>
<h3 id="heading-10-only-then-plan-the-change">10. Only Then Plan the Change</h3>
<p>Decide:</p>
<ul>
<li><p>what behavior must survive,</p>
</li>
<li><p>what code can disappear,</p>
</li>
<li><p>what boundaries should be introduced,</p>
</li>
<li><p>what needs tests,</p>
</li>
<li><p>and what can migrate first.</p>
</li>
</ul>
<h2 id="heading-what-i-would-not-ask-ai-to-do-first">What I Would Not Ask AI to Do First</h2>
<p>There are several prompts I avoid at the beginning of a legacy modernization project.</p>
<p>For example:</p>
<pre><code class="language-text">Rewrite this application using Clean Architecture.
</code></pre>
<p>or:</p>
<pre><code class="language-text">Convert this monolith into microservices.
</code></pre>
<p>or:</p>
<pre><code class="language-text">Modernize this entire repository.
</code></pre>
<p>or even:</p>
<pre><code class="language-text">Find all the bad code.
</code></pre>
<p>The problem isn't that AI can't produce useful output from those prompts. It can.</p>
<p>The problem is that those questions already contain a solution.</p>
<p>You're asking for:</p>
<pre><code class="language-text">Clean Architecture
Microservices
Rewrite
Bad code
</code></pre>
<p>before you've established what the system actually needs.</p>
<p>A better sequence is:</p>
<pre><code class="language-text">What exists?
↓
Why does it exist?
↓
What behavior matters?
↓
What is uncertain?
↓
What should change?
</code></pre>
<p>That sequence is slower for the first hour, but it's usually much faster for the rest of the project.</p>
<h2 id="heading-the-most-useful-ai-output-is-sometimes-a-question">The Most Useful AI Output Is Sometimes a Question</h2>
<p>There's a tendency to evaluate AI coding tools by how much code they generate.</p>
<p>For legacy systems, I think that misses part of their value.</p>
<p>One of the most useful outputs can be:</p>
<blockquote>
<p>I cannot determine why this condition exists from the available code.</p>
</blockquote>
<p>Or:</p>
<blockquote>
<p>This event appears to have no consumer in the current repository, but external consumers cannot be ruled out.</p>
</blockquote>
<p>Or:</p>
<blockquote>
<p>These two discount calculations look similar, but their behavior differs for zero-value orders.</p>
</blockquote>
<p>Those are useful findings that tell an engineer where to investigate.</p>
<p>A confident but incorrect answer is much more dangerous.</p>
<p>When working with legacy systems, uncertainty is information. Treat it that way.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>AI makes unfamiliar codebases much easier to explore.</p>
<p>You can use it to summarize modules, trace execution paths, extract candidate business rules, find side effects, compare implementations, analyze Git history, and build dependency maps.</p>
<p>That can remove a large amount of mechanical investigation work.</p>
<p>But understanding a system isn't the same as generating an explanation of it. Legacy applications contain context that may exist outside the source code:</p>
<ul>
<li><p>production behavior,</p>
</li>
<li><p>old incidents,</p>
</li>
<li><p>external consumers,</p>
</li>
<li><p>business exceptions,</p>
</li>
<li><p>undocumented integrations,</p>
</li>
<li><p>and organizational history.</p>
</li>
</ul>
<p>AI can help you find evidence. It can't manufacture missing history.</p>
<p>That's why I prefer to use it as an investigator before I use it as a transformer.</p>
<p>Start with:</p>
<pre><code class="language-text">What does this system actually do?
</code></pre>
<p>Then ask:</p>
<pre><code class="language-text">What do I still not understand?
</code></pre>
<p>Only after that should you ask:</p>
<pre><code class="language-text">What should I change?
</code></pre>
<p>The faster AI lets you modify software, the more important that sequence becomes.</p>
<p>Because changing code you understand is engineering. But changing code you don't understand is experimentation.</p>
<p>And production is usually the most expensive place to run that experiment.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
