Home / Blog / ArchiMate & EA Practice

Domain Boundaries and Why Databases Shouldn't Cross Them

Somewhere in most mid-sized organizations there's a database that belongs to one team, on paper, and is quietly read by four others in practice. Nobody approved this arrangement in any meeting. It accreted one query at a time, and by the time anyone thinks to ask who's connecting to it, the honest answer is: we're not entirely sure. This is one of the most common architecture smells there is, and one of the least discussed, because it doesn't look like a mistake while it's happening. It looks like someone being resourceful.

Why a database is not just storage

A domain's database is not an interchangeable bucket of rows that happens to sit behind that domain's applications. It's the enforcement point for everything that domain guarantees about its own data — the constraints that make a record valid, the business rules that decide what state transitions are allowed, the validation that runs before a write is accepted. None of that logic lives in the schema. It lives in the application code that owns the schema, and the schema on its own is usually far more permissive than the rules the owning team actually enforces.

Take an orders domain. The orders table might allow any string in a status column, but the order service that owns it enforces a state machine — an order can move from placed to paid to shipped, but not from placed straight to shipped, and not backwards from shipped to placed at all. That rule exists in code, in the service that fronts the table. The database itself has no idea the rule exists; it will happily accept an update that violates it, because nothing in a typical relational schema encodes a state machine. The moment something writes to that table without going through the order service, the state machine isn't being enforced — it's being trusted to have been followed, by something that never agreed to follow it.

Reads carry a quieter version of the same problem. A finance system that queries the orders database directly to build a revenue report is coupling itself not to the concept of an order, but to today's physical representation of one — this column name, this table structure, this join path. The order service could compute "revenue-relevant orders" through business logic that isn't visible in the schema at all: excluding test accounts, applying regional tax rules, handling partial refunds. A direct query sees rows. It doesn't see the rules that were supposed to sit between the rows and anyone asking a question about them.

The three ways it actually creeps in

Nobody schedules a design review to approve cross-domain database access. It arrives through small, individually reasonable decisions, and by the time it's a pattern rather than an exception, nobody remembers it was ever a decision at all.

The reporting query that never left

Someone in finance needs a number for a Friday deadline — total orders by region, this quarter. The order service doesn't expose that report, and building a proper endpoint would take a sprint nobody has spare capacity for. Someone with read access to the orders database writes the query directly, gets the number, sends the spreadsheet, and everyone moves on. Except the query doesn't get deleted. It gets scheduled, because next month someone needs the same number again. A year later it's a dashboard six people rely on, built on a direct connection nobody remembers approving, against a schema that has since changed twice without anyone telling finance, because why would they — finance was never on the list of consumers the order team thought they had.

The batch job with no owner in the room

An overnight job needs to reconcile shipments against orders. The cleanest design routes it through both domains' APIs. The fast design — the one that ships this sprint — connects directly to both databases and joins across them in SQL, because that's a single query instead of two API calls and some code to stitch the results together. It's faster to write, faster to run, and it works, right up until either schema changes shape. Nobody designing the shipment domain's next migration knows this job exists, because it was never registered as a consumer of anything — it just runs, at 2 a.m., against tables it was never granted permission to depend on, in the sense that matters.

The integration that was always going to be temporary

A new partner integration is due in three weeks. The proper design is an API the partner's system calls, backed by the owning domain's service layer. The temporary design, agreed on explicitly as temporary, is a read-only database user handed to the integration team so they can pull what they need directly while the real API gets built later, when there's time. The real API does not get built later. There is never time, because the temporary connection already works, and "already works" beats "not built yet" in every prioritization conversation that follows. Two years on, the partner integration is still reading the database directly, and it has become one of the reasons nobody feels able to change that schema without a coordinated outage.

All three stories share a shape: the direct connection was the fast path under real time pressure, framed as a one-off, and never revisited because revisiting it required someone to notice it was still there. Nothing about any single decision was unreasonable. The pattern that results from all of them together is what causes the damage.

What actually breaks

The consequences aren't abstract, and they don't wait for a catastrophic failure to show up — they show up as friction, first, and only later as an incident.

Validation and business logic get bypassed. Every write that skips the owning service skips whatever that service enforces. A batch job that updates order status directly can put an order into a state the order service would have rejected, and nothing downstream knows to distrust that state, because as far as every other reader is concerned, the order service is the authority on order state.

Schema changes become dangerous for the owning team. This is the one that quietly costs the most time. A team that owns a database and knows every consumer goes through its API can refactor that schema freely — rename a column, split a table, change a data type — because the API is the contract, and the schema behind it is an implementation detail. A team that has direct consumers it doesn't fully know about can't make that assumption. Every schema change becomes a search for who might break, and because the searching is manual and the list of direct consumers was never tracked anywhere, the honest answer is usually "we're not sure, so let's not change it." Schemas calcify. Technical debt that would take a day to fix in isolation gets left alone for years, because the blast radius is unknown rather than because the fix is hard.

The boundary stops meaning anything. A domain boundary is supposed to be the place where one team's autonomy is real — where they can change internals without asking permission, because everyone else only depends on the contract they've published. Direct database access punches a hole straight through that. The boundary still exists on the org chart and in the architecture diagram everyone nods at in the quarterly review. It does not exist in the dependency graph that actually determines what can change safely, and those two pictures drifting apart is, in practice, what "the architecture doesn't match reality" means.

Direct cross-domain database access compared with an API-mediated boundary Direct access Finance Service Finance domain Order Service Orders DB Order domain direct SQL, bypasses service API-mediated Finance Service Finance domain Order Service Orders DB Order domain API call
On the left, Finance reaches Orders DB two ways — a legitimate API call and a direct query that bypasses the order service entirely. On the right, every path into the order domain's data goes through the service that owns it.

A healthier pattern, chosen on purpose

None of this is an argument that a domain's data should never leave its own database. It's an argument that how it leaves should be a decision, made once, by the team that owns the data — not an accumulation of individual shortcuts that nobody chose as a pattern. There are a small number of legitimate ways to share data across a domain boundary, and the difference between them and a direct connection isn't the technology. It's whether the owning domain controls the contract.

PatternWhat crosses the boundaryWho controls the contract
Direct database accessRaw tables, whatever shape they happen to be in todayNobody — the schema is the contract, and it wasn't designed to be one
Synchronous APIA request/response contract the owning service defines and versionsThe owning domain, explicitly
Published eventsDomain events the owning service emits when something meaningful happensThe owning domain, at the moment of publication
Deliberate replication / read modelA denormalized copy, built and refreshed for a known consumer's read patternThe owning domain decides what's replicated and how fresh it needs to be

An API is usually the right default for the reporting-query and integration cases, because both are really asking a question the owning domain is better positioned to answer than the raw schema is. An events stream fits the batch-reconciliation case well, because reconciliation is fundamentally about reacting to state changes over time, not about joining two tables at a point in time. A deliberately built read replica or reporting warehouse is the right answer when the volume or query pattern genuinely doesn't suit a request/response API — and this is the one case that looks, on the surface, like the thing this article is arguing against. The difference is that the owning domain chose to publish that replica, chose what's in it, and can evolve their live schema without breaking it, because the replica has its own contract instead of being a live tap on internal tables. A direct SQL connection from another team's service is never that. It's a dependency the owning domain didn't agree to and can't see.

The test that separates the two: if the owning team changes an internal table tomorrow, do they know who to tell? If the answer is "whoever asked for read access, if we remember who that was," it's not a data-sharing pattern — it's an unmanaged dependency that happens to work today.

Why the principle alone doesn't survive contact with a deadline

Almost every architecture practice has a version of this rule already, usually as a line in a principles document: "applications must not access another domain's database directly." It's a reasonable sentence, everyone in the review nods, and it changes nothing, because a principle with no enforcement mechanism is a preference, not a constraint. The reporting query from three weeks ago wasn't written by someone who disagreed with the principle. It was written by someone who had never read the document, or had read it and correctly judged that nobody was going to check.

The check that would actually catch it — does this new connection cross a domain boundary into a database that isn't its own — is exactly the kind of question nobody has time to answer by inspection. It requires knowing which domain owns which database, which is itself something that's supposed to be documented but usually lives across three different wikis and one person's memory. Even with that knowledge, verifying it means reading connection strings and configuration across every service in the estate, by hand, on a schedule nobody has budget for. The principle is correct and the enforcement is where it dies.

Enforcing it against the model, not the memory

This is the specific gap Mooodels is built to close: a rule like this only works if it can be checked against something that actually reflects the architecture, automatically, every time the architecture changes — not recited in a review and then trusted to hold. That requires two things the canonical model already provides: every application, service, and database exists as an element with a domain it belongs to, and every dependency between them exists as a relationship, not as an assumption. A rule that checks for cross-domain database access is then a straightforward query over that graph.

rule "no-direct-cross-domain-database-access" {
    match: relationship
    where: source.type == "ApplicationComponent"
       and target.type == "Database"
       and source.domain != target.domain
    require:
        exists relationship
            from: source
            to: target.owner   // the ApplicationService fronting this database
            replacing: this
    message: "{{source.name}} reads {{target.name}} directly across a domain " +
              "boundary ({{source.domain}} -> {{target.domain}}). Route through " +
              "the owning domain's application service instead."
}

Nothing about this rule depends on anyone remembering it exists. It runs the same way any other deterministic check runs against the model — on every proposed change, whether that change comes from a person dragging a connector on the canvas or an AI assistant proposing a ModelPatch. Both paths converge on the same model, so both are checked against the same rule before anything commits.

Walk the reporting-query story back through this. Someone models the new integration the way it would actually get built: a relationship from a FinanceReporting element, tagged to the finance domain, straight to OrdersDB, tagged to the order domain, carrying nothing to justify itself but a stated purpose: quarterly revenue report.

That relationship crosses a domain boundary straight into a database, with no intermediary service in between — exactly the shape the rule is watching for. The check fails at the point the change is proposed, not months later during an incident review, and the message names the two elements and the two domains involved, which is specific enough that whoever is reviewing the change doesn't have to go rediscover why it matters. Fixing it means adding the missing hop — pointing FinanceReporting at OrderService over HTTPS instead, with the same stated purpose attached to it — and letting the order domain's own service front the query — which also means the order team can now see, from their own model, that finance depends on a specific capability of their service. That visibility is the whole point. The rule isn't there to make a fix mandatory in some bureaucratic sense; it's there to make the dependency visible to the team that has to live with it, at the moment it's created, instead of two years later when nobody can say for certain who's still connected.

The same rule generalizes past the single-relationship case. Because it's a query over the model rather than a note in a document, it can run as part of a broader lint pass across an entire domain — surface every cross-domain relationship that terminates directly at a database, rank domains by how many undocumented dependencies point into them, or flag the ones that have grown the fastest since the last review. That's the kind of graph analysis that turns "we think a few teams still read that table directly" into an actual, current list — which is usually the harder problem to solve, because by the time anyone asks the question, memory has already failed.

What this looks like in ArchiMate terms

For teams modelling in the ArchiMate profile rather than the generic one, this same rule maps onto vocabulary most architects already use without having to invent anything new. A domain's database is a Data Object, realized or accessed by an Application Component that belongs to that domain. The API in front of it is an Application Service, exposed by that same component. A healthy cross-domain dependency is an Application Component in one domain using another domain's Application Service — a Serving relationship that terminates at a service, never at a Data Object it doesn't own. The unhealthy version is an Access relationship — Read, Write, or ReadWrite — running directly from an Application Component in one domain to a Data Object owned by a component in another, with no Application Service anywhere in between.

That distinction is precisely what a rule can check, because ArchiMate already gives Access relationships a direction and a Data Object an owner. The same rule shown earlier just narrows its match clause to the profile's relationship types instead of a generic one:

rule "no-direct-cross-domain-access" {
    match: relationship
    where: type in ["Access-Read", "Access-Write", "Access-ReadWrite"]
       and target.type == "DataObject"
       and source.domain != target.domain
    require:
        exists relationship
            from: source
            to: target.owningService  // an ApplicationService, via Serving
    message: "{{source.name}} has a direct {{type}} relationship to " +
              "{{target.name}}, owned by {{target.domain}}. Model a Serving " +
              "relationship to that domain's ApplicationService instead."
}

Teams using C4 instead express the same shape at the container level — a direct arrow from one system's container to another system's database container, instead of to its API container — and the rule reads no differently, because underneath either profile it's still elements, relationships, and a domain property on each. That's the practical benefit of a rule engine that sits on the canonical model rather than on any one notation: architects keep working in the vocabulary their organization already standardized on, and the governance check doesn't care which one they picked.

Where a direct read genuinely is fine, and how the model should say so

A rule enforced literally, with no way to make an exception, doesn't get adopted — it gets worked around, which recreates exactly the invisibility problem it was meant to fix. There are legitimate cases: an owning team that deliberately stands up a read replica for a known reporting workload, a data warehouse fed by change-data-capture that the owning domain explicitly signed off on, a shared reference-data store that was designed from the start to be read by anyone. What makes these different from the accidental cases isn't the SQL — it's that the owning domain chose them, on purpose, as a data-sharing pattern with its own contract.

The right way to model that isn't to weaken the rule until it stops catching anything. It's to model the replica or warehouse as its own element, owned by the domain that publishes it, with its own relationship into the source database — and to route consumers to that element instead of to the operational database. The rule keeps checking exactly the same condition. What changes is that the deliberate pattern gets an honest name in the model, instead of looking identical, in the dependency graph, to the shortcut nobody meant to make permanent. A reviewer looking at the model afterward can tell, at a glance, which cross-domain reads were designed and which one snuck in through a batch job three years ago — because only one of them has an owner.

That's really the difference this whole pattern comes down to. A direct connection and a deliberate data-sharing pattern can look almost identical in a network diagram — both are, mechanically, one system reading another's data. What separates a boundary that still means something from one that's been quietly hollowed out is whether the path was chosen by the team accountable for it, and whether that choice is something a model — and a rule running against it — can actually see.

See the model this article describes, working in a real editor.

Try the live demo