Essay 1

Fifteen hundred classes and no visible shape

Why is writing business software so hard? Essay one.

Six months into a new build, someone needed to add a column to a grid. The data was already in the database. The column simply needed to appear on a screen.

It took a senior developer two weeks, and the change touched around forty files. This was not a badly funded project assembled in a hurry. Numerous teams were working on it. It was run according to a scaled process that had been chosen deliberately and approved at the right level. Nobody was cutting corners. If you had audited it against the practices the industry currently recommends, it would have passed. The codebase held more than fifteen hundred classes, arranged into more than fifty packages. I want to be careful about how that story is used, because the obvious reading is the wrong one. The code was not written by careless or inexperienced people. The engineers were skilled, hardworking, dedicated and thorough. They wrote tests, followed the architecture and took the principles seriously.

I should also declare an interest. This was a system I was involved in, in a senior enough position that the structure was my responsibility as much as anyone else’s. I was standing directly in front of this and I did not see what was wrong. That was not early in my career. So the question is not who to blame. The question is why doing everything right produced this.

Scope

A word about the territory, because the argument does not generalise beyond it and is not meant to. This is about business software: commercial systems built in-house by medium and large organisations to run some part of what that organisation does. Not one-person tools, where almost any structure works, and not systems operating at a scale where a handful of companies in the world have the problem and have published papers about it. The middle is where many of us have spent most of our careers, and where surprisingly little of our architectural discussion seems to begin. We have better languages than we had, better tooling, faster feedback, version control that finally works, and now machines that will write code for us. Fifty years of improvement, much of it real. And building these systems is still enormously hard.

This essay is one piece of evidence, not an answer.

The architecture

Follow the column through the system. It starts in a table. There is a repository class that fetches it. There is a mapper that turns the row into a domain object. There is a service that applies the business logic. There is a controller that exposes it. There is a serialiser that turns it into JSON. On the other side there is a React application with its own model of the same data, its own state handling, and a grid component that renders it. Around each of those sit tests. Now ask the question that makes the point: which of those layers would you delete? You cannot answer. Every one of them is individually justifiable. Each was introduced by a competent person for a defensible reason, and each can be defended by citing a principle that is good advice. The repository isolates persistence. The mapper keeps the domain clean. The service holds the logic. The serialiser is the wire contract. The React component owns presentation. Nothing on that list is stupid, and yet the change had to cross all of them.

There was one further detail that turned this from a code problem into an organisational one. The front end and the back end lived in separate repositories, so the change could not even be seen as one change. It meant two pull requests, two reviews, two pipelines and, in practice, two conversations between two groups of people, in order to display a value that already existed. The boundary had been drawn between technologies. The thing that actually changed together was a feature. That is Conway’s observation in exactly the form you do not want: the architecture reflected the communication structure of the organisation instead of the shape of the change. A sharp reader can reasonably object that if we had put all of this into one module, we might still have needed to edit forty files. Forty edits are still forty edits. I think that objection is important because it gets closer to the real cost.

The problem was not simply the number of edits. It was the number of boundaries the edit had to cross. Forty files spread across six layers and two repositories can mean six mental models, six sets of conventions, two review queues and several separate places in which the change can be misunderstood. The same number of edits inside one well-contained module is a different kind of change because the reader stays inside one vocabulary and one set of assumptions. The compiler can enforce where the boundary is, and the tests can be run against one public contract. That suggests a test of a module: when the decision it hides changes, does most of the resulting change stay inside it?

Version control gives us evidence. Which files repeatedly change together? Which packages repeatedly change together? Which supposedly independent pieces turn out to move as a group? The history of a system contains evidence about its modules, whether or not the source tree admits they exist.

Coupling and cohesion

I studied software engineering at the end of the nineteen eighties. Coupling and cohesion came out of the structured-design work of Larry Constantine and others in the late sixties and early seventies, and I was taught them as a pair. That matters because they are two sides of the same judgement: you cannot sensibly minimise one without asking about the other.

Listen to how people talk now and the word you hear constantly is decoupling. It has its own vocabulary, its own patterns, its own tooling and its own moral weight. Cohesion is a word I hear far less, and when I do hear it, it is often used loosely as a synonym for tidy. Optimise one side of a trade-off for long enough and you get what you would expect: fifteen hundred loosely coupled classes with no visible shape.

Parnas and modularity

The original source here is David Parnas, On the Criteria To Be Used in Decomposing Systems into Modules, published in 1972. The conventional practice he was arguing against is unfamiliar now. You drew the processing as a sequence of steps, and each major step became a module: first read the input, then circularly shift it, then alphabetise it, then print it.

Parnas built two versions of the same small program, a key word in context index, one decomposed that way and one decomposed around hidden design decisions, and compared what each cost to change. His objection to the processing decomposition was specific. If you split by processing step, every module tends to know how the shared data is represented because each one handles the same data in turn. A change to that representation therefore reaches all of them. The modules are separate in the directory but coupled through the secret they all know.

His alternative was to decompose around design decisions that were difficult or likely to change, and to hide those decisions inside modules so the rest of the system could not see them. That is the information-hiding idea, and the operative word is decision. Now look again at the forty files: fetch, map, apply, serve, serialise, render. It is a processing pipeline, and we have arrived, fifty years later, at something uncomfortably close to the decomposition the paper warned against.

Diagnosing the problem

I remember discussing the project with another senior person. His judgement was blunt: the engineers just don’t get it. I knew what he meant because something about the resulting system felt plainly wrong, but neither of us could explain what the engineers were doing wrong. That was the uncomfortable part. They were experienced, hardworking and disciplined. They wrote tests, separated responsibilities, depended on abstractions and followed the architecture. If you stopped at a particular class or a particular design decision and asked what principle had been violated, there often was not one.

Each local answer was defensible. The total was not.

At the time, that was as far as I got. I did not have an explanation. I certainly was not thinking that we were applying the principles at the wrong level; that idea came years later. What I can see now is that our industry has a strong tendency, when something is difficult, to assume that the answer is more discipline: apply the principles more rigorously, tighten the boundaries, add more architectural guidance. But in this case the engineers were already disciplined. They were already applying the advice carefully.

The possibility that eventually bothered me, years later, was different: what if the advice was good, but we had been applying it to the wrong unit? I have come to think of that as a level error: good advice, applied at the wrong scale. If that was the mistake, greater discipline at the same level would not necessarily have helped. It might have made the problem worse.

Packages as modules

Parnas was writing about decomposition of systems into larger modules. Object orientation then gave us a much smaller unit that was also, quite properly, a module. A class has a boundary, a public interface and hidden internals, so the vocabulary transferred because it fitted. The mistake was not calling a class a module.

The mistake was stopping there.

A package is a module too.

In Java, a package can have a deliberately controlled public surface and a much larger private implementation. It might contain eighty types and expose six, while the other seventy-four are package-private: unavailable to the rest of the program, free to know about one another and free to change together. That is more than an organisational convention; the compiler enforces it.

Java packages are only one example. Other languages have modules, crates, namespaces, assemblies or other mechanisms with different strengths. The point is not the keyword but the existence of a larger unit with a controlled public surface and hidden internals. A small number of modules, each hiding something you can name, is a system a person can begin to comprehend. Fifteen hundred public classes cannot.

Fifteen hundred classes is not what disobedience looks like. It is what obedience looks like at the wrong level.

What a module should hide

If information hiding is the technique, the practical question is what makes a good secret. “What changes together belongs together” is a rule of thumb, but it does not tell you where to start looking. Two categories are especially helpful.

The first is external systems. If you depend on someone else’s service, database or file format, hide it behind an interface expressed in your terms instead of theirs so their implementation details do not leak across your system. This is familiar territory, close to what ports and adapters or hexagonal architecture describes.

The second is decisions about your own business. Take cancelling an order. The public operation is cancel order, but what is hidden is not only an algorithm. It is what cancellation means: reverse the payment, release the stock, notify the warehouse, write the audit record, perhaps apply different rules depending on the state of the order. The caller should not need to know any of that, and if the rules change, the module that owns cancellation changes.

This second category is where much of the difficulty in business software lives. A small class is often too small to hide what a business operation means because the operation crosses several internal concerns precisely because the business decision itself crosses them. The module is where those pieces can be gathered back together.

Responsibility and cohesion

There is an obvious objection to the package argument: the system already had more than fifty packages. The problem becomes clearer when you look at what they were called: controllers, services, repositories, dto, mappers, entities, config, exceptions, enums, constants, util, and its close relative common. Every name describes what kind of technical thing is inside it, while almost none describes a decision the system has made. You could swap the whole package structure into an insurance system, a logistics system or a trading system and many of the names would still make sense.

This is where I think we made an important mistake with the single responsibility principle: we took responsibility to mean technical role. Repositories had one responsibility, persistence; mappers had one responsibility, mapping; controllers had one responsibility, HTTP. Each package looked cohesive if the question was, “What kind of code is this?” But those were not the reasons the business asked the software to change. Nobody asks independently to change mapping, persistence, HTTP and services. They ask to change cancellation, pricing, settlement or order capture, and those technical activities are implementation steps in one business change.

The phrase separation of concerns can lead us into the same trap. It is difficult to disagree with as a principle: unrelated concerns should not be tangled together. But the principle does not tell us what the concerns are. If we decide in advance that persistence, mapping, HTTP, validation and business logic are the primary concerns, then technical layers follow naturally and the resulting architecture looks like a careful application of the principle. If the thing that actually changes together is our understanding of cancellation, however, those technical activities may all be implementation details of one concern.

Seen that way, we had not failed to separate concerns. We had chosen the wrong concerns. We grouped together things that looked technically alike and separated things that changed together.

There was another problem sitting on top of that: almost everything was public. A boundary that nothing enforces is not much of a boundary. If every type in every package can be reached from anywhere, then asking “where could this change reach?” has one honest answer: everywhere. Those fifty packages were folders in the Java sense; architecturally, they were not fifty meaningful modules.

This is also why I have become more sympathetic to some of the SOLID principles rather than less. It is easy to forget that they were reactions to recognisable failure modes, not instructions to maximise some property everywhere. Robert Martin’s original Interface Segregation Principle article explicitly talks about the disadvantages of “fat” interfaces whose operations are not cohesive. Interface segregation is not a demand for the smallest possible interface; it is a demand for a coherent one. A module might expose one Java interface, several interfaces, or simply a set of public types. The number is not the design criterion. What matters is whether the public view presented to a client hangs together.

Martin’s later formulation of single responsibility in terms of an actor or role helps for the same reason: who is asking for this part of the system to change? Parnas helps us decide what belongs together behind the boundary, SRP gives us a way to think about why that responsibility changes and who it is responsible to, and ISP asks whether what a client sees at the boundary is itself coherent.

Dependency inversion also looks different at this level. The package that owns the business policy should not have that policy dictated by a database driver, messaging library or external service. At the boundary, the package can declare the capability it needs in its own vocabulary and another module can provide it. What does not follow is that every dependency inside the package also needs an interface.

Inside a module

Once the package is the unit with the public contract, the classes inside it do not each need to behave as though they were separately published components. They can call each other directly. A package-private mapper can be constructed by the class that uses it. A small calculation can be a method rather than a service with its own interface, factory and registration. Internal collaborators do not each need to be independently replaceable.

Those classes are implementation details. That does not mean abandoning structure inside the package; eighty classes can still be a mess, and cohesion still matters. It means the default changes. You no longer have to create a public abstraction for every collaboration or arrange every internal dependency through a container because direct construction has been treated as an architectural failure. A surprising amount of code disappears when classes are allowed to be implementation details again.

The same shift changes testing. If the package is the meaningful unit, its tests should normally enter through its public surface and verify the behaviour it promises. Testing every internal class independently can work against information hiding because it encourages us to expose constructors, extract interfaces and make internal collaborators accessible so that tests can reach them. The unit is not obliged to be a class. If the package owns the decision, test the decision. That may involve several classes, a database or another external dependency, while leaving the internal structure free to change.

Dependency injection and wiring

Martin Fowler helped settle and popularise the name dependency injection in 2004, and containers subsequently became normal infrastructure in a large part of enterprise development. They solve real problems: they assemble components, manage lifecycles, supply configuration and integrate framework services. This is not an argument that containers should never exist.

But in systems like the one I have been describing, they can also hide something from us. Once every class depends on an interface, every implementation has to be selected and supplied. At fifteen hundred classes the wiring itself exceeds what a person can comfortably track, so a container makes the condition survivable. Annotations, scanning and conventions reconstruct the graph for us.

If we reduce the number of boundaries first, the picture changes. Inside a package, package-private classes can be constructed directly and internal collaborations do not need registrations. The package itself can be assembled as a single component, and the wiring between the modules may then be small enough to write by hand and, more importantly, small enough to read. The dependency structure becomes something a new joiner can be shown rather than something a framework has to discover.

The container did not create the class explosion, but it can make the class explosion tolerable enough that we stop asking why there are so many things to wire. Sometimes we do not need better wiring. We need fewer things to wire.

The local solution

At the time, my own fix was to make the front end data driven. The back end supplied metadata describing the grid, and the front end rendered whatever it was told about. Adding a column stopped requiring a front end change. It worked. It reduced the coupling, and I would do it again given the same constraints. But it solved the grid problem rather than the class of problem. The next time it is a report, or an approval screen, or a workflow, and you find yourself building another metadata mechanism beside the first. Point fixes accumulate into their own layer, and eventually somebody writes a framework to manage them. We had found a good local remedy. We had not understood the structural problem.

How I would design it now

If I were starting that system again, I would begin by trying to name the main business parts of it: perhaps pricing, order capture, settlement, client money, or whatever the business responsibilities happened to be. I would want the list to be short enough that a new engineer could understand the shape of the system without a generated dependency diagram. In Java, I would probably make each of those parts a package with a carefully defined public surface. Most of the classes inside would be package-private. They would call and construct one another directly where that was the simplest thing to do. I would introduce interfaces where they expressed a meaningful boundary or a view that makes sense to a client, particularly between a business module and an external system, instead of between every pair of collaborating classes.

I would test those modules primarily through their public interfaces, accepting that a test might exercise several classes and a database. And I would keep the wiring between the larger modules explicit for as long as it remained small enough for a person to read. None of that guarantees a simple system. The difficult part would still be deciding what the modules are and what each one should hide. But that is exactly the design problem I would now spend my effort on. I would spend much less effort making every individual class independently abstract, injectable and testable. Go back to the column that started this essay. It might still require changes to several classes. What I would want to be different is that those classes were private parts of one recognisable module, changing together behind one public contract, rather than stops on a journey through the architecture of the whole system.

That is the difference I could not put my finger on at the time.

A practical test

Open your own codebase and try to say out loud what it is made of, in under ten items. Not the technologies and not the layers: the main parts, and what decision or business responsibility each one hides. Then look at one of those parts. How many types does the rest of the program need to see? How many interfaces exist only because two implementation classes were not allowed to call each other directly? How many tests know about classes that ought to be private implementation details? How much wiring disappears if the package is allowed to be a module rather than a folder?

If you cannot describe the shape of the system, that is the finding.

References