We Taught Developers Clean Code. We Forgot to Teach Them Context

Why Your Clean Code Is Still Killing Your System


You are about to see something that will change how you look at every codebase you have ever worked on.

Ready?

Here is a piece of code that would make any clean code enthusiast smile.

public class Order
{
    public PaymentStatus Status;
    
    public bool IsPaid() 
        => Status == PaymentStatus.Paid;
}

public enum PaymentStatus
{
    Initial,
    Paid,
}

Beautiful, right?

Small method. Single responsibility. Command-Query Separation respected. No side effects. Pure function. Robert Martin would nod. Kent Beck would be proud.

And the tests. Look at the tests.

public class OrderTests
{
    [Fact]
    public void IsPaid_ReturnsTrue_WhenStatusIsPaid()
    {
        var order = new Order();
        order.Status = PaymentStatus.Paid;
        var result = order.IsPaid();
        Assert.True(result);
    }
    
    [Fact]
    public void IsPaid_ReturnsFalse_WhenStatusIsInitial()
    {
        var order = new Order();
        order.Status = PaymentStatus.Initial;
        var result = order.IsPaid();
        Assert.False(result);
    }
}


Test-Driven Development. Red, green, refactor. The tests are clear. The names are good. The coverage is complete. Any engineer would be happy to own this code.

But before we go any further, let me ask you something.

Look at the IsPaid() method again:

public bool IsPaid() => Status == PaymentStatus.Paid;




What do we actually know when we look at this method?

We know one thing: if Status == PaymentStatus.Paid, then IsPaid() returns true. That’s it. The order is “paid” according to this class. The transaction is “settled.” The flag is flipped.

Now let me change the direction of the question.

What happens if Status != PaymentStatus.Paid?

Not “what does the method return.” We know that. It returns false. The question is: what does that mean for the system?

What actions need to happen when an order is not paid? Does it need to be flagged for review? Does it need to be retried? Does it need to be cancelled? Does someone need to be notified? Does the inventory need to be released? Does the customer need to be reminded?

The method does not tell us. The class does not tell us. The tests do not tell us.

We cannot know just by looking at the method.


The Invisible Half of the Logic

Here is the problem with methods like IsPaid().

They tell you what is true when the condition is met. They are silent about what should happen when the condition is not met. They are silent about the context of the condition. They are silent about the use cases that need this information.

Let me ask the real questions:

In which use cases will this method be called?

  • Is it called before shipping an order? (If not paid, do not ship)
  • Is it called before refunding an order? (If paid, do not refund)
  • Is it called before cancelling an order? (If paid, charge a cancellation fee)
  • Is it called before sending a payment reminder? (If not paid, send reminder)
  • Is it called before updating customer loyalty points? (If paid, award points)

The method does not tell us. The class does not tell us. The tests do not tell us.

Who calls this method? What do they do with the answer?

  • The OrderDomainService calls it. If true, it returns early. If false, it proceeds to process payment.
  • The OrderApplicationService calls it. If false and payment method is SomeoneApprovedThisButNotUs, it checks credit confirmation.
  • The ShippingService might call it. If false, it blocks shipment.
  • The CancellationService might call it. If true, it applies a fee.

Each caller has a different context. Each caller has a different interpretation of what “paid” means. Each caller has a different set of actions that follow.

What are the actions that need to happen when an order is not paid?

  • Send a reminder email? After 1 day? After 3 days? After 7 days?
  • Cancel the order? After 7 days? After 14 days? After 30 days?
  • Release the inventory? Immediately? After the order is cancelled?
  • Notify the sales team? For high-value orders only?
  • Flag for fraud review? For suspicious payment methods only?

The method does not tell us. The class does not tell us. The tests do not tell us.

What are the edge cases?

  • What if Status is PaymentStatus.Initial? Not paid. Correct.
  • What if Status is PaymentStatus.IPG? Is that paid? The domain service thinks so. The Order class does not.
  • What if Status is PaymentStatus.ByCredit? Is that paid? The domain service thinks so. The Order class does not.
  • What if Status is PaymentStatus.SomeoneApprovedThisButNotUs? Is that paid? The application service thinks so after confirmation. The Order class does not.
  • What if the payment was refunded? Is the order still paid? The method says yes. The business might say no.

The method has no idea. It just compares enums.

The Five Questions Your Code Never Answers

When you write a method like IsPaid(), you are making five implicit claims. Claims that your code never states explicitly.

Claim 1: The definition of “paid” is universal. It is not. It changes by context. The Order class has one definition. The domain service has another. The application service has a third. The shipping service has a fourth. Each caller has its own truth.

Claim 2: The inverse is obvious. It is not. “Not paid” does not mean the same thing in every context. For shipping, “not paid” means “do not ship.” For reminders, “not paid” means “send an email after 3 days.” For inventory, “not paid” means “release the reservation after 7 days.” The inverse is not obvious. The inverse is where the complexity lives.

Claim 3: The use cases are irrelevant. They are not. The method exists because of use cases. The use cases define what “paid” means. The use cases define what “not paid” means. The method should not exist without the use cases. But the code hides them.

Claim 4: The actions are elsewhere. They are scattered. The OrderDomainService has some actions. The OrderApplicationService has others. The ShippingService has more. The actions are not in one place. They cannot be understood together. The system is fragmented.

Claim 5: The edge cases are handled. They are not. The method has no idea what a refund is. It has no idea what a chargeback is. It has no idea what a dispute is. It has no idea that “paid” might mean something different after a refund. The method is blissfully ignorant. The system is not.


Now zoom out

Here is where that innocent IsPaid() method actually lives. The OrderDomainService has a Process method that calls IsPaid() at the very top. If the order is already paid, it returns immediately—done. But if not, the real nightmare begins. The code then checks order.Status against values like PaymentStatus.IPG and PaymentStatus.ByCreditو values that don’t even exist in the Order class’s enum. It calls payment gateways, reserves inventory, settles accounting, and triggers delivery. The method is supposed to answer a simple yes/no question: “Is this order paid?”

But the answer is used to orchestrate a complex workflow that the method itself knows nothing about. The question is simple. The consequences are anything but. And no one looking at IsPaid() would ever guess what happens when it returns false. That’s the problem with clean code that ignores context.

public class OrderDomainService
{
    private readonly IPaymentService _paymentService;
    private readonly IInventory _inventory;
    private readonly IAccounting _accounting;
    private readonly IDeliveryManager _deliveryManager;
    
    public void Process(Order order)
    {
        if (order.IsPaid())
            return;
            
        if (order.Status == PaymentStatus.IPG)
            _paymentService.Pay(order.Id, order.Owner, order.TotalAmount);
            
        if (order.Status == PaymentStatus.ByCredit)
            _paymentService.Charge(order.Owner, order.TotalAmount);
            
        order.MarkAsPaid();
        _inventory.Reserve(order);
        _accounting.Settle(order);
        _deliveryManager.DeliverOrderToCustomer(order);
    }
}





Let’s answer the questions now.

If the order is not paid, we must do a handful of things. We must process the payment, either through IPG gateway or by charging a credit card, depending on what order.Status tells us. Then we must mark the order as paid, reserve the inventory so no one else buys those items, issue the financial transaction to accounting, and finally deliver the product to the customer. That’s the happy path. That’s what the Process method does when IsPaid() returns false.

Now let’s reverse the question.

If the order is paid, it means we have already done all of those things. The payment is processed. The inventory is reserved. The financial transaction is issued. The product is on its way. The order is already marked as paid.

So if IsPaid() returns true, the Process method simply returns. Nothing happens. The work is already done.

That makes sense, right?

But here is where the trouble starts.

Notice what IsPaid() actually checks. It checks Status == PaymentStatus.Paid. That’s it. It does not check whether the inventory was actually reserved. It does not check whether the financial transaction was actually settled. It does not check whether the product was actually delivered. It assumes that if the status flag is set to Paid, then everything else must have happened.

That is a dangerous assumption.

What if the payment succeeded but the inventory reservation failed? The status would be Paid. The system would think the order is ready for delivery. But the inventory is not reserved. The product might not exist. The customer would be charged for nothing.

What if the inventory was reserved but the financial transaction failed? Same problem. The status would be Paid. The system would think everything is fine. The accounting team would be missing a transaction. The books would be wrong.

What if the financial transaction succeeded but the delivery manager failed? The status would be Paid. The system would think the product is on its way. The customer would be waiting for nothing.

The IsPaid() method assumes that one boolean flag captures the state of an entire workflow. It does not. It cannot. A single flag cannot tell you whether the inventory is reserved, the money is settled, and the product is delivered.

The method is simple. The assumption is simple. The damage is anything but.

What we actually need is not a boolean. What we need is a way to know, at any moment, what has been done and what still needs to be done. What we need is a model of the workflow itself. Not a flag. A sequence. A state machine. A story.

But that is another pattern. For another article.

For now, just sit with this question:

How many of your boolean flags are hiding a workflow? How many of your IsPaid() methods are assuming that a single status means everything is fine?

The code is clean. The tests pass. The assumption is still wrong.


Now zoom out

.

What is PaymentStatus.IPG? What is PaymentStatus.ByCredit? The Order class only had Initial and Paid. Where did these come from?

The domain service knows about payment methods that the Order class does not. The IsPaid method checks PaymentStatus.Paid. But the domain service sets PaymentStatus.IPG and PaymentStatus.ByCredit. These are not the same.

Something is wrong.

Zoom out again.

public class OrderApplicationService
{
    private readonly IOrderRepository _orders;
    private readonly IPayBySomeoneApprovedThisButNotUs _someoneApprovedThisButNotUs;
    private readonly OrderDomainService _domain;
    
    public void ProcessOrder(Guid id)
    {
        var order = _orders.Get(id);
        
        if (!order.IsPaid() && order.PaymentStatus == PaymentStatus.SomeoneApprovedThisButNotUs)
        {
            var confirmed = _someoneApprovedThisButNotUs.IsPaymentConfirmed(order);
            if (!confirmed)
                throw new SibilRaisedAnException();
            order.MarkAsPaid();
        }
        
        _domain.Process(order);
        _orders.Save(order);
    }
}




Now there is PaymentStatus.PayBySomeoneApprovedThisButNotUs. A third payment method.

The application service knows about it. The domain service does not. The Order class does not.

The meaning of “paid” is different in each layer:

  • In the Order class, “paid” is a simple boolean flag
  • In the domain service, “paid” means IPG or credit card payment completed
  • In the application service, “paid” means SomeoneApprovedThisButNotUs credit confirmation received

Zoom out one more time.

[ApiController]
[Route("api/order/process")]
public class PaymentsController : ControllerBase
{
    private readonly OrderApplicationService _app;
    
    [HttpPut("{id}/pay-by-ipg")]
    public IActionResult PayByIPG(Guid id)
    {
        _app.Process(id);
        return Ok();
    }
    
    [HttpPut("{id}/pay-by-credit")]
    public IActionResult PayByCredit(Guid id)
    {
        _app.Process(id);
        return Ok();
    }
    
    [HttpPut("{id}/pay-by-sibil-e-farda-credit")]
    public IActionResult PayBySibilEFarda(Guid id)
    {
        _app.Process(id);
        return Ok();
    }
}




Three endpoints. Three payment methods. All calling the same application service method.

The controller does not know what the application service does with these different methods. The application service does not know what the domain service does. The domain service does not know what the Order class does.

Every layer has its own understanding of “paid”. Every layer has its own language. The word is the same. The meaning is different.

This code is clean. The tests pass. The architecture is reasonable. The system is still dying.

Because no one is looking at the language.


The Iceberg

This is the problem with most software.

We look at the code. We see small methods. We see good names. We see passing tests. We think everything is fine.

But the code is just the tip of the iceberg.

Underneath are layers of language. Layers of meaning. Layers of assumptions. Layers that most architects never see.

Let me show you the iceberg.


Layer 1: Intent & Meaning

What are we actually trying to build? What does “paid” mean in this context? To the product manager? To the finance team? To the customer? To the support team?

These meanings are rarely written down. They live in conversations. In Slack threads. In meeting notes. In the heads of people who might leave tomorrow.

When a product manager says “we need to process payments,” they mean: customers should be able to pay quickly, with low friction, high success rate, and clear error messages.

When an engineer hears “we need to process payments,” they think: the system should receive a webhook, validate the signature, update the order status, and send a confirmation.

Same words. Different intent. The gap is invisible. The gap is where systems die.


Layer 2: Decisions & Constraints

What have we quietly agreed on?

“Single region is fine for now.” Who decided? When? Why? Will it be revisited? The system is now deployed in one region. Years later, the company wants to expand globally. The architecture cannot support it. The decision was made implicitly. The constraint is now baked into the system.

“Refunds can wait.” Who decided? When? Why? The system now has no refund capability. The product team wants to add refunds. The architecture cannot support it. The decision was made implicitly. The constraint is now baked into the system.

“One database is simpler.” Who decided? When? Why? The system now has one database. Five teams share it. They step on each other. The architecture cannot support independent teams. The decision was made implicitly. The constraint is now baked into the system.

Architecture is not just boxes and arrows. Architecture is the set of constraints we have quietly agreed to. Often without realizing it.


Layer 3: Structure & Teams

How have we sliced the system? Which team owns which part?

Systems tend to mirror the communication structures of the organizations that build them. This is Conway’s Law. It is not a suggestion. It is a observation of how systems actually evolve.

When a fintech company has four teams—compliance, product, engineering, operations—each team builds their own service. Each service mirrors the team structure. The structure reinforces the fracture. The teams stop talking. The services stop integrating.

You cannot force four teams to share a word when they mean different things. You must either align the language or align the structure. Usually both.


Layer 4: Code & Local Design

This is where most architects spend their time. Clean code. Good names. Small methods. Passing tests.

All of this matters. None of this is sufficient.

The Order example had clean code at Layer 4. The methods were small. The names were good. The tests passed. The system was still dying. Because the layers above were broken.

Clean code on a broken language is like a fresh coat of paint on a cracked foundation. It looks good for a while. The cracks always show through.


Layer 5: Behavior & Cognition

Here is where the system talks back. In logs. In metrics. In incidents. In the heads of the people who operate it.

The system has a story to tell. Most teams never listen.

An incident happens. A bug is found. A metric spikes. The team fixes the code. They deploy the fix. They close the ticket. They do not ask:

  • What was the system trying to tell us?
  • What assumption was wrong?
  • What decision needs revisiting?
  • What language needs clarifying?

The healthcare company had incidents every week. They fixed the code. They did not fix the language. The incidents continued. The system decayed.


The One Test That Reveals Everything

Here is a simple test. Pick a line in the code. Any line. Ask yourself: how many “context jumps” does someone need to understand what this line means?

In the Order example, to understand order.IsPaid(), you need to jump to:

  1. The Order class
  2. The PaymentStatus enum
  3. The domain service
  4. The application service
  5. The controller
  6. The product requirements
  7. The finance team’s definition of “paid”
  8. The support team’s definition of “paid”
  9. The compliance team’s definition of “paid”

Seven jumps. Seven context switches. Seven opportunities for misunderstanding.

This is the hidden cost of software. Not CPU cycles. Not memory allocation. Not network latency. Context jumps.

Each jump is a chance for meaning to drift. Each drift is a chance for debt to accumulate. Each debt is a chance for the system to die.

The goal is not zero jumps. You cannot eliminate context. The goal is to make the jumps visible. To know when you are making them. To document them when you cannot reduce them. To reduce them when you can.


Flow of Value vs. Flow of Meaning

Here is a distinction that will change how you see your system.

Flow of Value is how fast ideas turn into running software. Continuous delivery. DevOps. Automated testing. Deployment pipelines. This is what most teams optimize for.

Flow of Meaning is how well intent survives across roles and artifacts. From product manager to engineer. From engineer to code. From code to production. From production back to the team.

You can have a fast flow of value. Deployments every hour. Tests in milliseconds. The pipeline is green. The monitors are quiet.

And still the system is dying.

Because the flow of meaning is blocked.

Here is the pattern I have observed after twenty years. When the flow of value jams, you can almost always trace it back to a crack in the flow of meaning.

  • A deployment that took three days? Somewhere, no one knew what “ready” meant.
  • A bug that took two weeks? Somewhere, “customer” meant different things to different teams.
  • An incident that escalated to the CTO? Somewhere, “normal” was never defined.

The fintech company had a fast flow of value. Their deployment pipeline was excellent. Their flow of meaning was broken. The word Transaction meant four different things. The crack was invisible. The crack killed the system.


What You Can Do Tomorrow

You do not need to fix all five layers at once. You cannot. Start with one.

Start with Layer 1. Ask your team: what does “paid” actually mean? Write down the answers. Compare them. If they differ, you have work to do.

Start with Layer 2. Find one implicit decision. “Single region is fine.” “Refunds can wait.” Write it down. Make it explicit. Put it in the code. Put it in the tests.

Start with Layer 3. Look at your team structure. Does it match your language? If not, change one or the other.

Start with Layer 4. Look for context jumps. How many files do you need to open to understand one decision? Reduce the jumps. Pull the context closer.

Start with Layer 5. Look at your last incident. Ask: what was the system trying to tell us? Write down the answer. Change the language. Change the code.

The layers are connected. Change one. The others will respond.


Tomorrow’s Experiment

Here is something you can do tomorrow.

Pick one rule in your system. One important rule.

  • “High-value orders require finance approval.”
  • “Orders over $10,000 cannot be canceled.”
  • “Refunds require manager override.”

Find where that rule lives. In code. In tests. In documentation. In conversations. Draw the chain.

How many files do you need to open? How many people do you need to ask? How many jumps to understand the rule?

Then ask: could this rule be closer to the story? Could it be one file? One test? One name?

Try it. Move the rule. Push the context closer. Reduce the jumps.

You will not fix your system in a day. You will learn something. The learning is the beginning.


The Most Important Insight

Design is not top-down. It is not bottom-up. It is a conversation between layers. Intent shapes decisions. Decisions shape structure. Structure shapes code. Code shapes behavior. Behavior reveals intent.

And the other way. Behavior reveals problems in code. Code reveals problems in structure. Structure reveals problems in decisions. Decisions reveal problems in intent.

The Order example shows a break in the conversation. The intent at Layer 1 was fractured. The decisions at Layer 2 were implicit. The structure at Layer 3 forced sharing. The code at Layer 4 was clean but isolated. The behavior at Layer 5 was confused.

The conversation was broken. The system was dying. The code was not the problem.


What Comes Next

The layers tell you where to look. But layers are static. They do not move.

Software is not static. It flows.

The real problem is not the layers. The real problem is the flow between them. How intent travels from product manager to engineer to code to production and back again. How meaning cracks. How it heals.

That is the subject of the next article. Flow of Meaning. Why flow of value without flow of meaning is just speeding toward the wrong destination.

But for now, sit with this thought.

Every architecture diagram you have ever drawn has words inside the boxes. Those words are not labels. They are the architecture.

The boxes are just drawings of the words.

Start looking underneath.


This article is adapted from “Language-Driven Design: A Pattern Language for Software Architecture” by Masoud Bahrami. The full book will be available for free at masoudbahrami.com/language-driven-design/

Leave a Reply

Your email address will not be published. Required fields are marked *