What is a modular monolith, and what concretely distinguishes it from a monolith whose packages happen to be organized by feature but have no enforced boundaries?
answer
- package-private internals, public API only
- enforcement tool (Modulith/ArchUnit) fails the build
- one deploy unit, many modules
- folders alone aren't boundaries
- shared DB can bypass code-level boundaries
basics
~10 sOne app, one deployment, but cut into modules that hide their internals and only talk through defined entry points - unlike a folder-organized monolith where any code can still call any other code directly.
solid answer
~50 sA modular monolith is a single deployable/runtime, like a classic monolith, but decomposed into modules - usually mapped to bounded contexts - each exposing a narrow public API (interfaces, DTOs, events) while its entities, repositories, and internal services stay package-private or otherwise inaccessible from outside. Cross-module calls go only through that API or async domain events, never direct field/repository access. Tooling such as Spring Modulith or ArchUnit enforces this at build/test time, failing CI if a class reaches into another module's internals. The key difference from 'folders organized by feature' is enforcement: folder structure alone is a convention a deadline can break in five minutes, while a modular monolith makes the violation a build failure, so boundaries - and the option to later extract a module into its own service - actually survive years of feature work.
go deeper
Should describe the core idea in plain terms - one running app, but code is grouped into modules that aren't supposed to reach into each other's internals - even without naming specific tools.
Should name the concrete mechanism: package-private/internal sub-packages, a public API surface (interfaces/DTOs/events), and a build-time or test-time check (e.g., Spring Modulith's verify(), ArchUnit rules) that fails when it's violated.
Should articulate why enforcement, not folder convention, is the differentiator, and compare the trade-offs against both a plain monolith and full microservices in terms of operational cost versus design discipline.
Should reason about organizational and long-term risks - database-level coupling that code-level tools can't see, god-module drift, resource-sharing blast radius - and how governance (schema ownership rules, CI gates, module review) keeps the boundary real for years, not just at the moment it was drawn.
## What a modular monolith is A modular monolith ships as a **single deployable unit** and runs as a **single process**, exactly like a plain monolith, but its codebase is decomposed into modules that behave like miniature, in-process services. Each module typically maps to one bounded context — orders, billing, catalog, notifications — and is organized as its own top-level package. Inside that package, the module keeps a small, deliberate public surface: - **Interfaces, request/response DTOs, and event classes** that other modules are allowed to depend on. - **Everything else** — JPA entities, repositories, internal helper services, mapping code — lives in a sub-package (often literally named `internal`) that is package-private or otherwise marked non-exported. ## How the boundary is checked A verification tool then walks the compiled classpath or source graph and checks that no class outside a module's public sub-package is referenced from another module. - **Spring Modulith** does this with `ApplicationModules.of(Application.class).verify()`, usually wired into a JUnit test that runs on every build. - **By hand**, a similar effect can be achieved with ArchUnit rules such as 'classes in package `..orders.internal..` may only be accessed by classes in `..orders..`'. The moment a developer imports `orders.internal.OrderRepository` from the `billing` module, the build goes red, not months later during a painful refactor. ## Why the enforcement is the entire point This enforcement is the entire point, because the alternative failure mode is extremely common and well documented: a codebase organized into feature packages 'because it looks clean' but with no verification degrades under delivery pressure. 1. A developer needing one field from another module's entity takes the **five-minute path** of importing the repository directly instead of the **twenty-minute path** of adding a proper API method. 2. A year later ten such shortcuts exist, the module graph is a cycle, and nobody can deploy or even test one module in isolation anymore. 3. Teams call this a 'big ball of mud' or, when it happens after a botched microservices split, a 'distributed monolith'. A modular monolith is a deliberate answer to that decay: keep the operational simplicity of one deployable — one build, one deploy pipeline, one process to run locally, ACID transactions available within a module — while getting the design discipline usually associated with microservices, namely that a module cannot be bypassed, only asked. ## The trade-off The trade-off is real on both sides. Building and maintaining the enforcement costs something: - The verification test has to actually run in CI (not just exist). - The team needs a convention for where a module's public API lives. - Discipline is needed about what belongs in the DTO/API layer versus the entity layer, because leaking a JPA entity as if it were a DTO defeats the boundary even when the visibility rule technically passes. In exchange, you get compile/test-time protection instead of relying on people remembering the rules, and you retain a much lower operational bill than a real microservice split: no service discovery, no per-module deployment pipeline, no network calls (with their retries, timeouts, and partial failures) between modules that are really just business capabilities of the same product. What you give up compared to true microservices is independent scaling and deployment — all modules ride in the same JVM and go down together — and, unless the team is careful, an easy route to accidentally share a single database schema across modules in a way the code-level boundary can't see or stop. ## Failure modes in production 1. **Database coupling.** That last point is also the most common production failure mode: two modules whose code never imports each other directly nonetheless read and write the same database tables, or one module's migration silently depends on a column another module owns. The build stays green because the enforcement tool only understands Java/Kotlin visibility, not SQL, so the coupling is invisible until a schema change in one module breaks another module's queries at runtime. 2. **The god module.** A second common failure is a single module — often called a 'god module' like `common` or `core` — becoming the dumping ground for anything nobody wants to own, which slowly reintroduces the everything-depends-on-everything problem the whole structure was meant to prevent. 3. **Resource-level coupling.** A third is resource-level coupling: because all modules share one JVM, a memory leak, an unbounded thread pool, or a blocking call inside one module can degrade or take down every other module's endpoints even though their code was never coupled. ## The concrete example on the JVM Spring Modulith is the concrete, named example of this pattern for the Spring/JVM ecosystem. It: - derives modules from the top-level packages under the main application package; - offers `@ApplicationModuleTest` to boot only one module (plus the modules it declares as dependencies) for fast integration tests; - supports declaring allowed dependencies explicitly between modules; - provides an in-process event publication mechanism so modules can react to each other's domain events (e.g., a `billing` listener reacting to an `OrderPlaced` event published by `orders`) without calling each other synchronously at all.
- If module boundaries are already clean and enforced, why not just deploy each module as its own microservice right away?Because microservices add real operational cost that a modular monolith avoids: service discovery, per-service deployment pipelines, network calls between what used to be in-process calls (with their timeouts, retries, and partial-failure handling), and distributed transactions instead of local ACID ones. A modular monolith lets a team validate that the boundaries are actually correct - not guessed - before paying that price, and extraction becomes a much lower-risk, incremental move later.
- How would you catch two modules that are coupled through the database even though their code never imports each other?Code-level tools like Spring Modulith's verify() or ArchUnit only see class dependencies, so they miss this. You need complementary checks: per-module schemas or at least per-module table-ownership conventions, a review rule that a migration can only touch tables owned by its module, and sometimes a runtime check that flags cross-module joins or foreign keys.
- What's the risk of a shared 'common' or 'core' module in this architecture?It tends to become a dumping ground because it's the path of least resistance for anything that doesn't obviously belong to one bounded context, and since every other module is allowed to depend on it, it quietly becomes a hub that reintroduces the everything-depends-on-everything problem the module boundaries were meant to prevent. Keeping it to genuinely generic, stable primitives, not business logic, is the usual guardrail.
Like an apartment building: every unit (module) has its own locked door and its own kitchen, and tenants only interact through the shared lobby and mail slots (the public API/events) - never by climbing through each other's windows - but it's still one building with one front entrance (one deployable), unlike a scattered campus of separate houses (microservices).
saying these in an interview costs you the question
- says 'modular monolith' just means folders organized by feature, with no mention of enforcement
- can't name any tool or mechanism (ArchUnit, Spring Modulith, visibility rules) that actually checks the boundary
- believes it requires a separate database per module
- thinks any cross-module method call is automatically a violation, including calls through a published public API
- conflates modular monolith with microservices as if they were the same thing
- assumes package-by-feature alone prevents the 'big ball of mud' outcome