Back to Home

Safe Module Refactoring: Preserve Behavior | IT Practices

The article reveals a strategy for safely extracting modules from monolithic systems. It describes principles of preserving behavior, using feature flags and testing to prevent regressions. Emphasis on minimizing changes in the first stage.

Refactoring Without Failures: Guide to Extracting Modules
Advertisement 728x90

# Refactoring Without Risks: How to Extract Modules While Preserving Behavior

When moving a module from a monolith to a separate service, the main goal is to preserve the behavior exactly as is, including known bugs and undocumented quirks. Attempts to "clean up" the code at this stage often lead to regressions. We'll show you how to sidestep the pitfalls using a strategy of minimal changes.

Why the 'Boring' Approach Saves You from Regressions

Production systems have an implicit contract shaped by years of operation: client integrations, error handling, timing delays, and adaptations to external APIs. This contract isn't documented—it's buried in logs, commits, and team experience. Refactoring shouldn't alter behavior, even if it includes bugs. For example, if the old code returns an empty string instead of null, changing that will break clients that have already adapted to this logic. Key principle: extraction changes the form but preserves the semantics. Any deviation from the original behavior turns refactoring into a release with unpredictable consequences.

One Type of Change at a Time: Boundaries Before Internal Structure

Break tasks into stages:

Google AdInline article slot
  • Stage A: Extract the module while preserving the contract (HTTP, queue, bus) and introduce a feature flag.
  • Stage B: Update the stack, libraries, or code style.
  • Stage C: Fix bugs.
  • Stage D: Optimize performance.

Mixing stages in a single PR is a surefire way to unreadable diffs, rollback headaches, and arguments over "is this a bug or a new feature?" In the first stage, wrap the old code: new service boundary, old logic inside. For example, when extracting a routing module, keep the original route() function but wrap it in an HTTP endpoint. This ensures identical behavior, even if the code looks "ugly."

Feature Flag: Your Emergency Kill Switch

Running old and new code in parallel in production requires a switch controlled via config or env variables. This provides:

  • Instant rollback without redeploying during an incident.
  • Gradual rollout to a portion of traffic (canary release).
  • Long-term retention of the old implementation for behavior comparison.

Critically, the switch must work without restarting the service. Otherwise, an off-hours incident turns into an emergency call instead of a simple config tweak. Implement the flag at the business logic level, not infrastructure—to avoid complex dependencies on orchestrators.

Google AdInline article slot

Copy the Behavior, Don't Rewrite It

Guideline for safe extraction: copy files "as is," changing only what's needed for the build. Real-world example—moving routing from an actor to a pure function:

object UpdateRouting {
  def route(update: Update): Either[RoutingError, RouteTarget] =
    if (update.message.exists(_.chat.isGroupOrSupergroup)) Right(GroupFlow)
    else if (update.callbackQuery.isDefined) Right(CallbackFlow)
    else Right(PrivateFlow)
}

When mapping models, preserve even subtle details. If the old code converted a missing channelId to an empty string, do the same:

def toBusMessage(in: PlatformInMessage): BusIncoming =
  BusIncoming(
    correlationId = newCorrelationId(),
    channelId = in.channelId.getOrElse("")
  )

Any semantic change (like swapping an empty string for null) breaks the contract with other services. Your goal: bit-for-bit identical behavior, even if the code looks dated.

Google AdInline article slot

What to Absolutely Avoid During Extraction

While the feature flag is active, steer clear of:

  • New libraries. Each adds transitive dependencies and attack surface. Stack updates are a separate task.
  • Code style changes. Mixing old domain code with new wrappers is the temporary cost of safety.
  • Fixing discovered bugs. Log a ticket and address after stabilization. Otherwise, every incident gets blamed on the refactor.
  • 'While-we're-in-the-file' optimizations. Profiling and speedup are a separate cycle.

These restrictions might seem excessive, but six months later in git blame, you'll see a clear history: "extracted module, hooked up the bus," not "rewrote everything."

Tests: Locking Down the Contract, Not KPIs

Unit tests on pure functions (routing, mapping) should capture behavior before extraction. A green test post-extraction guarantees unchanged logic. For integration scenarios (network, queues, DB), use E2E tests, but keep their limits in mind:

  • Mocks of external systems can perpetuate faulty assumptions from the code.
  • Real APIs often have subtle quirks (like a /api/ prefix) not covered in tests.

Always include a manual run on a production-like staging environment. Automation can't replace checks in near-real conditions.

Code Growth Isn't a Bug, It's a Process Stage

After the first stage, code volume often swells: adapters, feature flags, monolith duplication. That's normal—you're paying for safe rollbacks and staged rollouts. Cleanup and shrinkage come in stage two, once the boundary is stable, old code is gone, and tests confirm correctness. Don't optimize upfront—ensure it works first.

Key Takeaways

  • Extraction goal—transfer behavior "as is," not comprehensive improvement.
  • Feature flag is mandatory for safe rollback without deploy.
  • Tests lock the contract before extraction starts, not after.
  • Ban parallel changes to stack, bug fixes, and optimizations.
  • Code can grow—it's part of the strategy, not a mistake.

Refactoring legacy code isn't the place for experiments. The more boring the process, the less likely a 3 a.m. wakeup from production downtime. Follow these rules to turn a risky extraction into a predictable operation.

— Editorial Team

Advertisement 728x90

Read Next