The Four Ways to Connect Two Systems
There are exactly four ways to make two applications work together, and picking one is really a coupling decision wearing an integration costume. What each style locks you into, why "just give us read access to your database" is the most expensive shortcut in software, and how to choose on purpose.
Two teams need to share data. In the meeting, someone says the reasonable-sounding thing:
"Just give us read access to your database."
It ships that week. Everyone is happy. Six months later nobody can rename a column, because four services and a reporting job are reading it and no one is certain which. The migration that should take an afternoon takes a quarter and a coordination meeting.
Nothing went wrong in the engineering. The decision went wrong — and it did not feel like a decision at the time. It felt like skipping one.
There are only four
Every integration between two applications, however it is dressed up, reduces to one of four styles:
| Style | How it works | Strength | Weakness |
|---|---|---|---|
| File Transfer | one app writes files, another reads them | dead simple, universal, fully decoupled technology-wise | stale data, no semantics, ad-hoc formats |
| Shared Database | both read and write one common schema | always consistent, no transfer step | everyone coupled to one schema; contention |
| RPC | one app calls another's methods over the network | encapsulated, immediate, familiar | tight synchronous coupling; brittle under latency and partial failure |
| Messaging | apps exchange async messages over channels | loosely coupled, reliable, decoupled in space/time/format | event-driven complexity; eventual consistency; ordering |
Your service mesh, your webhook, your nightly CSV drop, your shared Redis — each is one of these four with modern branding. New transports keep arriving; what they change is the wire, not the style. The obvious objection — what about REST? gRPC? WebSockets? SSE? — deserves a precise answer, and it lands best once we've reframed the table below in terms of coupling.
The choice is a coupling decision in disguise
Here is the reframe that makes the table useful. You are not choosing a transport. You are choosing what a change here forces to change over there — which is the working definition of coupling.
Read the table again in those terms and it reorders itself into a spectrum:
- Shared Database couples you through the schema. Every consumer depends on your table shapes, forever, whether you know who they are or not.
- RPC couples you through availability and time. Your caller is blocked on your latency, and your outage is their outage.
- File Transfer couples you through format, loosely, and on a delay. Nobody blocks; everybody guesses at semantics.
- Messaging is the loosest, and it is worth being precise about the three dimensions it buys you: space (the sender needn't know who receives), time (sender and receiver needn't be up simultaneously), and format (a translator bridges differing shapes).
None of these is free, and none is "the right answer." They differ in where the pain lands and when.
Where REST, gRPC, and WebSockets actually land
None of those four is what you type. You type fetch, a gRPC stub, a WebSocket handler, an EventSource. The reason the table still holds is that those are transports, and the table is about coupling — so they place onto it cleanly, with exactly one honest exception.
REST and gRPC are RPC. A REST endpoint and a gRPC method are the same move: your code calls an operation on another service and blocks on the answer. gRPC spells it out — it is Remote Procedure Call. The wire differs (JSON over HTTP/1.1 versus protobuf over HTTP/2), and REST adds caching and a uniform interface, but the coupling is identical — you are joined to the callee's availability and latency. Every word the RPC section below spends on timeouts, retries, and the syntax hiding the network applies to your REST client and your gRPC stub unchanged.
WebSockets and SSE are messaging with the broker removed. A server-sent-events stream or a WebSocket feed is push: the server emits, the client reacts — the intent of an event message. What's missing is the channel in the middle doing store-and-forward, and that one missing piece is the whole story. You keep messaging's push and its low latency; you hand back its time decoupling — with a direct connection both ends must be up at the same instant, and no queue holds the message until a consumer returns. In exchange you inherit two costs a broker used to absorb: connection state (which node owns the socket, what happens on reconnect, how you scale sticky connections) and backpressure (a slow consumer on a live stream is now your problem, not a queue's depth). SSE is the one-way, dead-simple version; WebSockets the bidirectional, heavier one. Unary gRPC is RPC; streaming gRPC lands here.
The strain worth admitting
Store-and-forward messaging earned "loosest style" precisely because a queue sat between sender and receiver and decoupled them in time. Continuous real-time streams delete the queue. You can call that messaging-without-the-broker to keep the taxonomy tidy, or call it a fifth thing if you have operated a system where a million live connections, their backpressure, and their reconnect storms were the architecture. Both descriptions are the same reality: the table was drawn before always-on streaming was the default, and streaming is the one case where "which of the four?" gives an unsatisfying answer.
The reframe still saves you, because it was never about the four boxes. It was about the question underneath them: when this changes, what over there is forced to change — and can the other side be down when it happens? A WebSocket answers that as sharply as a shared database does; it just answers differently. Choose the coupling on purpose, and the transport is a detail you can swap.
Why the shared database is the trap
Of the four, one is systematically chosen for the wrong reason, and it is the one from the meeting.
A shared database wins on the axis you can feel in the moment: it is fast to set up, needs no new infrastructure, and gives perfect consistency with no transfer step. Every one of those is true.
The cost lands on an axis you cannot feel for months. Your schema silently becomes a public API — with no versioning, no contract, no deprecation path, and no list of consumers. A column name is now a breaking change to systems you have never met. The coupling is invisible in every architecture diagram you will draw, because the arrow goes to a box labelled "DB" that looks like infrastructure rather than an interface.
This is precisely why domain-driven design insists each bounded context owns its data and integrates through events or an explicit API rather than a shared table. The boundary is not about tidiness. It is about keeping the number of things that can break you finite and known.
The tell that you are in this trap: you cannot answer "who reads this table?" without running a query against production and hoping.
RPC's quieter version of the same mistake
RPC has a subtler failure. Its cost is not the coupling you can see — it is that the syntax hides the network.
// Looks like any other call. It is not.
const customer = await customerService.findById(id);
That line will, at some point: take 3 seconds, take 30, return a 500, half-succeed, or succeed after your caller already timed out and retried. Those are not exceptional cases; they are Tuesday. A local call has none of those modes, and the code reads identically.
You do not avoid RPC — it is the right choice constantly, and immediacy is a real requirement. You avoid forgetting it is remote: timeouts, retries with backoff, idempotency on the receiving side, and a decision about what you do when the callee is simply down.
Messaging is not free either
The honest counterweight, which the enthusiasm usually skips: an event-driven programming model is harder to build and harder to debug. You inherit ordering questions, delivery guarantees, and eventual consistency. Bulk data is a bad fit — that is what ETL is for.
What you get in exchange is worth naming precisely. The messaging system owns delivery, retrying until it succeeds, so your application stops carrying retry logic. And a message's intent becomes explicit in a way an RPC signature never makes it:
- Command — "do this."
- Document — "here is some data; you decide."
- Event — "this happened."
That third one is the load-bearing difference. An event does not know its consumers. Adding a fifth subscriber to OrderPlaced changes nothing upstream — which is exactly the property the shared database gave away.
When the two models disagree
Any of these can connect two systems that do not share a vocabulary. Your Customer has three fields the vendor's does not, and theirs has a status enum that makes sense only inside their business.
Do not let that leak. Put an anticorruption layer at the seam — a translation boundary that maps the foreign model into your terms so your domain only ever sees its own concepts. It costs real code and a hop, and it earns that back the first time the upstream system changes: only the layer moves, not your domain.
Skip it when the other model is clean and stable enough to adopt outright. An ACL is work; spend it where model integrity actually matters.
Choosing on purpose
Four questions, in order. The first one that gives a hard answer decides it:
- How fresh must the data be? Tolerates hours → File Transfer is legitimate and underrated. Needs to be immediate → RPC or Messaging.
- Can the caller wait, and can it fail when the other side is down? No → Messaging, so time-coupling disappears. Yes, and it genuinely needs an answer now → RPC.
- Who owns the data's shape? If the answer is "both of us," stop. That is the shared-database trap, and it is a decision to give up your ability to change.
- How many consumers, now and later? One known caller → RPC is fine. Unknown or growing → events, so adding a consumer is not your problem.
Real systems mix all four, deliberately: RPC for the synchronous read a user is waiting on, events for everything downstream that merely needs to know, a nightly file for the analytics warehouse. Mixing is not incoherence. Using one style everywhere by reflex is.
The takeaway
The next time someone offers you database access as a shortcut, the useful reply is not "that's bad practice." It is a question:
When we want to change this schema, who do we have to ask?
If the honest answer is "we don't actually know," you have not chosen an integration style. You have chosen to find out later, at the worst possible time.
Pick one of four, on purpose, and know what it costs you.