Interfaces, abstract classes and enums

Part of the free Low-Level Design course on LogicWiz, in the chapter: Object-oriented programming from zero. The class-diagram exercises run in the app.

One checkout, three ways to pay

"Checkout has an if for UPI, an if for card and an if for cash. Next month we add a wallet. How many places will you edit?"

Priya, reviewing Arjun's checkout code


Three ways to pay, one checkout

SwiftKart customers pay in three ways: UPI, card, or cash on delivery, where the rider collects cash at the door. Arjun's checkout handled all three in one method.

public class Checkout {
    public String pay(String type, long amountPaise, String upiId, String cardToken) {
        if (type.equals("upi")) {
            // ask the customer's UPI app to approve
            return "UPI-" + upiId;
        } else if (type.equals("card")) {
            // send the saved card token to the card network
            return "CARD-" + cardToken;
        } else if (type.equals("cod")) {
            // nothing to charge now: the rider collects cash
            return "COD-PENDING";
        }
        throw new IllegalArgumentException("Unknown payment type: " + type);
    }
}

Several problems hide here:

  • Checkout knows the details of every payment method.
  • The method asks for a UPI id and a card token, even when the customer pays cash.
  • A wallet next month means editing this method again, beside code that already works.
  • A typo such as "UPl" with a small L compiles, and fails only when a real customer pays.

This lesson gives you three tools to fix them: interfaces, abstract classes and enums.

An interface is a promise

Look at what checkout needs from a payment. It needs one action: pay this amount and give back a receipt. It does not care how. Java lets you write down that need by itself.

public interface PaymentMethod {
    Receipt pay(long amountPaise);
}

public class UpiPayment implements PaymentMethod {
    private String upiId;

    public UpiPayment(String upiId) {
        this.upiId = upiId;
    }

    @Override
    public Receipt pay(long amountPaise) {
        // ask the customer's UPI app to approve
        return new Receipt("UPI-" + upiId, amountPaise);
    }
}

An interface is a promise of what an object can do. It lists methods by their name, inputs and return type, with no body. It has no fields, so it holds no state. PaymentMethod says: anything that is a payment method can pay. (Receipt is a small class that holds a payment reference and the amount.)

A class keeps the promise with the keyword implements. UpiPayment implements PaymentMethod means it must write a real pay method, or the code does not compile. @Override is a label that asks the compiler to check that this method matches one in the interface. CardPayment and CashOnDelivery implement the interface the same way. Each keeps its own fields, such as a card token.

Think of the charging port on a phone. The port is a standard shape. Any charger that fits it can charge the phone, whoever made the charger. The analogy stops working at quality. A charger can fit the port and still be faulty. In the same way, the compiler checks only that pay exists with the right shape. It cannot check that pay really takes the money. That promise is yours to keep, and the SOLID chapter returns to it.

One variable, any payment

Now checkout can depend on the promise instead of the details.

public class Checkout {
    public Receipt complete(Order order, PaymentMethod method) {
        return method.pay(order.totalPaise());
    }
}

// somewhere in main
PaymentMethod method = new UpiPayment("arjun@okbank");
Receipt first = checkout.complete(order, method);

method = new CashOnDelivery();
Receipt second = checkout.complete(order, method);

A variable of an interface type can hold any object whose class implements that interface. You cannot write new PaymentMethod(), because a promise alone has no code to run. You create a real class and keep it in a PaymentMethod variable.

The same line, method.pay(...), runs UPI code the first time and cash code the second time. Java picks the method from the real object, not from the variable's type. This is called polymorphism, which means "many forms": one call, different behaviour for each kind of object. The chapter on encapsulation, inheritance and polymorphism covers it in full.

For now, see the result. A wallet next month means one new class, WalletPayment implements PaymentMethod. Checkout does not change.

An abstract class shares real code

UPI and card both go through SwiftKart's payment gateway, the outside service that moves the money. Both must check that the amount is positive, call the gateway, and build a receipt. Arjun had written those steps twice. An abstract class lets the two share them.

public abstract class OnlinePayment implements PaymentMethod {
    private String gatewayName;

    protected OnlinePayment(String gatewayName) {
        this.gatewayName = gatewayName;
    }

    @Override
    public Receipt pay(long amountPaise) {
        if (amountPaise <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        String reference = charge(amountPaise);
        return new Receipt(gatewayName + "-" + reference, amountPaise);
    }

    protected abstract String charge(long amountPaise);
}

An abstract class is a class marked abstract. Like any class, it can have fields, a constructor and working methods. It can also have abstract methods, such as charge: methods with no body that each child class (a class built on this one) must write. Because some code is missing, you cannot create one with new OnlinePayment(...). The compiler refuses.

public class CardPayment extends OnlinePayment {
    private String cardToken;

    public CardPayment(String cardToken) {
        super("CARD");
        this.cardToken = cardToken;
    }

    @Override
    protected String charge(long amountPaise) {
        // send cardToken and the amount to the card network,
        // which answers with a transaction reference
        return "TXN-1001";
    }
}

A child class joins with extends. CardPayment extends OnlinePayment means a card payment is a kind of online payment, and it gets the parent's field and pay method. super("CARD") calls the parent's constructor, so the parent fills in its own field. protected means the parent and its children can use the member, but other classes cannot. Building one class on another like this is called inheritance. UpiPayment now extends OnlinePayment the same way.

Go back to the bank form from the last lesson. Imagine the shop has printed the header, the terms and the total box, and left one box empty: "how you will pay". Each kind of payment fills only that box. The analogy stops working at use. A paper form can be handed in half-filled. An abstract class cannot be used at all until a child fills every empty box.

Cash on delivery calls no gateway, so it does not extend OnlinePayment. It implements PaymentMethod directly. Checkout still sees all three as a PaymentMethod.

In the diagram below, a dashed line with a hollow triangle means "implements" and a solid one means "extends". The # means protected, and the * marks an abstract method.

classDiagram
  class PaymentMethod {
    <<interface>>
    +pay(amountPaise) Receipt
  }
  class OnlinePayment {
    <<abstract>>
    -gatewayName: String
    +pay(amountPaise) Receipt
    #charge(amountPaise)* String
  }
  PaymentMethod <|.. OnlinePayment
  OnlinePayment <|-- UpiPayment
  OnlinePayment <|-- CardPayment
  PaymentMethod <|.. CashOnDelivery

Interface or abstract class?

Arjun asked, "An abstract class can do everything an interface can, and hold code too. Why not always use one?" Priya gave three reasons.

  • A Java class can extend only one class, but it can implement many interfaces. If PaymentMethod were an abstract class, a payment class that already extends something else could not join.
  • An abstract class ties every child to its fields and constructor. CashOnDelivery has no gateway. Forcing it under OnlinePayment would give it a gateway name it never uses.
  • Callers should depend on the smallest promise. Checkout needs pay and nothing more, and the interface says exactly that.

So start with an interface for the promise. Add an abstract class under it only when two or more implementations share real fields and code.

flowchart TD
  A["Several classes do the same kind of job"] --> B{"Do they share fields and working code?"}
  B -->|No| C["An interface only"]
  B -->|Yes| D{"Is each one truly a kind of the parent?"}
  D -->|Yes| E["An interface, plus an abstract class for the shared code"]
  D -->|No| F["An interface, plus a helper object each class holds"]

Holding a helper object instead of inheriting from a parent is called composition. The chapter on encapsulation, inheritance and polymorphism shows when to prefer it.

Interface Abstract class
Fields (state) None, only constants Yes
Method bodies None (Java allows a few default methods) Yes, shared by children
Constructor No Yes, called with super
Create with new No No
How many one class can take Many One
Use it for What an object can do Code shared by close relatives

Enums: a fixed set of named values

Order status had its own bug. Arjun stored it as a string.

order.setStatus("PLACED");
// in another file, months later
order.setStatus("DELIVERD");

// in the daily report of delivered orders
if (order.getStatus().equals("DELIVERED")) {
    deliveredCount++;
}

The typo compiles. That order never counts as delivered, and nobody notices until finance asks. A String can hold any text, but an order status can only be one of a few values.

public enum OrderStatus {
    PLACED, PACKED, OUT_FOR_DELIVERY, DELIVERED, CANCELLED;

    public boolean isFinished() {
        return this == DELIVERED || this == CANCELLED;
    }
}

order.setStatus(OrderStatus.DELIVERED);
// order.setStatus(OrderStatus.DELIVERD);  does not compile

An enum, short for enumeration, is a type with a fixed list of named values. By custom the values are written in capitals. A variable of type OrderStatus can hold only one of these five values, so a typo is caught when you compile. You compare enum values with == and can use them in a switch. An enum can also have methods, such as isFinished().

Think of a dropdown menu on a form instead of a free text box. You can pick only what is listed. The analogy stops working when the list changes often. Changing an enum means changing code and releasing the app. If staff add new values every week, such as product categories, keep them as data instead.

Now redesign Arjun's checkout on the board. He drew PaymentMethod as an ordinary class holding every payment's fields, and kept the order status as a string. Make PaymentMethod an interface with a pay method and no fields. Give UPI, card and cash on delivery a class each, and add an OrderStatus enum.

📌 Summary: An interface is a promise of methods with no state. An abstract class shares fields and code but cannot be created. An enum is a fixed set of named values that stops typos.

What Arjun learns next

Arjun now has the building blocks: classes, objects, interfaces, abstract classes and enums. But how will an interviewer judge him with them in 90 minutes? Next, Priya runs a mock LLD round and shows him what the round really tests.

Check your understanding

  1. Checkout takes a PaymentMethod and calls pay on it. SwiftKart adds wallet payments. What has to change?
  2. A teammate says, "An abstract class is an interface with code, so it is always the better choice." What is the strongest reply?
  3. OnlinePayment is abstract, and CardPayment extends it. Which line does not compile?
  4. The catalogue team adds new product categories from an admin screen every week. Should Category be an enum?
  5. Arjun puts a upiId field in the PaymentMethod interface so that every payment can use it. What is wrong?