Code as Entropy: Why Good Software Evolves, Not Designed
What makes code "good"? A friend once told me he doesn't know but bad code is code that other developers write. The joke is funny because it's true,and it's true because every developer has a different mental model. What's obvious to you is cryptic to me. What's elegant in your head is spaghetti in mine.
Most developers will list qualities like readability, maintainability, or elegance. But these are artifacts of good code, not descriptions of how to achieve it. They emerge when you follow the right evolutionary process, but they're subjective,dependent on those differing mental models.
The deeper more fundamental question is: what makes code capable of change? Because requirements always change, and code that can't adapt is dead code.
It took me decades of working with bad code—and writing plenty of bad code myself—to distill the simple principles I present here. The answer lies in physics. Specifically, in entropy, dependency graphs, and evolution. Good code isn't designed,it evolves through a dance with entropy.
Here's the nuance: all code increases in entropy over time. This is the 2nd law of thermodynamics and it applies to code too. Like biological cells that maintain internal order while increasing entropy in their environment*, good code minimizes entropy within each module while increasing entropy of the whole system.
This is the key insight of biology and code: distribute complexity outward (more modules, more namespaces) while keeping each module internally coherent. The system becomes more entropic, but each piece stays organized. This is what makes biology and code adaptable.
The Only Objective Measure: Malleability
Code quality is subjective. What's "clean" to one team is obtuse to another. Shared mental models help (Domain Driven Design attempts this), but teams still disagree.
There is, however, one objective standard: good code is code that adapts to changing requirements with low cost. Bad code is rigid,small changes cascade through the system, forcing rewrites instead of refinements.
This isn't about aesthetics. It's about adaptability and survival. Software that can't change dies when the world around it shifts. Malleability is the only fitness function that matters, it's why we call it "soft"ware, not "hard"ware.
High Cohesion and Loose Coupling: The Core Principle
Kent Beck spent 17 years learning to explain cohesion and coupling†. The principle is simple:
- High cohesion , things that change together should live together
- Loose coupling , things that change independently should be isolated from each other
When modules are tightly coupled, a change in one module ripples through all its dependents. You can't modify behavior without understanding the entire dependency graph. The cost of change becomes prohibitive.
When modules are loosely coupled but internally cohesive, changes are local. You modify what needs to change without touching anything else. The system adapts without rewriting.
This is measurable. Draw your dependency graph. If it looks like spaghetti, your code is rigid. If it looks like cleanly separated clusters with minimal cross-connections, your code can evolve.
Why Spaghetti Code is the Default State
By analogy to statistical mechanics: there are more ways to write tightly coupled code than loosely coupled code.
Every function you write creates potential dependencies. Without deliberate effort, those dependencies accumulate randomly. The probability space of "spaghetti architectures" vastly exceeds the probability space of "clean architectures."
This is entropy at work. In information theory, entropy measures how many bits of information you need to describe a state. A high-entropy system has many degrees of freedom,many possible configurations,requiring more information to specify exactly what's happening.
In code: high entropy means a programmer needs more bits in their head to understand the system, tracking not just the static structure but the runtime behavior, and more code to describe its state. Tight coupling (spaghetti) has vastly more possible configurations than loose coupling (clean layers). Without deliberate effort, systems drift toward high entropy simply because there are more ways to be messy than organized.
This is why high-entropy code is easier to write but harder to read and change. Writing tightly coupled code requires no planning, dependencies form naturally as you reach for whatever function you need. But reading it requires reconstructing all those implicit connections in your head. And changing it means tracing ripple effects through the tangled web.
Additionally, premature naming constrains your design. When you name a module before you understand its boundaries, you lock in assumptions. Structure should guide naming, not the other way around.
Example: You create a user-service namespace early in the project. Later you realize some functions deal with authentication, others with profiles, others with permissions. But the name user-service doesn't reflect these distinctions. Now you face a choice:
- Refactor into
auth,profiles, andpermissions(correct but requires renaming and moving code) - Keep everything in
user-service(easy but hides the real structure)
The premature name created resistance to the right structure. If you'd started with core or users and let the cohesive clusters emerge, the structure would have guided you toward auth, profiles, and permissions naturally.
Frameworks like Ruby on Rails make this worse by forcing everything into MVC: models, views, controllers. This is solution domain vocabulary, not problem domain vocabulary. Your e-commerce app doesn't naturally think in "controllers",it thinks in orders, payments, inventory, shipping. But Rails forces you to organize by technical pattern instead of business concept.
The result: OrdersController, PaymentsController, InventoryController,where the suffix "Controller" adds no information and the real domain concepts (orders, payments, inventory) are buried in a solution-domain structure. You've let the framework name your modules before you understand the problem.
Entropy in Software: Distribute to Organize
Entropy is often described as "disorder," but that's vague. In information theory, entropy measures how many bits of information you need to describe a state.
A high-entropy system requires more information to specify what's happening. In code, this translates to:
- More bits in your head , higher cognitive load to build a mental model
- More code to describe state , verbose, scattered logic
- More files to read , understanding requires jumping through many namespaces
Here's a concrete measure: how many files must you open to understand or modify a feature? If you need to read 10 scattered files, that's high entropy,you need a lot of context (information) to reconstruct what's happening. If one cohesive module contains everything, that's low entropy,all the information is localized.
This is why high-entropy code is hard to understand: it demands more information to describe, creating cognitive overload.
The solution is counterintuitive: increase system entropy while decreasing module entropy.
Start with a single namespace containing all your code. As it grows, identify cohesive clusters,groups of functions that change together. Extract them into separate namespaces. Repeat as the system evolves.
This increases system entropy (more namespaces, more distribution) while decreasing module entropy (each namespace is internally coherent). The result is a codebase where structure emerges naturally rather than being imposed prematurely.
;; Phase 1: Everything in one namespace
(ns myapp.core)
(defn fetch-user [id] ...)
(defn save-user [user] ...)
(defn validate-email [email] ...)
(defn send-notification [user] ...)
;; Phase 2: Cohesion emerges, extract clusters
(ns myapp.users)
(defn fetch [id] ...)
(defn save [user] ...)
(ns myapp.validation)
(defn email [email] ...)
(ns myapp.notifications)
(defn send [user] ...)Notice: we didn't start with a grand architecture. We let the code tell us where the boundaries are. This is evolution, not intelligent design.
Evolution Requires Selection Pressure: Three Fitness Functions
In biology, evolution works because natural selection kills unfit organisms. In software, evolution works because three forces kill unfit code:
- Automated tests — code that breaks contracts doesn't survive. Tests verify correctness.
- Developer cognitive load — code that takes too long to understand or modify doesn't survive. If it takes developers forever to learn the codebase, it's bad code because it makes change expensive. High cognitive burden is a selection pressure against that code.
- Business economics — code that's too expensive to maintain doesn't survive. Here's the irony: well-funded projects can sustain high entropy for a long time, allowing code rot to accumulate unchecked. Under-funded projects face immediate death if they don't adhere to good principles,they're forced to maintain low entropy to survive. Economic constraint is a selection pressure that well-funded teams often lack until it's too late.
All three pressures select for the same property: malleability. Code with high cohesion and loose coupling passes all tests: it's easy to verify, easy to understand, and cheap to change. (We'll explore the cognitive dimension more deeply later.)
Without these pressures, you have no feedback loop. Changes accumulate with no verification. Code drifts toward rigidity because there's nothing selecting for malleability.
So how do you create these pressures in practice? The Clojure REPL provides the ideal environment: low-cost experimentation lets you try variations quickly, observe their behavior, discover what works, and formalize the winning patterns as tests. This is adaptive evolution, not waterfall planning—you're creating selection pressure in real-time.
;; In the REPL: experiment
(comment
(let [user {:email "invalid"}]
(validate-email (:email user)))
;; => false, good
(let [user {:email "valid@example.com"}]
(validate-email (:email user)))
;; => true, good
;; Formalize as test
(deftest email-validation
(is (false? (validate-email "invalid")))
(is (true? (validate-email "valid@example.com"))))
)These three pressures work together. Code that breaks tests doesn't survive. Code that's too cognitively expensive to modify doesn't survive. Code that's too expensive to maintain doesn't survive. Code that passes all three tests—correct, understandable, and cheap to evolve—survives and reproduces (gets reused).
The Brittleness Paradox: When Tests Become Technical Debt
But here's the uncomfortable truth: tests have a cost. The wrong tests make your system more rigid, not less.
If you test every internal function, you've coupled your tests to your implementation. Now you can't refactor the internals without rewriting all the tests,even though the module's behavior (what it promises to the outside world) hasn't changed.
This is test-induced brittleness. The tests that were supposed to enable change now prevent it.
Test the Contract, Not the Implementation
The solution: only test the interfaces between modules.
The interface is where coupling lives. If Module A depends on Module B's interface, that interface is a promise. Breaking that promise ripples through the system. That's where you need tests,to ensure promises hold even as implementations evolve.
Internal changes within a module? Those should be free to evolve as long as the contract stays intact. If you have tests for every private helper function, you've made those internals part of the public API from a testing perspective.
;; Bad: Testing internals creates brittleness
(deftest test-internal-parser
(is (= [:token "foo"] (internal-parse-token "foo"))))
;; Refactor internal-parse-token? You have to update this test.
;; Even though the PUBLIC behavior didn't change.
;; Good: Test the public contract
(deftest test-parse-api
(is (= {:tokens ["foo" "bar"]}
(parse "foo bar"))))
;; Refactor internal functions freely.
;; As long as parse/1 returns the right shape, the test passes.This is the art of testing: identifying where the true coupling boundaries are and guarding those, not the implementation details.
The Knowledge Preservation Problem
But if you don't test internals, how do you understand them when you need to modify the module?
I encountered this on a project. The lead engineer had a philosophy: "Test only external contracts, not internal implementation details." Sound reasoning. But then they added constraints:
- All non-public functions must be marked
defn-(private) - No committed
(comment ...)blocks with REPL experiments - No internal tests in separate directories
The result? Every time I had to modify something in a module, I had to write throwaway REPL tests to understand the internals, then delete them. The next person (including future me) would have to rediscover the same edge cases, the same assumptions, the same behavior.
We were paying the exploration cost repeatedly instead of amortizing it across the team.
The Middle Path: Comment Blocks as Ephemeral Knowledge
The solution isn't to test everything. It's to preserve knowledge without coupling to implementation.
Commit (comment ...) blocks with REPL experiments. These aren't tests,they're ephemeral documentation. They show:
- How the module's internals work
- What edge cases exist
- What assumptions the author made
- How to quickly verify behavior during changes
But here's the critical part: comment blocks decay. Code evolves. Function signatures change. Edge cases get handled differently. Assumptions that were true yesterday become false today.
Unlike automated tests that fail in CI when they're wrong, comment blocks silently rot. They become lies,misleading the next person who runs them.
The cultural solution: delete ruthlessly when they lie.
Don't try to maintain every comment block perfectly. That's as expensive as maintaining tests without the benefit of automation. Instead:
- Write comment blocks while developing to clarify thinking
- Commit them,they're valuable while accurate
- When someone finds they lie, delete them (don't fix, just delete)
- The ones that survive are the ones that keep reflecting reality
This creates natural selection for documentation. Comment blocks have value while they're true. When they're false, the cost of deletion is lower than the cost of maintenance. The team culture enforces it: "If it's wrong, delete it. Don't leave lies.".
(defn- parse-tokens
"Internal: splits input into tokens. Handles empty input gracefully."
[input]
(if (empty? input)
[]
(str/split input #"\s+")))
(comment
;; Exploration from 2024-10-15
;; If you find these examples don't work, DELETE this block
(parse-tokens "foo bar baz")
;; => ["foo" "bar" "baz"]
(parse-tokens "")
;; => []
(parse-tokens " extra spaces ")
;; => ["extra" "spaces"]
;; Edge case: leading/trailing spaces are stripped
)
;; Three months later, someone refactors parse-tokens to return maps:
;; They run the comment block, see it's wrong, and DELETE it.
;; No guilt. No maintenance burden. Just removal.Future you (or a teammate) can run these in the REPL to quickly understand how the function behaves. When the examples stop working or become misleading, delete the entire comment block. No guilt. No heroic maintenance. Just ruthless culling of stale knowledge.
All Documentation Decays
It's tempting to think prose documentation is safer than runnable examples. It's not. Prose rots just as fast,maybe faster, because you can't run it to verify it's still true.
The docstring that says "Returns a vector" becomes a lie when you refactor to return a map. The comment that says "Handles nil gracefully" becomes misleading when you change the nil-handling behavior. The README that explains the architecture becomes obsolete when you restructure the codebase.
All documentation has a half-life. The question isn't how to prevent decay,it's how to make decay cheap to handle.
Options:
- Automate it , Tests fail in CI when wrong. High cost to maintain, but failures are visible.
- Delete it , Comment blocks deleted when wrong. Low cost, but you lose knowledge.
- Accept it , Prose documentation decays silently. Lowest maintenance cost, highest confusion cost.
There's no perfect answer. Just different tradeoffs. The key is choosing consciously based on what you're optimizing for.
When to Test Internals (Temporarily)
Sometimes a module is complex enough that you need confidence in its internals while building it. In those cases, write tests as scaffolding:
- Write internal tests to clarify logic and explore edge cases
- Use them to validate assumptions during development
- Delete or demote them once the module stabilizes, keeping only interface tests
Or better yet: if a function is complex enough to need permanent testing, extract it into its own module with a tested public interface. Complexity often signals unclear boundaries.
The Real Fitness Function
The measure isn't "how many tests do you have." It's: how cheaply can you change without breaking promises?.
Test the contracts between modules. Preserve exploration knowledge in comment blocks. Let internals evolve freely. The system stays malleable because tests guard promises, not implementation.
The Central Question: Do You Need to Understand to Modify?
Earlier we introduced developer cognitive load as one of three selection pressures that kill unfit code. Here's why this matters: good architecture reduces the cognitive burden required to make changes safely.
You don't always need to understand the entire system to modify part of it. If your modules are loosely coupled, you can change one without deeply understanding the others. The dependency graph protects you.
Bad architecture forces you to understand everything before changing anything. The spaghetti graph means modifications in one place might break something three layers away. You need a complete mental model just to make a one-line fix.
Good architecture isolates risk. Change is local. Understanding is bounded. The system is malleable because the cost of modification stays low.
Practical Workflow: From Chaos to Structure
Here's the process distilled:
- Start messy , Write everything in one namespace. Don't prematurely organize.
- Experiment in the REPL , Use
(comment ...)blocks to explore behavior and discover edge cases. - Observe cohesion , As code grows, notice which functions change together.
- Extract clusters , Move cohesive groups into separate namespaces.
- Minimize coupling , Reduce dependencies between namespaces.
- Test interfaces , Write tests for the contracts between modules, not internal implementation details.
- Commit comment blocks , Preserve REPL experiments as runnable documentation for future maintainers.
- Iterate , Repeat as requirements change.
This isn't top-down design. It's bottom-up evolution guided by feedback loops. Structure emerges from observing the system's natural boundaries.
Dependency Graphs: Seeing Quality Without Understanding the Code
Here's a remarkable property of dependency graphs: you can assess code quality by visual inspection alone, without understanding the code or the domain.
A good dependency graph looks layered,clean separation between modules, minimal cross-connections, clear flow from low-level primitives to high-level features. You can see the structure at a glance.
A bad dependency graph looks like spaghetti,dense tangles, circular dependencies, everything connected to everything. The visual chaos reflects the architectural chaos.
This is objective. You don't need to read a line of code. The graph shape tells you:
- Spaghetti (densely tangled) → changes cascade everywhere, rigid code, high coupling
- Layered (clean strata) → changes are local, malleable code, loose coupling
- Clustered (islands with bridges) → cohesive modules with minimal inter-module dependencies
Tools like lein-ns-dep-graph or clj-depend (Clojure), madge (JavaScript), or pydeps (Python) can generate these visualizations. The graph doesn't lie,tight coupling is visible as tangled edges, loose coupling as clean separation.
Use this as your fitness metric. If the graph is improving (clearer layers, fewer tangles), your code is evolving toward health. If it's degrading (more tangles, circular dependencies), you're accumulating technical debt.
The beauty: you can review a PR's dependency graph changes without understanding what the code does. Does it add more tangles or clean up the structure? The visual tells the story.
Why This Matters for Datom.World
Datom.World's architecture isn't just inspired by the entropy management philosophy,it reifies these principles as infrastructure. Where traditional codebases fight entropy through discipline and testing, datom.world embeds entropy management into the substrate itself.
Immutable Streams: Entropy Distribution by Design
The central insight above is: distribute complexity outward (more modules) while keeping each piece internally coherent. Datom streams are the architectural embodiment of this principle:
- System entropy increases naturally , append-only streams grow over time, accumulating more datoms, more information, more history
- Information entropy stays minimal , the datom (data atom) is the ultimate invariant of information in datom.world. Its fixed 5-tuple structure
[e a v t m]eliminates structural variability. No shape negotiation, no version drift, no schema coordination,just five positions that never change. This restriction reduces substrate entropy to the minimum while preserving expressive power - Loose coupling via interpretation , consumers extract different meanings from the same stream without coordinating, no shared mental model required
- Minimal API surface , inspired by Plan 9's "everything is a file," datom.world has streams as the only API with just two functions:
readandwrite. Fewer operations mean fewer coupling points - Evolution becomes queryable , time-travel via streams means architectural evolution isn't hidden in git history,it's a Datalog query
This inverts the typical problem. In traditional code, you fight entropy accumulation. In datom.world, entropy accumulation is the feature,the stream wants to grow, and that growth is harnessed rather than resisted.
Streams achieve ultimate loose coupling: producers and consumers know nothing about each other except through data. The producer writes datoms without knowing who will read them or how they'll be interpreted. The consumer reads datoms without knowing who wrote them or why. They interact purely through data,no shared interfaces, no method calls, no contracts beyond the datom shape itself.
This is fundamentally different from traditional APIs where the producer (server) and consumer (client) are tightly bound through interface contracts. Change the API signature, and you break every consumer. With streams, interpretation creates meaning. The same stream can be interpreted in completely different ways by different consumers:
- One consumer sees user activity events for analytics
- Another sees the same datoms as an audit log
- A third interprets them as training data for ML models
- All from the same stream, no coordination required
This isn't a new idea,it's Clojure's own philosophy applied to data. Clojure itself is layered interpretation over streams:
- Character stream → Reader , the Clojure reader interprets a stream of characters as data structures
- Data structures → Evaluator , the evaluator interprets those data structures as executable code
- Multiple interpreters, same substrate , the character stream doesn't "know" it will become code, the reader doesn't "know" what the evaluator will do
Datom.world extends this principle: if Clojure proves that layered interpretation over simple streams creates powerful expressiveness at the language level, datom.world applies the same design to the data level. Streams of datoms are the universal substrate, interpretation layers extract meaning, and no single consumer owns the semantics.
Like Plan 9's uniform file interface that made diverse resources composable through a tiny API (open, read, write, close), datom.world's two-function stream interface makes everything composable while minimizing the surface where tight coupling can form. But streams go further: they decouple not just the mechanism of access but the meaning extracted from it.
Continuations: Loose Coupling as a Runtime Guarantee
We argued earlier that "things that change independently should be isolated." Continuations enforce this architecturally, not culturally:
- No shared mutable state , continuations can share data through streams, but they cannot share mutable state. All communication happens through immutable datoms, making dependencies explicit and side effects visible
- Mobile computation , Yin.vm's CESK machine unifies functions, closures, continuations, and eval into a single evaluator. This means continuations are first-class values that can migrate between nodes without coupling to the host environment,computation moves to data, not the other way around
- Capability-based security , access is controlled through Shibi, datom.world's capability token system. If a continuation holds a Shibi token for a stream, it can access that stream,otherwise it cannot. Stream access = dependency, and the coupling is visible (you can inspect which Shibi tokens a continuation holds) and controllable (tokens can be granted, revoked, or delegated)
Continuations can share data,they just do it through immutable streams rather than shared memory. This makes coupling explicit: if two continuations need to coordinate, they exchange datoms through a known stream. You can't accidentally create tight coupling when all communication must flow through data, not mutable references. The architecture enforces the dependency hygiene that most codebases achieve only through code review discipline.
Single Source of Truth: Solving the Entropy Crisis
Earlier we described spaghetti code as having "too many possible configurations." The four-way translation problem (database ↔ API ↔ backend ↔ frontend) is exactly this entropy crisis:
- High entropy , need to track state across four independent representations
- Tight coupling , change in one layer ripples through all four, requiring synchronized updates
- Coordination overhead , every change demands translation code, validation, and keeping representations coherent
Datom.world's solution: one canonical representation as immutable streams. The database is the API is the backend state is the frontend model,just different interpretations of the same stream. This collapses four sources of entropy into one, radically reducing system complexity.
Queryable Evolution: Understanding Emergence as Data
The evolutionary workflow described above assumes you can refactor freely because tests provide safety. But what if you need to understand how the code evolved to its current state? Traditional systems require git archaeology,reading commit messages, diffing files, reconstructing mental models.
Datom.world makes evolution first-class data:
- Time-travel via streams , query "what was the dependency graph at transaction T?" directly
- Datalog over code , ask "which functions changed together?" to discover cohesion clusters empirically
- Replayable evolution , understanding emergence isn't archaeology,it's running a query
This principle,"observe cohesion, extract clusters",becomes mechanized. You don't manually notice which functions change together,you query the stream history and discover it.
Schema-on-Read: Ultimate Malleability
Our fitness function is: "how cheaply can you change without breaking promises?" Datom.world's schema-on-read takes this to its limit:
- No schema migrations , adding new attributes costs zero, old datoms coexist with new interpretations
- Backwards compatibility by default , queries interpret what exists, gracefully handling missing attributes
- The promise is the query interface , not the data structure,evolve the schema without coordinating with historical data
- Additive evolution , existing interpreters continue working unchanged while new interpreters add new functionality through new interpretations of the same stream. The stream doesn't need to know about new capabilities,they emerge from how you read it
Traditional databases force you to migrate the world forward (ALTER TABLE, backfill nulls, update every row). Datom.world lets you reinterpret the past without rewriting it. The fitness function isn't "can we change without breaking tests?" but "can we extract new meaning from old data without touching it?"
This is evolution through interpretation: your analytics interpreter from 2024 still works in 2025, extracting the metrics it always did. But a new ML interpreter can now extract training features from the same historical stream that the analytics interpreter is reading. Both coexist, both work, neither requires the other to change. Functionality grows without coordination.
The Meta-Point: Principles as Infrastructure
Most systems treat entropy management as a developer discipline,write clean code, follow SOLID principles, refactor diligently. Datom.world treats it as substrate design:
- Entropy distribution isn't a guideline,it's how streams work
- Loose coupling isn't a code review checklist,it's what continuations enforce
- Single source of truth isn't an architecture decision,it's the only option
- Malleability isn't a quality metric,it's the operational property of schema-on-read
We still apply the evolutionary workflow to our codebase: start simple, let structure emerge through REPL exploration, test interfaces not implementations, preserve knowledge in comment blocks, refactor toward malleability. But the platform itself is built to make these practices natural rather than effortful. The dependency graph stays clean because the architecture wants to resist tight coupling.
This is the deeper point: good software principles shouldn't be things developers remember to do. They should be things the infrastructure makes easy.
And here's the full-circle irony: datom.world itself is a self-funded solo developer project. The economic selection pressure we described earlier—where under-funded projects are forced to maintain low entropy to survive—applies directly to this codebase. These principles aren't just philosophical for datom.world,they're survival requirements. Without the luxury of a large team or abundant funding to sustain code rot, the architecture must keep entropy low by following these principles. Datom.world exists because it practices what it preaches.
Conclusion: Code That Breathes
Good code isn't a static artifact. It's a living system that evolves in response to changing requirements. The measure of goodness is malleability,can it adapt with low cost?
High cohesion and loose coupling provide the structure. Entropy guides the distribution. Three selection pressures—automated tests, developer cognitive load, and economic constraints—kill unfit code, forcing evolution toward malleability. Interface tests guard promises, not implementations. Comment blocks preserve ephemeral knowledge,valuable while true, deleted when they lie. Dependency graphs reveal quality at a glance,layered structure vs. tangled spaghetti, visible without understanding the code.
The result is code that breathes: expanding when needed, contracting when simplified, always adapting to the environment around it.
This is software as evolution, not intelligent design. And evolution, given enough time and the right pressures, produces remarkably resilient systems.
Further reading:
- Kent Beck: Software Design is an Exercise in Human Relationships , InfoQ , he spent 17 years learning how to explain cohesion in software design
- Simple Made Easy , Rich Hickey's foundational talk on simplicity and complexity
- A New Thermodynamics Theory of the Origin of Life , Quanta Magazine , on how systems maintain low entropy by increasing entropy in their environment
- What is Good Code? , the statistical mechanics argument and dependency graphs