skip to content

In a monolithic codebase without enforced module boundaries, a change to a shared data-model class used by both the billing code path and the reporting code path breaks the reporting module's tests. Why does this kind of ripple effect happen more easily in a monolith than it would across independently deployed services, and what does it cost the team over time?

level: seniorimportance: must knowfreq 75%

answer

  1. no network boundary = no forced contract
  2. compiler ripple = coupling made visible
  3. services expose only their contract
  4. blast radius grows with codebase size
  5. modular monolith enforces boundaries at compile time

basics

~20 s

Because both billing and reporting directly use the same shared class in the same codebase, a change one team makes can silently break the other team's code, unlike separate services which only talk through a stable, agreed-upon interface.

solid answer

~50 s

In a monolith, any class is reachable and reusable by any other part of the codebase unless the team explicitly restricts visibility, so it's easy for reporting to depend directly on billing's internal data-model class instead of a stable, intentional interface. When billing changes that class for its own reasons, the compiler correctly flags every caller, including reporting — but that means billing's internal refactor becomes reporting's emergency, even though reporting was never consulted. Across independently deployed services, the only thing exposed is a versioned API contract; internal data models can change freely as long as the contract doesn't, so ripple effects are contained by design. Left unaddressed, this coupling compounds: over time nobody can change a widely used class without a cross-team audit, understanding one feature requires understanding unrelated ones, and the codebase drifts toward a 'big ball of mud' that resists both refactoring and onboarding.

go deeper

for a junior

Should recognize that sharing a class between two features means a change to it can break both, even unintentionally.

for a middle

Should articulate that the monolith lacks a forced contract boundary that a network call would otherwise provide, using this scenario as the example.

for a senior

Should explain the compounding organizational cost (blast radius, test fragility, onboarding difficulty) and propose a concrete mitigation such as module-visibility rules or architecture tests.

for a principal

Should weigh this cost against the alternative of service extraction, know a named tool/pattern (Spring Modulith, ArchUnit, modular monolith) for enforcing boundaries in place, and articulate when boundary discipline is enough versus when it isn't.

## Why the coupling happens so easily Tight coupling in a monolith is a direct consequence of the same shared-memory property that makes in-process calls fast: because every class in the codebase lives in the same compiled artifact, any class can, technically, reference any other class directly — there is no structural barrier requiring billing and reporting to interact through a deliberate, stable interface. In the scenario described, reporting most likely didn't import a designed public API from billing; it imported billing's actual internal entity or data-transfer class, because that class was sitting right there, public, and convenient. That's an easy, natural thing to do in a monolith and often goes unnoticed for a long time because it compiles cleanly and works. ## The compiler is the mechanism, working as intended The mechanism that turns this into a ripple effect is the compiler itself, working exactly as intended: when billing changes a field name, a method signature, or a class structure on that shared entity for its own internal reasons, the compiler correctly identifies every single caller across the entire codebase that now fails to type-check or whose tests now fail — including reporting, which had no involvement in and no advance notice of billing's change. This is the coupling made visible. It's not a bug in the compiler; it's the accurate discovery that reporting's correctness was, all along, silently dependent on the exact internal shape of a class it doesn't own. ## Why two deployed services behave differently This differs fundamentally from two independently deployed services. There, the only thing one service can depend on is whatever the other service intentionally exposes: - a REST endpoint; - a message schema; - a published client library. That exposed contract is the one thing the owning team commits to keeping stable (often with explicit versioning, like `/v1/` and `/v2/` endpoints existing side by side during a migration). The service's internal data model can be refactored completely, every day, and no other service even notices, because the network boundary enforces that only the contract is visible from outside. The absence of that enforced boundary is precisely why a monolith needs discipline to recreate, voluntarily, what a distributed system gets automatically from the network: - code review conventions; - module-visibility rules; - or automated architecture tests. ## Why the trade-off runs the other way at small scale The reason this problem exists at all is that the trade-off runs the other way for good reason at small scale: shared, freely reachable classes make small monoliths fast to write, since there's no ceremony required to reuse a well-written class elsewhere. The failure mode only becomes expensive as the codebase and the number of independent teams touching it both grow — the same forces that erode any large shared-memory system without imposed structure. ## How the cost compounds The cost compounds in predictable, observable ways in production and in team velocity. 1. **First, blast radius on change grows.** A class used in three places is a minor refactor risk; the same class used in forty places across code owned by six different teams turns any change into a cross-team coordination exercise, and teams learn to simply avoid touching shared classes rather than improve them, leading to duplicated near-copies of the same logic instead ('I'll just make my own version rather than risk breaking billing'). 2. **Second, test suite fragility increases.** A change intended to affect one feature causes unrelated test failures elsewhere, teams start ignoring red builds because failures are 'probably unrelated,' and genuine regressions get missed in the noise. 3. **Third, onboarding and cognitive load rise.** A new engineer trying to safely change billing's entity class has to first discover every other module that depends on it, because there's no compiler-enforced boundary telling them 'these are the only sanctioned callers.' This is the classic trajectory toward what's commonly called a 'big ball of mud' — a system where the dependency graph between classes has become so dense and undocumented that no single engineer, or even team, can predict the full consequences of a local change. ## The mitigation that keeps the deployment model A well-known concrete mitigation, without abandoning the monolith's deployment model, is the **modular monolith** pattern: the codebase stays one deployable artifact, but module boundaries are made explicit and enforced by tooling — for example, Spring Modulith or ArchUnit rules that fail the build if reporting imports a class from billing's internal package rather than its designated public API package. This recreates the discipline a network boundary would impose, at compile time instead of at runtime, while keeping the shared-memory performance and transactional benefits intact. It doesn't eliminate the possibility of coupling, but it makes accidental coupling like the billing/reporting scenario a build failure instead of a silent landmine discovered weeks later.

  • Why didn't reporting just define its own copy of the data it needed instead of directly depending on billing's class?
    In principle it should have — depending on your own DTO shaped for your own needs, and mapping from billing's model at the boundary, is exactly the discipline that avoids this coupling. In practice it's easy to skip that step in a monolith because reusing the existing class compiles immediately and duplicating a class feels like unnecessary work, especially under deadline pressure — the friction that would force a cleaner boundary in a distributed system simply isn't there to push developers toward it.
  • How would an automated architecture test have caught this coupling before it caused a production incident?
    A tool like ArchUnit or Spring Modulith's verification can assert rules such as 'code in the reporting package may not reference classes in billing's internal package' and run that check as part of the CI build. If reporting had imported billing's internal entity directly, the build would fail immediately at the point the dependency was introduced, forcing the team to either go through billing's public API or explicitly justify the exception — turning a silent, delayed failure into an immediate, cheap one.
  • If the team decides this coupling is a chronic problem, is extracting reporting into a separate microservice the right first move?
    Not necessarily as a first move — it's usually cheaper and lower-risk to first introduce explicit internal module boundaries (a modular monolith) and see whether that discipline alone resolves the coupling, since it requires no new operational infrastructure. Extracting a separate service is a much larger investment that makes sense once there's a specific additional driver like independent scaling, deployment, or team-ownership needs that boundary enforcement alone can't satisfy.

It's like two roommates sharing one utility drawer instead of each having their own: if one roommate reorganizes the drawer for their own convenience, the other roommate's habit of reaching for the scissors in 'their usual spot' breaks immediately — versus if each had their own drawer and only agreed to hand each other specific tools on request, reorganizing your own drawer would never affect the other person.

saying these in an interview costs you the question

  • Says coupling is impossible to prevent in a monolith without splitting into services
  • Doesn't recognize that services avoid this via an explicit, versioned contract rather than magic
  • Thinks a compiler error caused by this coupling is itself the bug, rather than a symptom of missing boundaries
  • Has no concrete mitigation to offer besides 'rewrite it as microservices'
  • Can't explain why the same coupling risk doesn't apply, or applies much less, across two independently deployed services

context