aduwillie.com

Enjoy Coding!

Listen to this article

The easiest path is not always the simplest one. In software, the difference can determine whether a system becomes a platform for growth or a maze the team must keep escaping.

There is a moment every engineer recognizes. The team is close to the end of a sprint. A feature is almost done, the pull request is open, the demo is scheduled, and the remaining work looks small enough to finish before the next standup. Someone notices a shortcut: put the validation in the controller, read directly from a shared object, add one more feature flag, call the downstream service from the current method, reuse an existing helper even though it was built for a slightly different purpose, or hardcode the region rule for now.

In the moment, the shortcut feels reasonable. It feels efficient. It may even feel responsible because the team has a real deadline and the shortcut gets the work over the line. The code ships, the tests pass, the demo succeeds, and the feature works. Because the solution was familiar, quick, and convenient, someone says, “This is simple enough.”

Six months later, another engineer opens the same area of the codebase and has a very different experience. The validation is not just validation anymore. The helper is not really a helper. The feature flag changes behavior in three paths. The region rule has grown into a cluster of exceptions. A small change to the request shape breaks a background job that nobody expected to be related. The team is no longer asking, “How do we add this feature?” They are asking, “Why does this break when we touch it?”

No single decision caused the problem. There was no dramatic architectural failure. The system simply accumulated convenient choices until it became difficult to reason about. That is one of the most common traps in software engineering: we confuse what is easy to do now with what will remain simple to live with later.

The hidden cost of easy

Engineering teams rarely set out to create complex systems. Complexity usually arrives disguised as practicality. It sounds like “just this once,” “we can clean it up later,” “this is faster,” “the old pattern already works,” or “creating another abstraction feels like overkill.” Each of those statements can be true in a specific moment. There are times when shipping quickly is the correct decision, when introducing a new boundary too early creates more ceremony than value, and when an imperfect solution is exactly what the business needs.

The danger is not that engineers make tradeoffs. The danger is when teams stop recognizing the tradeoff. Convenience has a way of making decisions feel cheaper than they are. It reduces the cost of the first implementation while quietly increasing the cost of every future change. The bill does not arrive immediately; it arrives later, usually when the system is under pressure during an incident, a migration, a compliance change, a scale event, or a new product requirement that does not fit the assumptions of the original design.

At that point, the team discovers that the easy path was not free. It was financed. The interest is paid in slower delivery, fragile tests, unclear ownership, more regressions, and the emotional weight of working in a system nobody fully trusts.

Simple and easy are different ideas

The first step toward better design is to separate two words we often treat as synonyms. Easy is about immediate effort. Something is easy when it is familiar, quick, nearby, or comfortable. Easy is personal and contextual: a framework may be easy for one team because they know it well and difficult for another because it is unfamiliar. A shortcut may be easy because the code is already open in the editor. A pattern may be easy because it avoids a design conversation.

Simple, on the other hand, is about structure. Something is simple when it has fewer intertwined parts, clearer boundaries, and less accidental coupling. Simple is not about how quickly the first version can be written; it is about how easily the system can be understood, changed, tested, operated, and evolved over time.

That distinction changes how we evaluate engineering decisions. A global variable is easy, but it is rarely simple. Copying a block of logic is easy, but duplicated business behavior is rarely simple. Adding a boolean parameter is easy, but a method whose behavior changes based on five booleans is rarely simple. Calling another service directly is easy but embedding that service’s assumptions into your core logic is rarely simple. Putting authentication, validation, business rules, persistence, telemetry, and response mapping in a single handler is easy, but a system where those concerns can evolve independently is simpler.

The confusion happens because easy choices often look smaller on day one. Simple choices can look larger at first because they require naming concepts, drawing boundaries, and making dependencies explicit. Those upfront costs are often what keep the system understandable later. Simplicity is not minimalism, fewer files, fewer lines, or clever compression. Simplicity is the absence of unnecessary entanglement.

Complexity is a tax on reasoning

The hardest part of software engineering is not typing code. It is reasoning about behavior. Engineers constantly ask questions like: What happens if this input is missing? Who owns this state? What changes when this flag is enabled? Which service contract does this assume? Can this job run twice? What happens if the downstream dependency times out? Can this rule vary by region, tenant, customer, product, or environment?

In a simple system, these questions have discoverable answers. The structure of the system helps you think. Responsibilities are clear, dependencies are visible, state is limited, variation is isolated, and tests describe meaningful behavior rather than implementation gymnastics. In a complex system, the structure fights you. A small change requires understanding many unrelated concepts at once. You cannot reason locally. You pull one thread and the entire system moves.

A useful metaphor is a braided rope. Each strand may be understandable on its own, but once the strands are tightly braided, you cannot move one without disturbing the others. Many software systems become braided ropes: business rules braided with infrastructure details, data access braided with authorization decisions, product behavior braided with deployment configuration, regional policy braided with core domain logic, observability braided with control flow, and temporary migration logic braided with permanent behavior.

This is why complexity is so expensive. It consumes human attention. Engineers have limited working memory, and when a system requires people to hold too many relationships in their heads, quality drops. Reviews become shallow because reviewers cannot fully reconstruct the behavior. Testing becomes defensive because the team does not know what might break. Debugging becomes archaeology. The code may still run, but the team no longer moves with confidence.

The seductive story convenience tells

Convenience is persuasive because it speaks the language of delivery. It says, “We need to ship.” It says, “Users are waiting.” It says, “This is not the time for architecture.” It says, “We already have a pattern.” It says, “The simpler-looking code is the better code.” Sometimes convenience is right. Engineering is not an academic exercise, and a team that never ships because it is always searching for an ideal design is not practicing simplicity; it is practicing paralysis.

Convenience becomes dangerous when it wins without examination. The convenient choice usually optimizes for the person making the change today, while the simple choice optimizes for everyone who must understand and change the system after today. Those audiences are not the same. The engineer today wants speed, low friction, and a path that fits the current mental model. The future team wants clarity, isolation, predictability, and safe change.

Great engineering balances both audiences. It respects delivery pressure without sacrificing the system’s ability to evolve. The key is to ask one extra question before accepting the convenient solution: What does this make harder later? That question does not always reject the shortcut. Sometimes the answer is, “Not much.” Sometimes the shortcut is isolated, reversible, and appropriate. But if the answer is, “It couples policy to transport,” “It makes this service depend on another team’s internal schema,” or “It creates hidden state that tests cannot control,” then the team should treat the decision as a design cost, not a harmless convenience.

A story: the endpoint that became a platform

Consider a familiar example. A team builds an API endpoint to create an order. The first version is straightforward: receive the request, validate the payload, check whether the user is authorized, apply the business rules, write the order to storage, publish an event, and return a response. At the beginning, putting that flow in one place feels efficient. The endpoint is new, the rules are simple, the team understands the context, and splitting things apart may feel unnecessary.

Then the product grows. A premium customer needs a slightly different rule. A new region requires a compliance check. A partner integration sends a different payload shape. A feature flag changes the ordering flow for a small cohort. The event publisher needs retries. The storage layer needs a migration path. The mobile app needs a lighter response. The operations team needs better telemetry. Another service wants to reuse the business rule without going through HTTP.

Each change has a reasonable explanation. Each change is small enough to merge. Each change uses the endpoint as the easiest place to put the next piece of logic. After a while, the endpoint is not an endpoint anymore. It is an orchestration engine, policy interpreter, authorization boundary, data mapper, migration coordinator, feature flag router, telemetry producer, and domain service all at once.

Now the team has a problem. Changing the business rule risks breaking the API behavior. Changing the API shape risks breaking internal domain behavior. Changing the compliance logic risks affecting customers outside the region. Testing one rule requires constructing the entire environment. Debugging one failure requires understanding the whole flow. The system became complex not because the team lacked discipline, but because the first convenient design kept accepting new responsibilities.

A simpler design would have created clearer seams. The API layer would handle transport concerns like request parsing, response formatting, and HTTP-specific errors. The application layer would coordinate the use case. The domain layer would own business decisions. Policy objects or rule providers would handle customer, region, or compliance variability. Adapters would translate between internal models and external dependencies. Persistence would be accessed through explicit interfaces, and events would be published through a boundary that can be tested and retried independently.

This design may involve more named pieces, and it may not look smaller in a diff. But it reduces the number of reasons any one part must change. That is the practical payoff of simplicity.

A small code-shaped example

Imagine a handler like this:

async function createOrder(req, res) {
const user = await auth.getUser(req.headers.authorization);
if (!user || !user.canCreateOrders) {
return res.status(403).json({ error: "Forbidden" });
}
if (req.body.region === "EU" && !req.body.acceptedGdprTerms) {
return res.status(400).json({ error: "Missing GDPR acceptance" });
}
const customer = await db.customers.find(req.body.customerId);
if (customer.tier === "premium" && featureFlags.newPremiumFlow) {
await premiumOrderFlow(req.body, customer);
} else {
await standardOrderFlow(req.body, customer);
}
await db.orders.insert({
customerId: customer.id,
amount: req.body.amount,
region: req.body.region,
createdBy: user.id
});
await eventBus.publish("OrderCreated", req.body);
return res.status(201).json({ ok: true });
}

This kind of code is easy to write because everything is nearby. But nearby is not the same as simple. The function mixes transport, authentication, authorization, policy, customer lookup, feature flag behavior, domain flow selection, persistence, event publishing, and response formatting. If any one of those areas changes, the handler changes. If the handler changes, many behaviors are at risk.

A simpler design would not necessarily be shorter. It might look more like this at the boundary:

async function createOrder(req, res) {
const command = mapCreateOrderRequest(req);
const result = await createOrderUseCase.execute(command);
return mapCreateOrderResponse(res, result);
}

The important work has not disappeared. It has moved into places with clearer names and clearer responsibilities. Request mapping belongs to the API boundary. Authorization can be handled by a policy or application service. Region-specific rules can live behind a policy interface. Premium flow selection can be represented as a domain decision. Persistence can be abstracted behind a repository or gateway. Event publishing can be handled as an explicit side effect.

The point is not to force every codebase into the same layered architecture. The point is to prevent one convenient location from becoming the dumping ground for every kind of change. Simple systems make change paths visible.

Why “clean code” is not enough

It is possible for complex systems to look clean. The formatting can be consistent, the names can be decent, the functions can be short, the tests can pass, and the code can follow the team’s style guide. And still, the system can be hard to change because the underlying responsibilities are tangled.

This is why simplicity is bigger than code aesthetics. A beautifully written function that depends on hidden global state is still risky. A well-named abstraction that leaks another service’s contract is still coupled. A concise helper that quietly changes behavior based on environment variables is still hard to reason about. A small module that owns three unrelated responsibilities is still complex. Good code style helps readability; it does not automatically create good system design.

To evaluate simplicity, look beyond the shape of the code and examine the shape of the relationships. Which parts know about which other parts? Which decisions are local, and which decisions leak across boundaries? Which assumptions are explicit? Which changes require coordinated edits? Which tests fail when a requirement changes? Which modules are hard to explain without telling a long story? Complexity often hides in relationships, not syntax.

Designing for change without over-engineering

One reason teams avoid simplicity work is fear of over-engineering. That fear is valid. Not every branch needs a strategy pattern. Not every function needs an interface. Not every feature deserves a framework. Not every future possibility should be modeled before it exists. Simplicity is not about preparing for every imaginable future; that creates speculative complexity.

The better approach is to design for the kinds of change that are already visible. If the product already behaves differently by region, region is a real axis of change. If customers already have different tiers, customer tier is a real axis of change. If storage is being migrated, persistence is a real axis of change. If multiple services consume the same concept, the contract is a real axis of change. If business rules are changing every sprint, business policy is a real axis of change.

Those are not imaginary futures. They are signals. Simple design listens to those signals and creates boundaries where variation is likely. A helpful question is: Am I abstracting because change is visible, or because change is theoretically possible? Visible change deserves design attention. Theoretical change deserves restraint.

Practical principles for choosing simplicity

Simplicity becomes easier when teams turn it into repeatable habits. These principles are not rules to follow mechanically; they are prompts that help teams notice when convenience is creating hidden coupling.

1. Separate concerns by reason to change

Do not separate code just to create layers. Separate code because different decisions change for different reasons. Transport changes when the API changes. Business rules change when the product changes. Persistence changes when storage changes. Policy changes when compliance, region, or customer behavior changes. Presentation changes when the user experience changes. When these concerns live together, unrelated changes collide. When they are separated thoughtfully, the system becomes easier to evolve.

2. Prefer explicit dependencies

Hidden dependencies make systems feel magical until something breaks. Prefer passing dependencies explicitly, using clear configuration over ambient behavior, and writing functions that reveal what they need. Prefer modules whose behavior can be understood without searching for global state, environment switches, or feature flags buried several layers below the call site. Explicit dependencies may feel slightly less convenient at first, but they make reasoning and testing dramatically easier.

3. Keep state small, local, and intentional

Shared mutable state is one of the fastest ways to create complexity because it turns time into a hidden dependency. Behavior now depends not only on what code runs, but on what ran before it. When possible, treat data as values, favor immutable structures, limit mutation to clear boundaries, and make ownership of state obvious. If many parts of the system can change the same state, many parts of the system can surprise each other.

4. Use boundaries to protect the core

The core of a system should not know too much about the outside world. External APIs change, databases change, vendors change, protocols change, message formats change, and frameworks change. If the core business logic directly absorbs those details, the system becomes fragile. Adapters, gateways, repositories, and interfaces are useful when they protect important logic from external volatility. They are not ceremony when they reduce blast radius. The question is not, “Should we always add an abstraction?” The question is, “What part of the system deserves protection from change?”

5. Make policy visible

Policy logic often starts as a small conditional:

if (region === "EU") {
// special rule
}

Then another region appears. Then customer tier matters. Then product type matters. Then the rule depends on date, contract, environment, or compliance status. Policy hidden inside procedural flow becomes difficult to audit and risky to modify. When policy is important, name it. Move it behind a clear boundary. Make it testable in isolation. This is especially important for systems that operate across regions, tenants, compliance regimes, or customer segments.

6. Test behavior at the right level

Complex systems often require large tests for small behavior because too many concerns are fused together. Simple systems allow targeted tests: domain rules can be tested without HTTP, policy decisions can be tested without storage, request mapping can be tested without business orchestration, adapters can be tested against contracts, and end-to-end tests can focus on integration rather than compensating for missing unit coverage. When a small rule requires a full environment to test, the system is telling you something about its design.

7. Treat shortcuts as debt with a payment plan

Sometimes the convenient choice is necessary. That is reality. But if a shortcut introduces coupling, make the cost visible. Leave a clear note, create a follow-up work item, put a time limit on the exception, and explain what boundary should exist later. Most importantly, do not let temporary structure become permanent by accident. Technical debt is not always bad. Untracked technical debt is dangerous.

Questions to ask in design and code review

The best teams build simplicity into their review culture. They do not rely on one architect to notice complexity; they teach everyone to ask better questions. These questions are not meant to block progress. They are meant to reveal tradeoffs before the tradeoffs become incidents.

  • What responsibilities are combined here?
  • What does this component know that it should not need to know?
  • If this dependency changes, what breaks?
  • Is this behavior explicit or hidden?
  • Can this be tested without unrelated infrastructure?
  • Is this shortcut isolated and reversible?
  • Which future requirement would make this design painful?
  • Are we optimizing for writing the code or operating the system?
  • Does this make the next change easier or harder?
  • Would a new engineer understand this without historical context?

The leadership dimension

Simplicity is not only a technical concern. It is a leadership concern. Teams tend to produce the systems their incentives reward. If an organization rewards only immediate feature throughput, teams will optimize for convenience. If code reviews focus only on whether the code works today, the design will drift toward short-term ease. If deadlines never leave room for design repair, complexity will compound.

Engineering leaders who care about velocity must care about simplicity because long-term velocity depends on it. That means making room for design discussions before implementation, refactoring that reduces future risk, clear ownership of boundaries, documentation of important system decisions, review standards that consider maintainability, and time to remove temporary complexity before it hardens.

This does not mean every feature needs a design document or every refactor deserves a project plan. It means teams should treat simplicity as part of delivery, not as a luxury that happens after delivery. Shipping fast is valuable. Continuing to ship fast is more valuable.

Simplicity is an act of empathy

There is also a human side to this. When you choose a clear boundary, you are helping the engineer who will debug the system during an incident. When you make a dependency explicit, you are helping the new teammate trying to understand the codebase. When you isolate policy, you are helping the future team respond to a compliance change without fear. When you avoid unnecessary coupling, you are helping product teams move faster later.

When you choose simplicity, you are extending empathy across time. That future engineer may be someone else. It may also be you, three months from now, staring at a failure and wondering why a small change had such a large blast radius.

The real payoff of simple systems

Simple systems create options. They let teams replace dependencies without rewriting the core, test important behavior without assembling the universe, help new team members build confidence faster, make incidents easier to diagnose, and allow product direction to change without dragging a mountain of accidental complexity behind it.

They also reduce fear. That last point matters because fear is one of the clearest signs of system complexity. When engineers are afraid to touch code, afraid to deploy, afraid to refactor, or afraid to remove old behavior, the system has become a constraint on the team. Simple systems do not eliminate risk, but they make risk visible and manageable. They give teams the confidence to change.

A practical simplicity checklist

Before merging a design or implementation, pause and ask:

  • Responsibility: Does each part have a clear job?
  • Coupling: Does this create unnecessary knowledge between components?
  • State: Is mutable state limited and owned?
  • Policy: Are important rules named, visible, and testable?
  • Variation: Are known axes of change isolated?
  • Testing: Can important behavior be tested without unrelated systems?
  • Operations: Will this be understandable during an incident?
  • Reversibility: If this is a shortcut, can we undo it safely?
  • Future change: What requirement would make this design painful?
  • Reader empathy: Will the next engineer understand why this exists?

If the answer to several of these questions is uncomfortable, the design may still be valid, but the team should make the cost explicit.

Conclusion: choose the future team

Every convenient shortcut serves the team under pressure today. Every simple design serves the team that must live with the system tomorrow. The best engineers learn to serve both. They understand deadlines, respect delivery, and know that software must create value in the real world. But they also know that the work does not end when the feature ships.

Software has a life after the merge. It will be read by someone tired, changed by someone new, debugged under pressure, extended in ways the original author did not predict, and shaped by the consequences of today’s design decisions. That is why the next time a solution feels easy, it is worth pausing long enough to ask whether it is also simple.

Ask what it couples, what it hides, and what future change it makes harder. Ask whether it protects the core of the system or braids more concerns into it. Ask whether the team is moving fast because the design is clear, or moving fast by borrowing from the future.

Convenience helps us ship today. Simplicity helps us keep shipping. In the long run, that is the better way to build software.

Leave a Reply

Discover more from aduwillie.com

Subscribe now to keep reading and get access to the full archive.

Continue reading