BLOG · #Engineering

Modularity and migration: cutting boundaries that survive

Module boundaries end up mirroring your org chart whether you plan them or not. The useful criterion isn’t layers or file types — it’s hiding decisions that are likely to change, and it’s also what makes migration possible without a rewrite.

Also in 中文. Part 5 of Trading app architecture.

A long-lived app has a property no design document mentions: it must keep shipping while it changes shape. Nobody gets to stop for a year. That constraint, more than any technical one, determines which architectures survive.

Two old ideas do most of the work here, and a third tells you how to move.

Your architecture will look like your org

Conway’s observation is that a system’s module boundaries end up mirroring the communication boundaries of the organization that produced it. It is not a warning; it’s a prediction that holds well enough to design against.

The practical reading: if eight teams work on one app, you will get roughly eight modules. The only question is whether you chose them. Boundaries that match how people actually work become cheap — a team ships without coordinating. Boundaries that cut across teams become expensive forever, because every change requires a meeting.

So modularity is not only a code-structuring exercise. It is a decision about who can ship without asking whom, and that is why it shows up in build times, review latency, and how often a release is blocked by an unrelated team.

Cut where decisions are likely to change

The criterion is Parnas’s, from 1972, and it is still the most useful sentence about modularity: a module should hide a decision that is likely to change.

Note what it does not say. It doesn’t say group by technical layer — all models here, all views there. That arrangement is the one most teams reach for and it inverts the goal: a typical change is a feature, and a feature touches a model, a view, and a service, so every change touches every module. You have created boundaries that add ceremony and prevent nothing.

The test is simple: take your last ten meaningful changes and see how many modules each touched. If most touched one, your boundaries match reality. If most touched four, your modules are shaped like your file types instead of your changes, and the fix is to cut along features and volatile decisions instead — the market data protocol, the chart engine, the auth flow, the persistence choice.

The core should be handed its dependencies

Once you have modules, the question is which way the arrows point. Dependency inversion answers it: the core declares the interfaces it needs — a network transport, a store, a clock, a logger — and the outside world supplies implementations. The core depends on nothing concrete.

Three things follow, and they are the reason this pattern keeps reappearing in large systems:

  • The core is testable in isolation, because every collaborator can be replaced with a fake. Including the clock, which is how you test a timeout without waiting for one.
  • Infrastructure can be replaced without touching business rules. The HTTP client is an implementation detail if and only if nothing in the core names it.
  • The interface list documents the real dependencies. A core initialized with a dozen injected capabilities tells you exactly what it needs from the world — often a more honest architecture diagram than the one in the wiki.

The corresponding smell is a core that reaches out: constructs its own HTTP client, reads the system clock, calls a singleton logger. It works, and it quietly welds the business rules to the infrastructure. You discover this the day you try to test it.

Registries buy decoupling and charge for safety

When modules must find each other without compile-time dependencies, teams reach for a registry: modules register implementations at startup, consumers look them up by interface.

The benefit is real — modules build and ship independently, and nothing needs a compile-time reference to a sibling.

The cost is equally real: a missing binding is no longer a compile error, it’s a crash at runtime, in a code path someone reaches on a Tuesday. You have traded a guarantee for flexibility.

If you take that trade, pay down the risk deliberately: keep interfaces in small contract modules both sides depend on, and validate the full registry at startup in debug builds so a missing binding fails immediately and loudly rather than deep in a user flow. Flexible wiring is fine; silent flexible wiring is not.

Migrate by growing the new inside the old

Eventually a large app needs a technology it didn’t start with. The instinct is a rewrite, and the reason rewrites fail is structural, not motivational: you are aiming at a moving target. The existing product keeps gaining features while you rebuild, so you must reach parity with something that is still growing, and until you do, you ship nothing.

The strangler-fig approach inverts it. Grow the new system inside the old one, one screen at a time, until the old one can be removed. Concretely that needs four things:

  1. A seam — usually routing or navigation — where a destination can be served by either implementation.
  2. Parity per screen, not per app. One screen at a time is a shippable unit; a whole app is not.
  3. Both runtimes coexisting, with the costs from the previous part accepted explicitly.
  4. A kill switch. If the new implementation misbehaves in production, routing falls back. Without this, incremental migration has the same risk profile as a rewrite, just spread out.

The cost is a period — often years — of maintaining two ways of doing things. That is the price of never having a flag day, and for a product where downtime or a stalled roadmap is unacceptable, it is a good trade. It is also why large apps look “inconsistent” from the outside: you are seeing a migration mid-flight, which is what a healthy long-lived system looks like.

Don’t buy this early

Everything above is priced for coordination problems. If you don’t have them, it’s overhead.

For one to three developers, module boundaries mostly add ceremony: more build configuration, more indirection, more places to look. A registry is strictly worse than calling the thing. The honest default is one codebase with clear internal structure and no enforced boundaries.

Cut a module when a boundary starts to hurt — repeated merge conflicts in one area, build times you notice, ownership nobody can state, a dependency you want to swap and can’t. Those are measurements. “We might grow” is not.

The expensive failure isn’t a missing module. It’s a small team maintaining a large team’s architecture, paying the coordination cost of an organization that doesn’t exist.

References


Previous: The cross-platform boundary · Next: The client security model