Encapsulation at the class level is enforced by access modifiers. What enforces it at the module or service level, and why is a shared database between two services the classic failure of encapsulation at that scale?
answer
- No compiler across processes
- Shared DB = every table is public
- Single writer per schema
- Architecture tests are module-scale private
- Views + events + strangler to split
basics
~20 sAt module or service scale there is no private keyword across process boundaries, so encapsulation must be enforced by explicit published interfaces plus tooling and ownership. A shared database breaks it because every table is effectively a public field any service can read and write.
solid answer
~50 sClass-level hiding relies on a compiler. Across modules and services the equivalents are: module systems and package/export boundaries; a declared allowed-dependency graph verified by architecture tests; published APIs and event contracts as the only sanctioned interaction; and organizational ownership so one team owns each secret. A shared database defeats all of it. The schema becomes the integration contract, and it is the *most* volatile artifact the service has. Any consumer can write rows that bypass the owner's validation, so invariants can no longer be enforced anywhere; migrations require lock-step deploys across teams; and nobody can tell who depends on which column, so change is paralyzed. It is representation exposure at system scale. The fix is database-per-service with the owner mediating access — synchronous API or published events — plus a migration path (views, anti-corruption layer, change-data-capture, strangler) rather than a big-bang split.
go deeper
Say services should not share a database because each should own its data and expose an API; direct table access skips the owner's rules.
Add the concrete consequences: unenforceable invariants, schema as frozen contract, lock-step migrations, and the fix of database-per-service with API or event access.
Discuss enforcement mechanisms across scales (module systems, architecture tests, credentials/grants), published vs internal contracts, and the strangler migration path using views and events.
Frame the trade: what you give up (joins, cross-entity transactions, reporting simplicity) and how you buy it back; when a modular monolith is the right boundary instead; Conway's Law alignment of boundaries to teams; and how to make the boundary economically self-enforcing rather than policy-dependent.
## Why the mechanism changes with scale `private` works because a compiler refuses to emit the call. Across a module boundary within one binary you still have compiler help (package visibility, module exports, explicit export lists). Across a process boundary you have none: anything reachable on the network or in a shared datastore is callable by anyone who knows it exists. Encapsulation therefore stops being a language property and becomes a **combination of contract, tooling and organization**. **Enforcement mechanisms, weakest to strongest** 1. *Convention/documentation* ("don't call that") — erodes under deadline pressure. 2. *Naming/packaging* (`internal` packages, `impl` subpackages) — signals intent, easy to bypass. 3. *Language module systems and export lists* — compiler-enforced within a build. 4. *Architecture/fitness tests* — automated checks in CI that assert the allowed dependency graph and that only designated types are public (ArchUnit-style rules, Spring Modulith's module verification, dependency-cruiser for JS). This is the practical `private` for modules. 5. *Deployment/network isolation* — separate credentials, per-service schemas, network policy, API gateways. The only thing that stops a determined runtime caller. 6. *Ownership and review* — a codeowners rule that routes any change to a published contract to the owning team. ## The shared database, dissected Suppose Orders and Billing both read and write the `orders` table. - **Invariants become unenforceable.** Orders' rule "an order cannot be shipped before payment clears" lives in Orders' code. Billing writing the row directly skips it. There is now *no* place in the system that can guarantee the rule, because there is no single writer. This is exactly the class-level representation-exposure failure, one abstraction level up. - **The schema is the contract, and it is the wrong contract.** Column names, types, nullability, and even index-dependent query plans become things others rely on (Hyrum's Law again). The schema is the artifact you most want to refactor — and it is now frozen. - **Lock-step deployment.** Adding a NOT NULL column, renaming, or splitting a table now requires coordinated releases across teams. You have a distributed system with the coupling of a monolith and none of the atomic-refactoring benefits — the "distributed monolith". - **No blast-radius control.** A bad query from one service degrades everyone sharing the instance; connection-pool exhaustion is shared; a schema migration lock blocks unrelated services. - **Unknowable dependencies.** Without instrumentation nobody can enumerate who reads a column, so the safe move is always "change nothing." ## What to do instead - **Database-per-service (or at minimum schema-per-service with separate credentials).** The owner is the only writer; others go through its API or consume its events. Enforce with grants, not goodwill. - **Published vs internal contracts.** Distinguish a *published* interface (documented, versioned, deprecation policy, consumer-driven contract tests) from an *internal* one (changeable at will). Very few things should be published. - **Events for read-side needs.** If Billing needs order data, publish domain events and let Billing keep its own projection. Accept eventual consistency deliberately; do not fake it with cross-service transactions. Where a workflow spans services, use a saga with compensations rather than distributed locks. - **Anti-corruption layer.** When you must integrate with a model you don't control (legacy DB, vendor), put a translating layer at the boundary so their representation never reaches your domain. - **Migration path.** Big-bang splits fail. Practical sequence: (1) instrument to discover actual consumers; (2) expose an API/event stream from the owner; (3) give other services read-only *views* rather than base tables, so you can refactor underneath; (4) move writes to the owner one path at a time; (5) revoke direct access; (6) physically separate. This is the strangler pattern applied to data. ## Trade-offs to state out loud - Splitting data costs you cheap joins, cross-entity transactions, and single-query reporting. You buy those back with read models, data warehouse/ETL, or API composition — all real work. - Modular monolith is often the right intermediate: enforce module boundaries and single-writer-per-schema *in one deploy unit*, with architecture tests as the enforcement, and split to services only when independent scaling or deployment actually pays. - Encapsulation boundaries should track team boundaries (Conway's Law). A boundary that cuts through one team's daily work will be violated; a boundary that matches ownership tends to hold.
- Is a read-only shared database acceptable if only one service writes?It is much safer — invariants survive because there is a single writer — but the schema is still a de facto contract with unknown readers, so the owner loses refactoring freedom. Exposing stable views instead of base tables, or a replica with a curated schema, keeps the single-writer benefit while preserving the ability to change the underlying storage.
- How do you enforce module boundaries inside a single deployable monolith?Language module systems and package visibility for what the compiler can catch, plus automated architecture tests in CI that assert the allowed dependency graph and that only designated API types are public. Pair with codeowners so cross-boundary changes reach the owning team.
- What replaces the joins and transactions you lose when you split the data?Read models or projections built from published events for query needs, API composition for small fan-outs, a warehouse/lake for reporting, and sagas with explicit compensating actions where a business process spans services. Each is real added work and is the honest cost of the boundary.
Two departments sharing one filing cabinet with no lock: any clerk can rewrite any folder. Rules posted on the wall ('only Finance edits invoices') are documentation, not enforcement. Giving each department its own cabinet and a request desk is the boundary; the desk is the API.