The Technical Candy Problem in Software Development

java dev.to

Goodhart's Law, software architecture, and the questions it lets teams stop asking

"When a measure becomes a target, it ceases to be a good measure."

Goodhart's Law is usually told as a story about metrics gaming: set a target, watch people optimize the number instead of the thing the number was supposed to represent. In software, the story is worse than that. The proxy doesn't just get gamed — pursuing it actively burns the bridge back to the real thing. It doesn't merely fail to deliver quality; it forecloses the path to it later.

This article is about why that keeps happening, decade after decade, under a different name each time.

Two teams, same language, different sociology

Take two Java teams of equal size, given the same ticket volume.

Team A treats each ticket as a self-contained unit of work: pick it up, implement it, write tests, open a merge request, get it reviewed, done. The code is organized into services, entities, repositories, DTOs — a recognizable, auditable recipe. Nobody designs anything, exactly, because there's nothing to design. There's a template, and you fill it in. It is, in a real sense, an assembly line. It is also an accountant's dream: simple, auditable, predictable steps.

Team B treats each ticket as a question first: what does this belong to? Before code gets written, a quick conversation happens — sometimes a scheduled discussion, more often just someone acting as a two-minute mirror to bounce an idea off. Does this fit the existing model? Does the model need to extend, or is this evidence that an earlier understanding was wrong and the model needs correcting? Logic ends up living on the domain object it actually concerns, not in a service that pokes at data from outside.

Both teams are nominally writing object-oriented Java. Only one of them is doing object-oriented design. The other is writing procedural code with class syntax — a warning about Java that's been repeated since the early 2000s and apparently needs repeating every decade since.

The visible difference is architectural. The real difference is social. Team A's structure requires no shared understanding beyond "here's how we lay out a service." Team B's structure requires continuous, cheap, ongoing conversation about what the domain actually is. That conversation is what produces the two real payoffs people associate with good OO: knowledge spreads across the team because everyone's heard the two-minute version of what everyone else is building, and bugs are easier to find because behavior lives at the logical place it concerns, not scattered across a service that orchestrates several unrelated objects at once.

Team A's review process, by contrast, happens too late to catch any of this. A merge request review can genuinely catch things: an actual bug, an off-by-one, a missed edge case, even a template followed incorrectly — wrong layer called from the wrong place, a repository doing something a repository shouldn't. What it essentially never catches is a wrong concept. Nobody reviews a merge request and concludes that the domain itself has been misunderstood, because by the time the diff exists, the architectural choice is sunk cost. Rejecting it means throwing away finished work; approving it means leaving a comment about a variable name or a missing null check. Those costs aren't symmetric, so review reliably degrades to what's cheap and checkable against a template, never to "is this even the right shape for this to take." That question needed to be asked while the shape was still free to change, not after.

The candy problem

Here's where it gets interesting. Team A's approach isn't the result of laziness or incompetence. It's the default outcome whenever the tools make it easy to avoid asking "what owns this."

Every framework and pattern that gets marketed as good architecture offers a way to satisfy that immediate need without addressing it — technical candy, in a fairly literal sense. Candy solves hunger for the next twenty minutes; it does nothing for what the body actually needed, and the debt it leaves doesn't come due immediately, or at low volume. A domain model is supposed to capture the actual mechanics of the business, not just whatever a ticket asked for — it's meant to hold the rule the ticket was a symptom of. Each tool below satisfies the ticket instead. On a small domain, with few objects and little accumulated history, that trade barely registers, because there isn't enough complexity yet for the missing mechanics to matter. It's precisely as complexity rises that the debt compounds — there's more of it to service, and less slack left to absorb the interest:

  • Fat services give logic a home that isn't the domain concept it concerns. The common result is what's usually called an anemic domain model: entities that are little more than getters and setters, with every rule about what they're allowed to do living somewhere else. "OrderService" can hold behavior that has nothing to do with what an Order fundamentally is — it's just where the ticket's logic went.

  • Technical layering puts accidental complexity first. Brooks' distinction still holds: essential complexity is the actual complexity of the problem domain, accidental complexity is whatever the tools and techniques used to solve it add on top. Accidental complexity is supposed to serve essential complexity — the technical structure exists to express the domain, not to compete with it. Mainstream layering conventions routinely invert that order. A vocabulary of technical roles — Repository, Value Object, Aggregate, Factory, among others — gets applied first, and domain responsibility gets fit into whatever slot that vocabulary provides, rather than the other way around. The object best placed to own a piece of behavior, by the actual shape of the domain, doesn't stop being the right owner just because the technical vocabulary has a separate box that convention says the behavior should go in instead. Where the two disagree, the convention usually wins, because it has a name and a slide in the architecture deck, and the essential answer doesn't. That's accidental complexity leading essential complexity, exactly backwards from what Brooks described — and it's popular for the same reason fat services are: it gives everyone a template to follow instead of a domain to understand.

  • Workflow and eventing engines let one process step outside multiple domain objects and orchestrate them from above — the same move a hand-rolled orchestration script makes, except now it's an industry-standard framework, which makes it harder to notice as procedural. "We're using a workflow engine" sounds like an architecture decision. It's usually a way of saying "we don't want to figure out whose responsibility this is" and letting a generic runtime hold the ambiguity instead.

  • Microservices are a bet on where the domain's real seams are — placed while the team has the least evidence it will ever have about where those seams actually sit. That's true even when the split isn't the obviously lazy version, drawn along the org chart or wherever the system currently hurts. A service boundary is, functionally, a hypothesis about the model made expensive to revise: encoded in network contracts, versioning, and deploy coordination, rather than in files that happen to sit near each other and can be moved in an afternoon. Drawing bounded contexts up front is the same category error Waterfall made — deciding the shape of the whole thing before enough is known to decide it — except the bill for microservices comes due later and larger.

  • Boilerplate reduction promises simpler code by making meaning-bearing decisions disappear, not by removing decisions that never existed in the first place. A blanket-generated equals and hashCode makes a call about what identity means for that object; a generated setter decides the field is freely mutable; a query built from a method name by naming convention replaces text a reviewer could check against the schema with a rule that has to already be known to be checked at all. None of that is repetition being eliminated — it's a decision that used to be visible in the code, now made invisibly, by a framework, on the team's behalf. What disappears from the IDE doesn't disappear from the system: it moves into container wiring, dynamic proxies, and a classloader hierarchy that never shows up in a diff or a LOC count, but still has to spin up correctly at runtime and still has to be understood in full the moment something inside it breaks. The codebase reads smaller. The machine that actually runs it has more cogwheels, most of them out of view.

None of these are wrong to reach for in every circumstance. A service genuinely earns its keep sometimes; a Factory can be exactly the right tool for a genuinely complex construction step. Microservices earn theirs too, but for a narrower reason than usually advertised: independent scaling under genuinely different load profiles, independent deployment cadence — not complexity management, which is the justification most often given for them. The problem is that each tool is always available, always sounds like sound engineering, and is always cheaper in the moment than asking what a piece of logic actually belongs to. So they get reached for by default, not by exception — and once reached for, they don't just fail to help. The logic now has a plausible-sounding home that isn't its real one, which makes the real question harder to raise later than if no home had been offered at all.

Bounded contexts deserve one specific caveat, since they're often cited as the case that legitimizes a split — and the caveat holds even for a split made carefully, not just the reckless kind drawn along an org chart. A domain object is a fact for the whole model: Customer means one thing, and if a subdomain seems to need it to mean something different, that's not a context boundary being discovered, it's a sign the object is wrongly defined or being pulled toward a god object, and the fix is finding the second object and letting it point back. What a "Shipping subdomain" can legitimately mean is narrower: today, nothing in the business requires shipping logic to know about billing logic — ordinary OO design working correctly, not a boundary that was designed. That can stop being true the moment the business states a connection that didn't exist before, which is exactly why a split based on it, however carefully reasoned, assumes today's understanding is final at the moment it's had the least time to be corrected — a direct contradiction of what Agile was supposed to guarantee, that the cost of changing course stays low precisely because requirements and understanding are expected to change. It costs what these splits always cost regardless: referential integrity that used to be a foreign key becomes hand-written reconciliation, invisible on the happy path and paid for the moment something fails partway through and two systems are left holding two different versions of the same fact.

The procedural team is the least likely to catch this, not because its engineers are less capable, but because nothing in the day-to-day work gives anyone a reason to ask. As long as the ticket fits the template — service, entity, repository, DTO, and now: which service does this call — there's no moment where "does this boundary still match what we understand the domain to be" comes up. Nobody is defending the boundary. It's simply never examined, because the process that generates the work never pauses to raise the question.

Why the proxy always wins

This is where Goodhart's Law earns its place in the story, and why it's a sharper diagnosis than "some teams are more disciplined than others." Classic Goodhart is a measure that stops representing what it measured once it's optimized for. Software adds a third step: the architecture that grows up around the proxy actively resists being undone, not just un-tracked — a gamed coverage number just tells you nothing useful, but the test suite or the service boundaries built to hit it fight back when someone tries to remove them.

"Well-designed" is hard to measure and hard to put in a status report. So it gets replaced by proxies that are easy to measure and easy to report:

Real goal Proxy that replaces it
Well-tested Test coverage percentage
Well-designed Proper layering (services, repositories, DTOs)
Scalable Number of services
Maintainable Adherence to a named technical pattern (layering conventions, workflow orchestration)
Reduced complexity Reduced lines of code (boilerplate elimination)

Each proxy is legible in a way the real goal isn't. "95% coverage" fits in a slide. "We're microservices now" is a sentence a VP can repeat. "We had a genuinely good conversation about what a Company is during a merger" is not a sentence that survives being put in a quarterly update, even though it's the thing that actually determines whether the system stays maintainable.

Unit testing is the clearest small-scale case. Chasing coverage produces tests that assert on implementation detail — this method calls that mock with these arguments — rather than on behavior through a stable interface. That test suite becomes a second copy of the system's internal wiring, expressed again in test form, with no reason to exist except that a coverage number demanded it. Refactor the production code and a parallel structure breaks with it, one that had no business caring about the wiring in the first place. A production change of a few hours turns into days of fixing tests that were never really testing behavior, just repeating structure.

Microservices do the same thing at the org level: a second copy of coupling, encoded in network contracts and deploy schedules instead of mock assertions. Coupling that used to be visible as "these two classes call each other a lot" becomes invisible as "these two teams need to sync their release," and stops looking like a modeling problem at all — it just feels like the normal cost of distributed systems, so the root cause never gets revisited.

Either way, the team hasn't just failed to invest in quality. It has spent real effort making the eventual correction more expensive than doing nothing would have.

The two questions that resist the proxy

This is the practical version of Team B's two-minute mirror: not a design review, just two questions asked in sequence, before any technical machinery — a new library, a workflow engine, a service split — gets reached for.

The first is why do you need it. Not as gatekeeping, but as a genuine question that, answered honestly, tends to fall back into a domain discussion: what is actually supposed to happen, and whose responsibility is it? Often the answer turns out to be simpler than the machinery proposed to solve it, and the need for the machinery quietly disappears.

Sometimes the need is real, though, and that's where the second question does the work the first one can't: where in the model does this belong? A workflow engine that seemed necessary to coordinate steps across three objects often turns out to be standing in for an invariant that belongs on one of those objects natively, as its own behavior — the engine wasn't decoupling anything, it was providing a home for logic that hadn't found its real one yet. The first question establishes that a home is needed. The second stops the answer from defaulting to a new service, a new engine, a new boundary — the candy that's always sitting there, ready-made — when the harder and usually correct answer is that it belongs on something that already exists.

Together the two questions describe a different order of operations than the one most tickets follow by default. The usual path runs ticket → technical mechanism → implementation: pick a plausible tool, then build. The alternative runs need → domain behavior → ownership → mechanism: establish what's actually required, find what in the model that requirement changes, decide who owns the change, and only then reach for a mechanism to express it — by which point the mechanism is often unnecessary, or much smaller than first assumed.

Neither question scales by making everyone a domain expert. Both scale because they're cheap and because of when they happen: before the code exists, while the answer is still free to change, rather than after a merge request or a deployed service boundary has already made changing it expensive.

This is where the candy debt comes due for the largest bet on the list. Splitting an application into services doesn't touch the business domain's complexity at all — the domain is exactly as complicated the day after the split as it was the day before. What changes is where the bill gets paid: the same rules, the same conditions, the same relationships still have to be honored, except now some of them have to be honored across a network instead of inside one model, with everything that adds — latency, versioning, partial failure. Complexity that lived in the business doesn't go away because it now lives in five services instead of one. It just gets a passport.

That's the specific case. The general one is the same shape everywhere in this piece: not a system that breaks, but a system that quietly costs more to run than it needed to.

None of this is an argument that the alternative doesn't function. A fifty-service application built by asking "which service handles this" instead of "what does this belong to" will run. It will serve traffic, pass its uptime targets, and ship features on a roadmap. Working is a low bar, and nearly every architecture clears it. What changes is what it costs to keep clearing it: extending it means finding which of fifty services should grow, rather than which existing object should; debugging it means tracing a request across network hops and logs instead of reading one call stack; understanding it means holding fifty deploy units in your head instead of one model; and refactoring it — the moment something is discovered to be wrong, which on a long enough timeline is not an if — means a migration across contracts and teams instead of a same-day change to a class. None of that shows up as a failure. It shows up as things simply, permanently, taking longer than they should, in a way nobody can point to a single decision for.

That's the actual lever. Not "use fewer frameworks," not "adopt a named pattern" — both become proxies of their own the moment they're followed as rules instead of understood as consequences. Coverage numbers, service counts, technical layering, workflow engines: all of it is what fills the space by default when nobody asks what a thing belongs to while asking is still cheap. The window in which that question is cheap to ask is also the only window in which it gets asked at all — which is exactly why it has to be asked early, on purpose, every time.

Source: dev.to

arrow_back Back to Tutorials