Things to consider when designing objects
6 min readMar 9, 2021
It is very simple to learn how to code, but it is very hard to make the code readable/understandable, clean and robust. Let's try to mention some principles in making this easier.
#1 Inject dependencies and configuration values as constructor arguments
public interface Worker {
public void work();
}
public class ConstructionWorker implements Worker {
private List<Tool> tools;
public ConstructionWorker(final List<Tool> tools) {
this.tools = tools;
}
@Override
public void work() {
// use tools
}
}
#2 Keep configuration properties together
before:
public class ApiClient {
private String username;
private String password;
public ApiClient(final String username, final String password) {
this.username = username;
this.password = password;
}
}
after:
class Credentials {
private String username;
private String password; // constructor and getters}public class ApiClient {
private Credentials credentials;
public ApiClient(final Credentials credentials) {
this.credentials = credentials;
}
}
#3 Inject what you need (nothing more/nothing less)
#4 All constructor arguments should be required
before:
class Credentials {
private String username;
private String password;// constructor and getters}public class ApiClient {
private Optional<Credentials> credentials;
private RestTemplate restTemplate;
public ApiClient(final Optional<Credentials> credentials,
final RestTemplate restTemplate) {
this.credentials = credentials;
this.restTemplate = restTemplate;
} public String fetchData() {
// if credentials are present add to HttpHeader
// perform work
}}
after:
class Credentials {
private String username;
private String password;// constructor and getters}public class ApiClient {
private Credentials credentials;
private RestTemplate restTemplate;
public ApiClient(final Credentials credentials,
final RestTemplate restTemplate) {
this.credentials = credentials;
this.restTemplate = restTemplate;
} public String fetchData() {
// add credentials to HttpHeader
// perform work
}}
#5 Only use constructor injection
#6 Turn static dependency into object dependency
before:
class StaticClass {
public static void staticMethod() {}
}public class MyClass {
// constructor and getters public String doWork() {
StaticClass.staticMethod();
// do some other work
}
}
after:
class StaticClass {
public static void staticMethod() {}
}public class MyClass {
private StaticClass dependency; // constructor and getters public String doWork() {
this.dependency.staticMethod();
// do some other work
}
}
#7 Turn complicated functions into object dependencies
before:
public class Resource {
// constructor and getters public String getDataById(final String id) {
// fetch data by id
return jsonEncode(data);
} public String jsonEncode(final Object data) {
// compicated work
}
}
after:
public interface Encoder {
public String encode(final Object data);
}public class JsonEncoder implementes Encoder {
@Override
public String encode(final Object data) {
// encode as json
}
}
public class Resource {
private final Encoder encoder;
// constructor and getters public String getDataById(final String id) {
// fetch data by id
return encoder.encode(data);
}
}
#8 Make system calls explicit
before:
public class Service {
// constructor and getters public List<Object> getData() {
final Calendar calendar = new GregorianCalendar(pdt); return findDataInWeek(calendar.get(Calendar.WEEK_OF_YEAR))
} public List<Object> findDataInWeek(int weekNum) {
}}
after:
public class Service {
private Calendar calendar; // constructor and getters public List<Object> getData() {
return findDataInWeek(calendar.get(Calendar.WEEK_OF_YEAR))
} public List<Object> findDataInWeek(int weekNum) {
}}
#9 Task related data should be passed as method argument
#10 Prevent changing behavior of a service after instantiation
public class Service { private boolean shouldReportErrors = true; // constructor and getters
// only setter for shouldReportErrors public String doWork() {
// ...
if (shouldReportErrors) { <- changes behavior based on value
// report error(s)
}
// ...
}
}
#11 Constructor should be used only for properties assignment
#12 Throw invalid argument exception when constructing object if an argument is invalid
public class Fighter {
private final int rank; public Fighter(final int rank) {
if (rank < 0) {
throw new InvalidArgumentException('Fighter rank should be positive');
}
this.rank = rank;
}
}
#13 Don't use custom exception class for invalid argument when constructing an object
#14 Define as little as possible service entry points
#15 Extract object when validation is performed on multiple places
before:
public class FileDownloadService {
private final String protocol;
private final String host;
private final String port; public FileDownloadService(final String protocol, final String host, final String port) {
if (!isProtocolValid(protocol)) {
throw new InvalidArgumentException('...');
}
if (!isHostValid(host)) {
throw new InvalidArgumentException('...');
}
if (!isPortValid(port)) {
throw new InvalidArgumentException('...');
} this.protocol = protocol;
this.host = host;
this.port = port;
}
}
after:
public class FileServer {
private final String protocol;
private final String host;
private final String port; public FileServer(final String protocol, final String host, final String port) {
if (!isProtocolValid(protocol)) {
throw new InvalidArgumentException('...');
}
if (!isHostValid(host)) {
throw new InvalidArgumentException('...');
}
if (!isPortValid(port)) {
throw new InvalidArgumentException('...');
} this.protocol = protocol;
this.host = host;
this.port = port;
}
}
public class FileDownloadService {
private final FileServer fileServer; public FileDownloadService(final FileServer fileServer) {
this.fileServer = fileServer;
}
}
#16 Extract new object to represent composite value
before:
public class Product {
private final BigDecimal amount;
private final String currency;
// other properties public Product(final BigDecimal amount, final String currency, // other properties) {
}
}
after:
public class Money {
private final BigDecimal amount;
private final String currency;
}public class Product {
private final Money money;
// other properties public Product(final Money money, // other properties) {
}
}
#17 Use assertions to validate constructor arguments
#18 Pass dependencies as method arguments instead of injecting them
before:
public interface Encoder {
public String encode(final Object data);
}public class JsonEncoder implementes Encoder {
@Override
public String encode(final Object data) {
// encode as json
}
}public class Resource {
private final Encoder encoder;
// constructor and getters public String encodeData(final Object data) {
// fetch data by id
return encoder.encode(data);
}
}
after:
public interface Encoder {
public String encode(final Object data);
}public class JsonEncoder implementes Encoder {
@Override
public String encode(final Object data) {
// encode as json
}
}public class Resource {
// constructor and getters public String encodeData(final Encoder encoder, final Object data) {
// fetch data by id
return encoder.encode(data);
}
}
#19 Create methods to construct object from primitive type
public class LocalDate {
public static parse(final String date) { ... }
}
see more: https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/LocalDate.html
#20 Use private constructor to enforce constraints
see details of: https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/LocalDate.html
#21 Don't test constructors
#22 Use replaceable, anonymous and immutable values when creating value objects
#23 Replace values instead of modifying them
see details of: https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/LocalDate.html#minus(long,java.time.temporal.TemporalUnit)
#24 On a mutable object modifier methods should be command methods
public class Fighter {
private final int rank;
private final Position position public Fighter(final int rank, final Position position) {
if (rank < 0) {
throw new InvalidArgumentException('Fighter rank should be positive');
}
this.rank = rank;
this.position = position;
} public void move(final Direction direction) {
this.position = this.position.move(direction);
} public Position currentPosition() {
return this.position;
} }
#25 Properties and methods should have declarative names
#26 When comparing two objects they should be fully compared (all properties)
#27 When calling modifier on an object it should leave it in a valid state
public class Money {
private final BigDecimal amount;
private final String currency; // constructor and getters public Money withdraw(final BigDecimal amount) {
if (this.amount - amount < 0) {
// throw InvalidWithdrawalException
}
// process withdrawal
}
}
#28 When calling modifier on an object have a precondition check
#29 When throwing exception on a check failure add detailed message
#30 Query methods should return single-type value
#31 Object should have proper boundaries set
before:
public class Money {
private final BigDecimal amount;
private final String currency;
}public class Product {
public Money amount; public boolean shouldApplyDiscount() { // ...
} public Money fixedDiscountAmount() { // ...
}
}amount = new Money();
if (product.shouldApplyDiscount()) {
amount = product.getAmount().substract(product.fixedDiscountAmount());
}
after:
public class Money {
private final BigDecimal amount;
private final String currency;
}public class Product {
public Money amount; private boolean shouldApplyDiscount() { // ...
} private Percentage discount() { // ...
} public Money getNetAmount() {
if (shouldApplyDiscount()) {
return this.discount().applyTo(this.amount);
}
}
}amount = product.getNetAmount();
#32 Query methods should not issue commands
#33 Use queries to collect information and commands to take next steps
#34 Use mocks to verify calls to command methods
#35 Separate read from write objects
#36 Compose abstraction to achieve complicated behavior
public interface Encoder {
public List<String> encode(final Object data);
}public class JsonEncoder implementes Encoder {
@Override
public List<String> encode(final Object data) {
// encode as json
}
}public class MultipleTypeEncoder implementes Encoder {
private final List<Encoder> encoders; // constructor and getters @Override
public List<String> encode(final Object data) {
final List<String> result = new ArrayList<>();
for(final Encoder encoder : encoders) {
result.add(encoder.encode(data));
}
return result;
}
}