A recurring decoupling lever is to stop handing collaborators references to your mutable objects and let them exchange only copied values or messages. Erlang and Elixir enforce this in the runtime (every process has its own heap and every message is copied on send), Go offers it as the slogan "share memory by communicating" plus channels, and Rust expresses it as ownership transfer with the Send and Sync marker traits. Explain what coupling this lever actually removes, what coupling it creates in its place, and where the guarantee is enforced versus merely conventional.
answer
- copy deletes aliasing, not coupling
- coupling relocates to schema + lifecycle
- Go channel: header copied, backing array shared
- BEAM: private heap, copy-on-send; Rust: move + Send/Sync checked
- a copy is a snapshot -> staleness
basics
~20 sCopying removes aliasing — nobody mutates your state behind your back — but converts object coupling into message-shape, versioning, ordering and lifecycle coupling, plus staleness, since a copy is a snapshot. Erlang enforces isolation, Rust checks it, Go and Java only by convention.
solid answer
~50 sHanding out a reference to a mutable object couples you to everything the holder may do with it, forever: mutate it, retain it, pass it on. Exchanging copies or messages deletes that aliasing — not the coupling. - **Erlang/Elixir**: per-process heaps, copy-on-send (large binaries are the refcounted exception, and they are immutable). The runtime enforces it; coupling moves to message shape, mailbox and ordering assumptions, and lifecycle via links, monitors and supervision. - **Go**: one address space, so channels are a discipline, not a mechanism. Sending a struct with a slice, map or pointer field copies the header and both goroutines keep the same backing array; only the `-race` detector, at runtime, notices. - **Rust**: a send moves ownership and `Send`/`Sync` are compile-checked; sharing is still available as `Arc<Mutex<T>>`, but it is explicit in the type. - **Java/Python**: any reference handed out is permanent coupling to a mutable graph. You converted object coupling into schema and protocol coupling — plus staleness.
code
text · 11 linesshared-heap runtime (channel between two threads of one process):
producer: batch = { id: 7, items: <handle H -> [a, b, c]> }
send(ch, batch) // copies id and the handle H, not [a,b,c]
batch.items[0] = "z" // still reaches [a,b,c] through H
consumer: got = recv(ch)
read got.items[0] // "a" or "z" - unsynchronised race
private-heap runtime (copy-on-send):
producer: send(pid, batch) // deep copy of id AND [a,b,c]
batch.items[0] = "z" // touches the sender's copy only
consumer: got.items[0] // always "a"go deeper
Know the core recall: passing a reference lets the other side change your data later; passing a copy does not. Be able to say that a copy can go stale and costs time to make.
Be precise about what gets copied. A shallow copy of a struct copies pointer-shaped fields as pointers, so aliasing survives a channel send in a shared-heap language. Name at least one runtime where the copy is enforced and one where it is only a convention.
Lead with the relocation argument: name what you deleted (aliasing, temporal, lifetime, transitivity) and what you took on (message shape, versioning, ordering, backpressure, supervision, staleness). Say where the guarantee is compile-checked versus documented, and what that changes about how you review code.
Frame it as a boundary decision, not a coding style. Value-only exchange is the same discipline a network boundary would force, so use it to test whether a proposed service or module split is real, and budget for what it costs: schema governance, a versioning and rolling-deploy story, idempotent receivers, explicit backpressure, and a supervision model. Decide deliberately which units are entities that must keep a single owner.
## The lever, stated precisely "Reduce coupling by exchanging values instead of references" means: instead of giving a collaborator a pointer into your object graph, give it a self-contained copy of the data, or send it a message describing what happened or what you want. The collaborator can then do nothing to your state except through the messages you accept. ## What a shared mutable reference actually costs When A hands B a reference to a mutable object, four couplings are created at once and none of them are visible in the call signature: - **Aliasing.** B can mutate the object at any later time, so A's invariants now depend on B's behaviour. - **Temporal coupling.** Both sides must agree on a protocol that is nowhere written down: who locks what, in what order, and "don't touch it after you hand it over". - **Lifetime coupling.** As long as B retains the reference, the whole reachable graph stays alive; in a garbage-collected language this is how caches become leaks. - **Transitivity.** B may pass the reference to C, whom A has never heard of. Copying deletes all four. That is the real prize, and it is why the lever works. ## Four runtimes, four strengths of guarantee **Erlang and Elixir (BEAM).** Each process owns a private heap; a send deep-copies the term into the receiver's heap. The exception — binaries above 64 bytes live off-heap and are reference-counted — is safe because they are immutable. Isolation is therefore a runtime property, not a code review outcome, and it is what makes "let it crash" viable: a crashing process cannot have corrupted anyone else's state. **Go.** Channels live inside one address space. A channel send copies the value, and for a struct that means copying its fields — but a slice, map, pointer, channel or interface field *is* a header pointing elsewhere, so the backing array or map is shared afterwards. Nothing in the compiler stops the sender from writing to it. The `-race` detector is dynamic: it reports races that actually happen in the run you instrumented, and says nothing about the ones you did not exercise. So Go's version of the lever is a genuine convention with real teeth in style and review, and no enforcement. **Rust.** Sending a non-`Copy` value over a channel *moves* it: the compiler rejects any later use by the sender. `Send` (safe to move to another thread) and `Sync` (safe to share by reference) are auto-derived marker traits checked at compile time, so a type like `Rc<T>` — a non-atomic reference count — simply will not cross a thread boundary. You can still share, via `Arc<Mutex<T>>`, but that is a decision that appears in the type and forces locked access. Rust does not remove the coupling; it makes it impossible to create accidentally. **Java, C#, Python.** Any reference handed out is permanent coupling to a mutable graph, and defensive copying is entirely at the author's discretion. Note the concrete divergence that trips teams: JVM actor frameworks copy messages only when they cross a network boundary. A *local* send passes a reference, and "messages must be immutable" is documentation. A message class with one mutable field passes every local test and races in production the day two nodes become one. Python gets true copy semantics only when you cross into `multiprocessing`, where the copy is a pickle — and unpicklable things (locks, sockets, open files) cannot cross at all, which is exactly the schema coupling showing up as a hard error. Swift sits in between: value types copy on assignment, and `Sendable` checking became enforced by default under the Swift 6 language mode. ## What coupling survives, and where it moves to This is the part candidates miss. Total coupling is not reduced to zero; it is relocated: - **Message shape.** Field names, types and meanings are now a contract between two units, and a bare tuple or map is a weaker contract than a type. - **Versioning.** During a rolling deploy an old sender talks to a new receiver. This coupling did not exist when both sides shared one object. - **Ordering and delivery.** BEAM guarantees FIFO only between a given pair of processes, not globally. Once messages can be lost, retried or duplicated, idempotence becomes the receiver's problem. - **Backpressure.** Mailboxes and unbounded queues are where an unmatched producer/consumer rate reappears as memory growth instead of a blocked call. - **Lifecycle.** Links propagate exits bidirectionally, monitors unidirectionally, and supervision trees encode who restarts whom. That is a real dependency graph, just not an import graph. - **Staleness.** A copy is a snapshot. Shared references gave you always-current reads for free; now you need a refresh, invalidation or event story. ## When copying is the wrong move Copy cost is O(size) per hop, so a large structure sent through five stages is copied five times. And **entities cannot be copied**: a thing defined by identity — the one live connection, the one aggregate currently being updated — becomes two divergent things the moment you duplicate it. The workable rule is: copy values, and mediate access to entities through a single owner that others message. ## Using it as a probe Even where you do not adopt it, ask of any pair of units: could these exchange only values? Whatever coupling survives that thought experiment is exactly the coupling that would survive splitting them across a network — which is a cheap way to find out, years early, whether a boundary you drew is real.
- JVM actor frameworks and Erlang are both described as actor systems. What is materially different about the isolation guarantee for a *local* message send?On the BEAM the runtime deep-copies the term into the receiver's own heap, so isolation holds whether the peer is local or on another node. A local JVM actor send passes an object reference; "messages must be immutable" is a documented convention the compiler never checks, and serialization only kicks in when a message crosses a remote boundary. A message class with one mutable field therefore passes every local test and reintroduces shared-state coupling the moment the receiver retains it.
- If copying removes aliasing, why not exchange copies everywhere?Three reasons. Cost: copying is O(size) per hop, so a large payload through a five-stage pipeline is copied five times. Staleness: a copy is a point-in-time snapshot, so you inherit an invalidation or event-notification problem you did not have. And identity: an entity — the one live connection, the one aggregate being updated — cannot be copied without becoming two things that will diverge. Copy values; give entities a single owner that others message.
- Rust lets you either move a value across a channel or share it as Arc<Mutex<T>>. If a team picks the second, what did the language actually buy them?Not the removal of shared-mutable coupling — that is squarely back — but its visibility and its checking. The sharing appears in the type signature where reviewers can see it, the Send and Sync auto-traits verify at compile time that the payload is safe to move or share (a non-atomic Rc is rejected outright), and the Mutex makes locked access the only way to reach the data, so you cannot forget to take the lock. The coupling stays; it can no longer be accidental.
Giving someone a photocopy instead of a key to your filing cabinet. They can no longer rewrite your file — but you are now coupled to the form's layout, to whether they have last week's edition, and to what they do when a field they expected is missing.
saying these in an interview costs you the question
- Claiming message passing "eliminates coupling" — it relocates it to the message schema, versioning, delivery semantics and supervision.
- Believing that sending a struct over a Go channel means the two goroutines no longer share memory — a slice, map or pointer field still aliases the same backing storage.
- Treating "our actor messages are immutable" as a guarantee in a runtime that passes local sends by reference and only serializes across the network.
- Assuming copies are free, ignoring the O(size) cost per hop and the unbounded-mailbox growth that replaces blocking backpressure.
- Using a copied snapshot as if it were live, with no freshness or invalidation policy.
- Trying to copy an entity — something defined by identity — and ending up with two divergent versions of a thing that must be one.