← All posts

Refactoring without the rewrite: how to decompose complex code

How to tell refactoring, decomposition, and a rewrite apart, pin the behavior of legacy code, and change structure in steps you can roll back.

On a dark background, a tangled knot of links on the left and four separate glowing blocks on the right. A continuous line underneath shows the system keeps running
Contents

Friday, six in the evening. The quote module needs a discount change for one customer. The file is eight hundred lines. Next to the price formula it checks permissions, queries the database, emails a manager, and builds the browser response. You edit three lines. The happy-path checks stay green. On Monday accounting sees someone else's discount on a different contract.

You do not gut a kitchen while dinner is on the stove. You move one cabinet, label the shelves, and leave the burners on. Code wants the same discipline. Refactoring moves the interior and leaves what the user sees alone. Decomposition decides which shelf owns which job. A rewrite moves the household into a hotel for a year, and the old kitchen turns out to have held rules that were never written down.

What follows is how to tell those three jobs apart, where to draw a boundary, and how to walk one overloaded module without stopping the system. Fowler's named moves are unpacked in the Refactoring digest. This piece is about the decisions: when a function is enough, when you need a module, when a service is justified, and where an agent helps without taking over the meaning.

A knot of tangled lines separates into four clear blocks. Overview of decomposing code without rewriting the system.

Key takeaways

Refactoring, decomposition, and rewriting are three different bets. The first keeps observable behavior and changes shape. The second draws responsibility boundaries. The third changes the model or the technology and has to prove the old rules survived.

A long file proves nothing by itself. The pain is a business-rule change that also touches permissions, storage, mail, and the response shape. A rare scenario can sit in a long local block that people open once a year and still be fine.

Pin what the system actually does, then move the walls. When the requirements are gone, characterization tests record current behavior, historical oddities included. A defect and "it has always worked this way" get separated before any code moves.

Put the boundary around the direction of change, not around technical folders. A module is strong when its responsibilities change together, and weak when every neighbor reaches inside. Splitting into services makes sense only after that boundary exists.

A small checked step keeps the system alive. One logical move, a test run, a commit for structure and a different commit for behavior. An agent can do the mechanics. Equivalence and the meaning of the rule stay with an engineer.

Three jobs that get mixed in one pull request

On the left, a part moves inside a cube while the outside keeps working. On the right, a wrecking ball knocks the structure down.

A pull request often contains everything at once: renamed fields, a "while I was here" change to discount order, and half the file moved into a new directory. The reviewer cannot see where shape ends and the customer's money begins. The kitchen version is moving a cabinet, rewriting the stew recipe, and ordering a new stove on the same Saturday. Guests will not know which change ruined dinner.

Refactoring keeps the outside still

Refactoring changes internal structure while observable behavior stays put. The user scenario, the business result, and the answers for the same inputs remain. That is stricter than a cleanup. A cosmetic rename with no boundary and no check hides a formula drift easily.

The practical test is dull. After a structural commit, the characterization suite returns the same results as before. If a result moved, you are in a different job, whatever the commit message says about tidiness.

The catalog of moves is a shared team vocabulary. Extract function, introduce parameter object, encapsulate variable: names for motions, not taste. The two hats, the smells, and the steps are covered in the Fowler digest. The consequence that matters here: at any moment you are either moving walls or changing the recipe.

Decomposition names who owns what

Decomposition answers who owns what. You separate responsibilities, name the boundary, and decide what outsiders may see. A physical split into files, packages, or services can follow that boundary. It does not have to. Two types in one file with a narrow call between them are already a decomposition if neighbors cannot poke internal fields.

The confusion starts when "spread it across folders" is treated as architecture. Folders follow technical kinds: controllers, services, repositories. The discount rule is smeared across three folders and edited in three places. The cut followed the file type, not the reason the code changes.

When structure is no longer enough

Refactoring runs out in four situations.

The domain model is wrong. A discount, a cap, and a tax sit as three unrelated numbers, and no name in the code matches what commercial people say.

The architecture no longer matches the load. A module that priced one quote a minute now prices storefront batches, and a synchronous email inside the calculation stalls checkout.

The technology has a ceiling you cannot remove by rearranging functions. That conversation is about replacing a component, not about a prettier method.

Fourth: the rules are so tangled with accidental storage details that a local move only relocates the knot. Then a gradual redesign of one contour is justified. Replacing the whole system is rarely justified, and only for a piece with a clear boundary and a reconciliation plan. At enterprise scale that path is the strangler playbook.

A comparison of refactoring, decomposition, and a component rewrite: what changes, whether behavior stays, how you know the work is done, and the typical failure.

Question Refactoring Decomposition Rewriting a component
What changes The shape of the code Responsibility boundaries The model or the technology
Behavior The same The same, until you decide otherwise New, and it must be reconciled with the old
Done when Checks return the same results A neighbor calls a narrow contract Divergences are classified before cutover
Typical failure The formula moved in the same commit Folders instead of boundaries A year of two truths living in parallel

A long file is not a diagnosis

A glass cube with a knot inside and four labels: an overloaded module, a rule split across the system, logic reaching into foreign data, and a contract made of bare numbers.

Someone brings a static-analysis report: a four-hundred-line method, cyclomatic complexity over the threshold, duplication in two services. The report is a map. It becomes a diagnosis only together with the question of what hurts to change this week.

Symptoms the person editing can feel

A large function that checks permissions, calculates, writes to the database, and sends mail. A business rule duplicated until the copies drift: the discount ceiling lives in quoting and again in the nightly report, a percent apart. A conditional chain nobody wants to extend because neighboring branches depend on check order. A dependency cycle: pricing imports billing, billing imports pricing. One product change forces twelve files open. A small edit produces a side effect somewhere you did not touch.

Any one of these is tolerable alone. Together they make the cost of the next change unpredictable. That unpredictability is the tax deferred structure charges. What a surprise costs in production is covered in the economics of failure.

Smells worth naming

Names let a review talk about structure instead of taste.

God module. One type owns transport, rules, storage, and mail. Every task passes through it, and it knows too much about its neighbors.

Shotgun surgery. One business change needs simultaneous edits in distant parts of the system. The rule does not live in one place. Fowler's name is Shotgun Surgery.

Feature envy. Module A's logic keeps reading B's internals and barely uses its own. The behavior wants to sit next to B's data. The name is Feature Envy.

Primitive obsession. A contract, money, and a period are a scatter of strings and numbers with no type of their own. Checks for "this is an id, not an amount" spread to every caller. The name is Primitive Obsession.

Leaky abstraction. Outsiders were promised "price a quote", and what leaks out is table layout and driver error codes. Neighbors start depending on details that should have stayed inside.

Coupling here is simple: modules know more about each other than the call requires. Cohesion is the other side. Inside a module, the responsibilities either change for the same reason or they are accidental roommates in one file.

When a compromise is the sound choice

A long file that holds one migration scenario, run once a year, can be more honest than a dozen tiny types. Two similar formulas are sometimes cheaper than a shared abstraction: next quarter the rules diverge, and the shared method grows flags. A three-branch conditional is better than a premature class hierarchy. A universal framework "for every future sales channel" usually encodes a channel that does not exist yet.

The decision sits on three questions. How often does this place change? What does an error cost? What does the shape you want to introduce today cost to maintain? Rare code with a low error cost can stay obvious and even duplicated. A frequent rule with a high error cost deserves a name, a type, and its own check.

Record how the system behaves before you move a wall

A data stream passes through a block. Four moves: trace the path, record actual behavior, lock the seam with a check, and keep a behavior change out of a structural move.

Moving a cabinet with the burners on starts with an inventory. Which pots are actually on the heat, and which ones exist only on an old shopping list. Code is the same. Until you know the real path from input to result, a "cleaner" structure can throw away the exception a report depends on.

Walk the data path

Take one live scenario, preferably the one that hurts. From the entry point (request, queue message, nightly job) follow it to the write, the response, and the side effects. Mark external dependencies: database, mail, payment gateway, neighboring module. Write down rules that are not in the wiki: "a gold customer gets the discount only if the total clears the threshold and no promo is applied." Sketch who calls whom. A one-page map beats a whole-system diagram nobody updates.

Along the way you find places where the module knows too much. A permission check inside the price formula. An email body assembled from table columns. Those are future seams. You name them. You do not cut them in the same hour you found them.

Characterization tests hold the old behavior

Michael Feathers, in Working Effectively with Legacy Code, described the move for a system whose requirements are gone. A characterization test records what the code does now, not what you believe it should do. You feed an input, record the output, and lock the pair. After a structural step the pair must still match.

Write those checks on a seam you can actually grab. If the module cannot price anything without a database and a mailer, the first step is often not "cover everything with unit tests." It is extracting a pure formula so you can call it with numbers. Until the formula is separate, a characterization test can be a thin slice through the entry point: one order, one discount, one missing customer, one boundary zero.

Then a fork: defect, or historical behavior? If last year's report balanced on a strange rounding, "fixing it while we are here" breaks reconciliation. Lock the oddity in a test and open a separate task to change behavior. A behavior change is the other hat and another commit.

Metrics that answer the cost of change

Test coverage shows which branches you have already pinned. It does not show whether a new rule is easy to add. Look beside it at what the team actually feels.

How many modules a typical edit opens. How deep the conditionals go in the hot function. How long a typical change takes from ticket to verification. How often a small edit brings a regression. How painful the check is by hand when no automated check exists.

Cyclomatic complexity and method length stay hints, not goals. Refactoring so the analyzer goes quiet easily produces three-line functions and no new seam. The goal is narrower: the next change to the same rule touches fewer places and is faster to check.

Draw the boundary where the code changes together

On the left, a discount rule is cut across technical layers. On the right, three separate blocks: discount, tax, and limit.

Single responsibility is useful as a question, not as a quota of one method per class. Ask why this piece changes. If two reasons arrive from different people in different weeks, the piece is holding two roles. If the reasons always arrive together, cutting is early.

For an application module the roles are usually these. Transport accepts the request and returns the response. The application scenario sequences one case. Business rules compute money and prohibitions. Data access reads and writes storage. Integrations send mail and call other services. When one function plays three of those roles, it is asking for a seam even if the file is still short.

Group by the direction of change

Folders by technical type are fine while the rules are few. Once discount, tax, and cap start living their own life, gather the code around those rules. Inside that module cohesion is high: types, checks, and formulas move in one task. Between modules coupling is low: a neighbor passes an already computed result or asks for a named operation, and does not read your table.

A practical test of the boundary. Name a change that will arrive next quarter. If you touch one module and its public contract stays stable, the boundary is in the right place. If you hunt the same constant across four directories, there is no boundary, however many interfaces you added.

From function to module, without skipping a step

A staircase of five steps: pure functions, domain types, modules, a modular monolith, and a separate service.

The order is usually this, and skipping is expensive.

Pure functions first: numbers and decisions in, money and rejection reasons out, no database and no network. Then extract methods from the long procedure so the names match the rules. Then a domain type, if several functions guard the same invariants. Then an explicit contract for neighbors. Storage details and mail do not stick out past that contract.

Add an interface when there is a second implementation, or a hard seam you need to substitute in a test. An interface for every class, with no second implementation and no test seam, adds files and does not reduce coupling.

Principles without the dogma

SOLID and its neighbors are a vocabulary of constraints. Single responsibility asks about the reason for change. Open/closed warns that a new discount branch should not rewrite the old branches when the rules really are independent. Dependency inversion reminds you that pricing should not open a database connection itself. The connection arrives from outside.

Composition over inheritance avoids the hierarchy "gold customer, gold customer with a promo, gold customer with a promo and a manual discount." A sequence of small rules is simpler than a class tree for every combination.

Patterns from the Gang of Four digest belong once the code already has a repeated pain the pattern names. Replace conditional with polymorphism when the branches stably mean different behaviors and keep multiplying. Three branches in one function do not need polymorphism.

What hurts Nearest seam Do not do this the same day
A long function with one reason to change Extract functions beside it A new service and a shared library
Several rules change together and outsiders call them A module with a narrow contract An interface on every internal type
Two implementations, or you need a test seam A contract and a substitute A hierarchy for every future variant
Contracts exist, release and failure are still shared A modular monolith, block stray imports A network between modules "for later"
Different release cadence, scale, or team A service at that boundary Slice the whole application first

A step you can roll back in a minute

A ring of five steps: name the pain, lock behavior, one structural move, run the checks, and commit structure only.

A good move fits in your head and in the diff. If you cannot say which behavior must stay the same, the step is too big.

Basic moves

Extract Function lifts a chunk and names the rule. Inline Function puts back a wrapper that hides nothing and only slows reading. Renames and signature changes make the intent visible: total becomes totalAfterDiscounts. Introduce Parameter Object gathers arguments that always travel together into one type. Encapsulate Variable hides a mutable field so neighbors stop writing it behind the rule's back. Bare thresholds (1000, 0.9) get names when the meaning is not obvious at the call site.

Leave Replace Conditional with Polymorphism for later. The conditional has to settle into real variants first, not a temporary list of promotions.

Duplicated rules and duplicated text

Look for repeated meaning, not repeated lines. Two functions price the gold-customer discount and already disagree on rounding. That is one rule and it should live in one place. Two functions look alike in their loops, but one prices a quote and the other a warehouse reservation. A universal calculator with a dozen flags glues different meanings together. Next month a flag added for the warehouse changes the quote price.

Keep independent implementations until you can name the shared rule in one sentence without "and" and without a flag. If the sentence will not form, there is no shared rule.

Make dependencies explicit

An adapter hides a foreign protocol behind your domain's words: not "call the mail SDK," but "say the quote is ready." A facade packs a handful of internal calls into one operation for a neighbor who does not need the intermediate steps. A global connection, a global user, and a global mailer become arguments. A characterization test can then pass memory instead of a database and collect emails in a list instead of a real inbox.

Side effects that used to happen "somewhere inside" become lines of the scenario: check permission, price, store, notify. The order is visible. You can change it in a separate behavior commit when the business actually asks.

An order that does not mix the hats

A check that fails if behavior drifts comes first. Then one structural move. Then a run. Then a commit whose message does not mention a new feature. Behavior, if you need it at all, arrives in the next commit, and that diff stays short.

Reverting that commit restores the previous shape, not a blend of three tasks. The branch looks boring. Boring is how the stove stays on.

A cycle you can paste into the ticket:

  1. Name a concrete pain, not "clean up the module."
  2. Find the pieces that change together.
  3. Trace external contracts and side effects.
  4. Lock current behavior with tests on that seam.
  5. Pick the smallest boundary you can verify today.
  6. Make one structural move.
  7. Run tests and static checks.
  8. Look for an effect in a neighboring scenario.
  9. Repeat until a typical edit is local.
  10. Write down the decision: which boundary is now public, and what you will not cut next time.

A teardown loses to rules that were never written down

Input splits into the old path that reaches the user and a shadow path that writes divergences to a log.

Rewriting a module from a blank page feels right when the file is embarrassing to open. Embarrassment is a bad client. The old code holds exceptions one person in commercial remembers: a zero price for an internal transfer, a discount that must not stack with a promo, a mail that must reach the regional owner rather than the customer. While the new version is being written, the old one keeps growing those exceptions. At cutover you are no longer comparing two implementations of one formula. You are comparing two worlds.

The second cost is a double life. You cannot switch the old version off until the new one matches on real cases. The team pays for both. Divergences show up late if reconciliation was postponed until "after launch."

The drawing that wins is different. Take a limited slice with a clear result, for example the total only, without mail and without permissions. Isolate the old implementation behind a contract so neighbors stop calling its guts. Move responsibility in small pieces. Keep the old response compatible until consumers have moved. Delete old code when nothing calls it, not when "the new architecture is basically ready."

Gradual replacement, Strangler Fig in the literature, looks like this at module scale. The new calculation sits beside the old one. Calls go through a fork: some requests take the old path, some the new, and the answers are compared. A divergence is logged with the quote id. It is not patched by eye on Friday. When divergences are understood, and each one is either a defect or an accepted rule change, the fork goes away. Where this drawing is mandatory for ERP contours, and where it does not fit, see the strangler playbook. On a single module it does not fit when you cannot compare results and cannot roll the fork back.

Three safeguards matter for a critical calculation. Dual computation without dual write: both implementations calculate, the old one still writes, and the difference is visible in observation. A shadow run: the new path executes on a copy of the input and does not affect the user's response. A feature switch: the new path takes a fraction of traffic and turns off without a deploy if errors climb. Name the rollback before you enable the path, not during the incident review.

A service comes after the boundary

Good decomposition often ends in a modular monolith. One application, one process, one release. Inside it, modules with public contracts, a ban on importing another module's internals, and tables that outsiders do not read directly. That is both a stop on the way to services and a finished architecture when the team does not have separate release cycles. Why a monolith swells when boundaries are missing is covered in why ERP systems become monoliths. How web application layers tend to evolve is in the architecture history.

Extract a service when several conditions coincide. The life cycle and the scale really differ: quote pricing must survive a storefront spike, and mail can wait. Failure requirements differ: a dead mailer must not take down checkout. A team releases this contour on its own and will keep the contract. The network, retries, timeouts, and partial failure become part of the work, not a surprise after the cut.

The price of a distributed layout is concrete. The network drops packets and adds delay. A transaction that used to be one database commit splits across two stores and a consistency question. The contract needs versions, because the neighbor cannot be updated in the same second. Observation, logs, and incident review cost more than a stack trace in one process.

The decision order stays short. First a logical boundary and a contract inside one process. Then a measurement: does a shared release actually hurt? Only then the network. Cutting along folders "because that is what people do" moves the same mess into two repositories and adds timeouts.

A worked example: the quote module

Imagine a module that does everything at once. It reads the request body. It checks the quote:create permission. It loads the customer. It sums the lines. It applies the gold-tier discount and a promo code. It refuses to let the total go below zero. It stores the quote. It sends mail. It returns a string with two decimal places.

The starting shape in TypeScript is deliberately flat. This is the seam before the work.

export async function createQuote(req, db, mailer) {
  const user = req.user;
  if (!user?.permissions.includes('quote:create')) {
    throw new Error('forbidden');
  }

  const customer = await db.query(
    'select id, tier, email from customers where id = $1',
    [req.body.customerId],
  );
  if (!customer.rows[0]) throw new Error('customer');

  let total = 0;
  for (const line of req.body.lines) {
    total += line.qty * line.unitPrice;
  }
  if (customer.rows[0].tier === 'gold' && total > 1000) total *= 0.9;
  if (req.body.promo === 'SPRING' && total > 500) total *= 0.95;
  if (total < 0) total = 0;

  await db.query('insert into quotes(customer_id, total) values ($1, $2)', [
    customer.rows[0].id,
    total,
  ]);
  await mailer.send(customer.rows[0].email, `Quote ${total}`);
  return { status: 200, body: { total: total.toFixed(2) } };
}

Step 1. Name the roles

Five roles sit here: transport and permissions, the "create a quote" scenario, price rules, the database write, and notification. While they share one function, any discount edit risks the email and the SQL.

Step 2. Lock the behavior

Characterization cases worth recording before the formulas move: a caller without permission; an unknown customer; a line total with no discount; gold tier above the threshold; a promo above its own threshold; both rules at once; zero and a negative intermediate total if the input can produce one. Take the expected numbers from the current code, not from a new "more correct" rounding.

Step 3. Lift the rules without changing the formulas

The pure function does not know about the request or the mailer. Thresholds and rates stay exactly as they were. The names say the rule.

const GOLD_MIN = 1000;
const GOLD_RATE = 0.9;
const PROMO_MIN = 500;
const PROMO_RATE = 0.95;

export function priceQuote(input: {
  linesTotal: number;
  tier: string;
  promo?: string;
}): number {
  let total = input.linesTotal;
  if (input.tier === 'gold' && total > GOLD_MIN) total *= GOLD_RATE;
  if (input.promo === 'SPRING' && total > PROMO_MIN) total *= PROMO_RATE;
  if (total < 0) total = 0;
  return total;
}

Do not improve the formulas in this commit. If the gold discount and the promo must not multiply, that is a behavior change and a separate task with a report reconciliation.

Step 4. Isolate the outside

Storage and notification become contracts. Tests substitute lists. Production still uses the old SQL and the old mailer behind them.

export interface QuoteStore {
  findCustomer(id: string): Promise<{ id: string; tier: string; email: string } | null>;
  insertQuote(customerId: string, total: number): Promise<void>;
}

export interface QuoteNotifier {
  quoteReady(email: string, total: number): Promise<void>;
}

Step 5. Leave one scenario on the outside

The application function orchestrates the roles. Transport only pulls the user and the body, checks the permission, and maps errors to response codes. Neighbors no longer see the 1000 threshold.

export async function createQuote(
  input: { customerId: string; linesTotal: number; promo?: string },
  store: QuoteStore,
  notifier: QuoteNotifier,
): Promise<number> {
  const customer = await store.findCustomer(input.customerId);
  if (!customer) throw new Error('customer');
  const total = priceQuote({
    linesTotal: input.linesTotal,
    tier: customer.tier,
    promo: input.promo,
  });
  await store.insertQuote(customer.id, total);
  await notifier.quoteReady(customer.email, total);
  return total;
}

Step 6. Move call sites and compare

For a while the old createQuote is a thin shell over the new scenario. Compare responses on the characterization cases before you delete the shell. If something in the system called the function for the side-effect email without a write, it shows up here, not after the release.

Step 7. Ask whether the next change got cheaper

Do not count lines. Count how many files a gold-threshold edit opens (expect one rules module). Ask how easily you can check the rule without a database. Look at the public entrance: an id, a line total, an optional promo, a number out. Time the next similar edit.

If the threshold is still hardcoded in the nightly reconciliation report, decomposing the quote module is not finished. The rule still has a second life. That is the next turn of the same cycle, not a reason to create a microservice.

Where the rework costs more than the code

Four expensive mistakes: a shared calculator, a framework for a future that is not here, functions cut too fine, and a behavior change hidden in a structural commit.

A large chunk with no intermediate checks. You can no longer revert it: the diff holds renames and a new formula.

An abstraction for a sales channel that is not on this year's list. The framework outruns the task and becomes the code people are afraid to change.

An interface on every class. Navigation gets longer, behavior stays, and there is no seam to substitute.

A shared method for rules that mean different things. Flags inside a "universal" function bring back the complexity you left, in a worse form: the name no longer shows it.

Functions and files cut too fine. Reading a scenario becomes a tour of twenty jumps. A seam should match a rule, not every line.

An attempt to install Clean Architecture or domain-driven design across the whole system at once. Layers appear where there is not yet one stable rule. Book names do not replace a map of one scenario.

Structure and behavior in one commit. Review misses a rounding shift behind a file move.

Refactoring for an analyzer score. Complexity is green, and editing the discount still hurts.

Work without knowing the callers. A deleted "dead" argument turns out to be part of a nightly job's external contract.

An agent moves text. A person keeps the meaning

On the left, a mechanism moves pieces of code. On the right, a person holds the meaning of the rule and the external contract.

An agent earns its place where the work is mechanical and the result can be checked independently. It holds rules poorly when they are written neither in the code nor in a test.

High return. Walk the import graph and show the cycles. Find every copy of a discount threshold in the repository. Draft characterization tests from current inputs and outputs, which you then read yourself. Rename and extract a function inside one file under an explicit instruction. List who calls a public method before you change its signature. Propose two ways to split a module, which you compare against the direction of change.

A person is required where it matters. Does the name priceQuote match how commercial people understand the discount? Is the behavior still equivalent, strange rounding included? Which side effects are part of the contract even though they are absent from the return type? May the table schema and the external response format change? A green run does not replace reading the diff. Agents like to "slightly improve" a formula in the same step.

A practical split across agents keeps each task narrow. One explores dependencies and entry points and does not edit code. Another proposes characterization cases and does not change formulas. A third performs one named move on a limited file. A separate pass reads the diff, the tests, and a ban list: do not touch the response contract, do not change rates, do not drop the email. An agent told to "bring the module to a clean architecture" will almost certainly mix the hats. Where generation ends and an engineering decision begins is covered in where code generation ends. Working with several agents in one contour is in agentic engineering and in the AI-on-legacy piece.

FAQ

How do refactoring and rewriting differ in practice?

Refactoring has to keep the system's answers for the same inputs. A rewrite deliberately builds a new implementation, so it starts with reconciliation, not with deleting the old file. If "tidying up" changed a total, that is already a rewrite, even inside one module.

Do you need tests before any edit?

On the seam you are moving, you need a foothold: a characterization test, a thin slice through the entry point, or at least one recorded request and response. Coverage of the whole repository is not the entry ticket. No foothold at all on a hot rule means the first step is to make the rule callable and record the current result.

When is duplication better than a shared function?

When the text is similar and the rule is not. Two formulas that match on demo data today and belong to different owners are better left apart. Introduce a shared function when you can name one rule and do not need a "warehouse mode / price mode" flag.

Should a long class always be split?

No. Split a class that has several independent reasons to change, or that hides roles belonging elsewhere. A long scenario with one reason and rare edits is often easier to read than a scatter of types. Watch the cost of change, not the ruler.

How is a modular monolith different from tidy folders?

In a modular monolith a neighbor does not import another module's internal types and does not read its tables. The public surface is small and named in domain operations. Folders without that rule are just navigation by technical layer.

When is it time to extract a microservice?

When the boundary already exists and the shared process gets in the way: different failure requirements, different scale, a separate team, a separate release. If there is no boundary, a service smears the same rule across the network. A contract in one process first, then a conversation about transport.

Can you refactor code nobody understands?

You can, but the first product is a map of one scenario and characterization tests, not a new structure. Until input and output are recorded, moving walls is early. Ask the people who remember the exceptions before you delete strange branches, not after the incident.

How do you stop an agent from rewriting the whole module?

Put one move and a ban list in the task. Ask for a diff that does not change rates, error strings, or table schema. Compare characterization cases separately from the run the agent started itself. Replace "improve the module's architecture" with "extract the total calculation into a pure function and do not change the call sites."

Conclusion

Four moves for this week: pick a rule, record its current result, lift it without changing the numbers, and see how many files the next edit opens.

Architecture here is not a drawing you approve once. It is the ability to change a discount rule on Friday and not wake a stranger's contract on Monday. Refactoring leaves the stove on while the cabinet moves. Decomposition labels the shelves so the next person opens one door. Small steps with a comparison give more control than a weekend teardown after which nobody can say where the old pot went.

This week, pick one hot rule. Record its current result. Lift it into a function without changing the numbers. See how many files the next edit opens. If the answer is still more than one, the boundary is still ahead, and that is a normal next step, not a reason to tear the module down.

Comments

Loading comments…