As business rules grow, it is very easy to add one more if to the code.

At the start there is one check:

if (condition) {
    // do the work
}

Then another rule arrives:

if (condition) {
    if (anotherCondition) {
        // do the work
    }
}

After a while the code looks like this:

if
 └── if
      └── if
           └── if
                └── if

This shape is known as the arrow anti-pattern. The further the code moves to the right, the harder it is to read, and adding a new rule gets riskier every time.

Instead, we can use guard clauses and early return. The idea is simple:

Check the invalid cases first and leave right away. Keep the real work for the end.

What is a guard clause?

A guard clause checks the cases that should stop the method at the top of the method, and leaves immediately with return or throw.

For example:

if (user == null) {
    return;
}

Instead of asking “what should I do if there is a user?”, we say:

“If there is no user, stop here.”

That way the rest of the code is allowed to assume that user is not null.

When there is more than one rule, we use the same idea:

if (user == null) {
    return;
}

if (!user.isActive()) {
    return;
}

if (!user.hasPermission()) {
    return;
}

// The real work

The code is no longer nested.

A real scenario: check-in

Think of the check-in step of an airline. For a passenger to check in:

  1. They must have a valid ticket

    If there is no ticket, or it was cancelled, there is no point in going further.

  2. They must not be on the no-fly list

    We ask an external service about banned passengers.

  3. On an international flight, they must have a valid visa

    On a domestic flight this rule never runs.

  4. They must be within the baggage limit

    We compare it with the allowance of the flight.

The same four rules, written in two ways:

public CheckInResult processCheckIn(Passenger passenger, Flight flight) {
    // ❌ The code keeps moving to the right
    if (passenger.getTicket() != null && passenger.getTicket().isValid()) {
        if (!blacklistClient.isBanned(passenger.getIdentityNumber())) {
            if (flight.isInternational()) {
                if (passenger.hasValidVisa(flight.getDestinationCountry())) {
                    if (passenger.getBaggageWeight() <= flight.getMaxBaggageAllowance()) {
                        return CheckInResult.success("Boarding pass created.");
                    } else {
                        return CheckInResult.error("Baggage limit exceeded.");
                    }
                } else {
                    return CheckInResult.error("No valid visa found.");
                }
            } else {
                // ❌ We need to write a separate flow for domestic flights
            }
        } else {
            return CheckInResult.error("Passenger is on the no-fly list.");
        }
    } else {
        return CheckInResult.error("No valid ticket found.");
    }
}
public CheckInResult processCheckIn(Passenger passenger, Flight flight) {

    // 🛡️ Guard 1: no valid ticket, leave now
    if (passenger.getTicket() == null || !passenger.getTicket().isValid()) {
        return CheckInResult.error("No valid ticket found.");
    }

    // 🛡️ Guard 2: on the no-fly list, leave now
    if (blacklistClient.isBanned(passenger.getIdentityNumber())) {
        return CheckInResult.error("Passenger is on the no-fly list.");
    }

    // 🛡️ Guard 3: international flight without a visa, leave now
    if (flight.isInternational()
            && !passenger.hasValidVisa(flight.getDestinationCountry())) {
        return CheckInResult.error("No valid visa found.");
    }

    // 🛡️ Guard 4: over the baggage limit, leave now
    if (passenger.getBaggageWeight() > flight.getMaxBaggageAllowance()) {
        return CheckInResult.error("Baggage limit exceeded.");
    }

    // ✅ Happy path
    return CheckInResult.success("Boarding pass created.");
}

The real problem with the first version is not only that it is long. Every new rule adds one more layer inside the existing if blocks. To understand when a line runs, you have to keep following the conditions above it. At some point the developer stops following the business rule and starts following the brackets.

The empty else for domestic flights is not an accident. In a nested shape, saying “this rule only applies in some cases” means splitting the flow in two. In the guard clause version the same thing fits on one line: flight.isInternational() && !passenger.hasValidVisa(...).

The second version is much easier to read from top to bottom. Each if answers one question:

Is the ticket valid?
    No → leave

On the no-fly list?
    Yes → leave

International and no visa?
    Yes → leave

Over the baggage limit?
    Yes → leave

Passed all of them?
    Yes → check-in succeeds

The best part is that the happy path is now obvious. Whatever is left at the end of the method is the successful case, where every check has passed.

Separating the happy path

Another benefit of this approach is that it collects the failure cases at the top. The first part of the code:

// Invalid cases
if (...) return error;
if (...) return error;
if (...) return error;
if (...) return error;

Then:

// Happy path
return success(...);

This split makes long business methods much easier to read.

Testing these rules with unit tests

When you use guard clauses, writing unit tests also becomes natural. Each guard has one behaviour, so we can write one test per rule.

With JUnit 5 and Mockito:

class CheckInServiceTest {

    private BlacklistClient blacklistClient;
    private CheckInService checkInService;

    @BeforeEach
    void setUp() {
        blacklistClient = mock(BlacklistClient.class);
        checkInService = new CheckInService(blacklistClient);
    }

    @Test
    void cannotCheckInWithAnInvalidTicket() {
        Passenger passenger = passengerWithInvalidTicket();
        Flight flight = domesticFlight();

        CheckInResult result =
                checkInService.processCheckIn(passenger, flight);

        assertThat(result.isSuccess()).isFalse();
        assertThat(result.message())
                .isEqualTo("No valid ticket found.");
    }

    @Test
    void cannotCheckInWhenOnTheNoFlyList() {
        Passenger passenger = passengerWithValidTicket();
        Flight flight = domesticFlight();

        when(blacklistClient.isBanned(passenger.getIdentityNumber()))
                .thenReturn(true);

        CheckInResult result =
                checkInService.processCheckIn(passenger, flight);

        assertThat(result.isSuccess()).isFalse();
        assertThat(result.message())
                .isEqualTo("Passenger is on the no-fly list.");
    }

    @Test
    void cannotCheckInOnAnInternationalFlightWithoutAVisa() {
        // We build a passenger with no visa — no stubbing needed
        Passenger passenger = passengerWithValidTicket().withoutVisas();
        Flight flight = internationalFlight("DE");

        CheckInResult result =
                checkInService.processCheckIn(passenger, flight);

        assertThat(result.isSuccess()).isFalse();
        assertThat(result.message())
                .isEqualTo("No valid visa found.");
    }

    @Test
    void cannotCheckInOverTheBaggageLimit() {
        Passenger passenger = passengerWithValidTicket().withBaggageWeight(40);
        Flight flight = domesticFlight().withMaxBaggageAllowance(30);

        CheckInResult result =
                checkInService.processCheckIn(passenger, flight);

        assertThat(result.isSuccess()).isFalse();
        assertThat(result.message())
                .isEqualTo("Baggage limit exceeded.");
    }

    @Test
    void checkInSucceedsWhenEveryRulePasses() {
        Passenger passenger = passengerWithValidTicket();
        Flight flight = domesticFlight();

        CheckInResult result =
                checkInService.processCheckIn(passenger, flight);

        assertThat(result.isSuccess()).isTrue();
        assertThat(result.message())
                .isEqualTo("Boarding pass created.");
    }
}

Look at the shape of the tests. Each test checks one business rule:

Invalid Ticket
    Test

Blacklist
    Test

Invalid Visa
    Test

Baggage Limit
    Test

Happy Path
    Test

That makes the tests easy to read too.

Do not mock your own domain objects

Only BlacklistClient is mocked in these tests. Passenger and Flight are real objects.

There is a technical reason for that. If passengerWithValidTicket() returns a real object, when(passenger.hasValidVisa("DE")) does not work — Mockito throws MissingMethodInvocationException, because when() only accepts a call on a mock.

But the main reason is about design. A mock is there to draw a border. BlacklistClient is a service on the other side of the network, and you cannot start it in a unit test. Passenger is your own domain object; building it with real data is both faster and closer to reality. When you mock your own object, the test no longer checks the behaviour of the code — it checks your own assumptions.

That is why the examples above use small test data builders like withoutVisas() and withBaggageWeight(40) instead of stubs.

Testing only the invalid cases and skipping the happy path is a common mistake. If one guard is written slightly too strict (> instead of >=, say), only the happy path test catches it — all the failure tests keep passing.

A guard clause is not only return

Do not think of guard clauses as only return. Sometimes throwing an exception is the better answer:

public Booking createBooking(Customer customer) {

    if (customer == null) {
        throw new IllegalArgumentException("Customer cannot be null");
    }

    if (!customer.isActive()) {
        throw new CustomerNotActiveException(customer.getId());
    }

    // Happy path
    return bookingRepository.save(...);
}

So the core idea is:

Catch the invalid case early and keep it out of the normal flow.

Whether that is a return, a throw or some other early exit depends on the problem.

Should every if become a guard clause?

No. Guard clauses are a good tool, but you do not have to use them everywhere.

No need to split

If two conditions really belong to the same business rule, keeping them together reads better:

if (order.isPaid() && order.isConfirmed()) {
    shipOrder(order);
}

Breaking this up just to “use a guard clause” makes no sense.

Worth splitting

If there are three nested checks:

if (user != null) {
    if (user.isActive()) {
        if (user.hasPermission()) {
            // ...
        }
    }
}

guard clauses give a much more readable result.

The second example as guard clauses:

if (user == null) {
    return;
}

if (!user.isActive()) {
    return;
}

if (!user.hasPermission()) {
    return;
}

// ...

The goal is not to write more if statements. The goal is to have fewer nested ones.

So when do you need Chain of Responsibility?

As the number of guard clauses grows, a different problem shows up. For example:

1. Ticket check
2. Blacklist check
3. Visa check
4. Baggage check
5. Payment check
6. Age check
7. Loyalty check
8. Document check
9. Security check
10. Airport restriction check
...

If the method is now made of dozens of guards, the problem is no longer indentation. At this point it makes more sense to move each rule into its own class:

CheckInService
BookingCheckPipeline
 ┌───────────────┐
 │ TicketCheck   │
 ├───────────────┤
 │ BlacklistCheck│
 ├───────────────┤
 │ VisaCheck     │
 ├───────────────┤
 │ BaggageCheck  │
 └───────────────┘

This is where Chain of Responsibility or a pipeline fits better. Each rule gets its own class:

public interface BookingCheck {
    void check(BookingContext context);
}

And then:

public class TicketCheck implements BookingCheck {

    @Override
    public void check(BookingContext context) {
        // ticket validation
    }
}

Now the business rules are independent — and can be tested one by one.

A rough decision tree

In practice you can think about it like this:

graph TD
    A{"How many rules?"} -->|"1 – 5"| B["Guard clauses"]
    A -->|"5+"| C{"Are the rules<br/>reused?"}
    C -->|No| D["Simplify the method first"]
    C -->|"Yes / dynamic"| E["Chain of Responsibility<br/>or a pipeline"]

This is not a hard mathematical border. There is no rule saying “we hit 5 checks, so we must use Chain of Responsibility now”. The number is only a signal to stop and think.

The real question is:

Can these rules still be understood inside one method?

If the answer is yes, guard clauses are probably enough. If the answer is no, it may be time to split the rules into separate components.

What guard clauses are really for

A guard clause is not just a trick to make code shorter. What it really does is simplify the mental model of the code.

Nested

If A is true
  and B is true
    and C is true
      and D is true
        do the work
      else...
    else...
  else...
else...

With guard clauses

A false → leave
B false → leave
C false → leave
D false → leave

All passed → do the work

The second one is much easier to read. As you read each line, the number of conditions you have to hold in your head stays the same. In the first one it grows with every line.

The rule underneath

The simplest Clean Code rule to remember here:

Clear the invalid cases first, then do the real work.

When nested if-else blocks start to grow, your first move does not have to be adding more abstraction. Sometimes the fix is much simpler:

if (invalidCondition) {
    return;
}

if (anotherInvalidCondition) {
    return;
}

// Happy path
doTheRealWork();

The point of good code is to make the reader think about as few things as possible.

That is what a guard clause does: it pushes the errors and the exceptions aside, and leaves the real flow flat and readable.