Classes Start With Functions, Not Data

A common mistake developers make when designing classes is to start with a data model in mind and then try to attach functions to that data (e.g., a Zoo has a Keeper, who has a first name and a last name, etc). This data-centred view of classes tends to lead us towards anaemic models, where classes are nothing more than data containers and the logic that uses the data is distributed throughout the system. This lack of encapsulation creates huge amounts of low-level coupling.

Try instead to start with the function you need, and see what data it requires. This can be illustrated with a bit of TDD. In this example, we want to buy a CD. I start by writing the buy function, without any class to hang that on.


class BuyCdTest {
@Test
void buyCdPaymentAccepted() {
int stock = 10;
double price = 9.99;
String creditCardNumber = "1234";
Payments payments = new PaymentsStub(PaymentResponse.ACCEPTED);
stock = buy(stock, price, creditCardNumber, payments);
assertEquals(9, stock );
}
private int buy(int stock, double price, String creditCardNumber, Payments payments) {
if(payments.process(price, creditCardNumber) == PaymentResponse.ACCEPTED)
stock–;
return stock;
}
}

view raw

BuyCdTest.java

hosted with ❤ by GitHub

The parameters for buy() tell us what data this function needs. If we want to encapsulate some of that data, so that clients don’t need to know about all of them, we can introduce a parameter object to group related params.


@Test
void buyCdPaymentAccepted() {
int stock = 10;
double price = 9.99;
String creditCardNumber = "1234";
Payments payments = new PaymentsStub(PaymentResponse.ACCEPTED);
stock = buy(new CompactDisc(stock, price, payments), creditCardNumber);
assertEquals(9, stock );
}
private int buy(CompactDisc cd, String creditCardNumber) {
int stock = cd.getStock();
if(cd.getPayments().process(cd.getPrice(), creditCardNumber) == PaymentResponse.ACCEPTED)
stock–;
return stock;
}

view raw

BuyCdTest.java

hosted with ❤ by GitHub

This has greatly simplified the signature of the buy() function, and we can easily move buy() to the cd parameter.


@Test
void buyCdPaymentAccepted() {
int stock = 10;
double price = 9.99;
String creditCardNumber = "1234";
Payments payments = new PaymentsStub(PaymentResponse.ACCEPTED);
CompactDisc cd = new CompactDisc(stock, price, payments);
stock = cd.buy(creditCardNumber);
assertEquals(9, stock );
}

view raw

BuyCdTest.java

hosted with ❤ by GitHub

Inside the new CompactDisc class…


public class CompactDisc {
private int stock;
private double price;
private Payments payments;
public CompactDisc(int stock, double price, Payments payments) {
this.stock = stock;
this.price = price;
this.payments = payments;
}
public int getStock() {
return stock;
}
public double getPrice() {
return price;
}
public Payments getPayments() {
return payments;
}
int buy(String creditCardNumber) {
int stock = getStock();
if(getPayments().process(getPrice(), creditCardNumber) == PaymentResponse.ACCEPTED)
stock–;
return stock;
}
}

We have a bunch of getters we don’t need any more. Let’s inline them.


public class CompactDisc {
private int stock;
private final double price;
private final Payments payments;
public CompactDisc(int stock, double price, Payments payments) {
this.stock = stock;
this.price = price;
this.payments = payments;
}
int buy(String creditCardNumber) {
if(payments.process(price, creditCardNumber) == PaymentResponse.ACCEPTED)
stock–;
return stock;
}
}

Now, you may argue that you would have come up with this data model for a CD anyway. Maybe. But the point is that the data model is specifically there to support buying a CD.

When we start with the data, there’s a greater risk of ending up with the wrong data (e.g., many devs who try this exercise start by asking “What can we know about a CD?” and give it fields the functions don’t use), or with the right data in the wrong place – which is where we end up with Feature Envy and message chains and other coupling code smells galore.