The art of designing a java API

Abstract

Basic principles

  • intuitive
  • understandable
  • learnable
  • discoverable
  • consistent
  • self-defensive
  • concise
  • easy to use
  • minimal
  • orthogonal
  • idiomatic
  • flexible
  • evolvable
  • well documented
  • right level of abstraction
  • correct use of the type system
  • limited number of entry-points
  • respect the principle of least astonishment

Be ready to change

A good design is not the one that correctly predicts the future, it’s the one makes adapting to the future affordable.

Change is the only constant in the software.

If in doubt, leave it out. A feature that only takes a few hours to be implemented can:

  • create hundred of hours of support and maintenance in future

  • bloat your software and confuse your users

  • become a burden and prevent future improvements

Software doesn't age like wine, it ages like milk.

Best practices and practical hints

  • write meaningful Javadocs
  • Javadocs are the API specs
    • it’s all about boundaries definition and modularity
  • use overloading judiciously and sparingly
  • consistent argument orders
  • consider static factories
    • nicer syntax for users (no need of new keyword)
    • can return different subclasses
    • can check preconditions and edge cases returning different implementations accordingly
  • promote fluent API without abusing them
    • Streams are a very nice and convenient example of fluent API
    • fluency works really well for builders
  • use the weakest possible type
    • e.g. use Collection instead of ArrayList in signature of the API
  • support lambdas
  • avoid checked exceptions
  • stay in control (loan pattern)
public byte[] readFile(String fileName) throws IOException {
  try (var in = new FileInputStream(fileName)) {
    // ...
  }
}

We are transferring to our users the burden to use our API correctly that’s a leaky abstraction.

One way is to use the following:

public static >T> T withFile(
  String fileName,
  ThrowingFunction<FileInputStream, T> consumer
) throws IOException {
  try (var in = new FileInputStream(fileName)) {
    return consumer.apply(file);
  }
}
 
@FunctionalInterface
public interface ThrowingFunction<T, R> {
  R apply(T t) throws IOException;
}

Then the API consumer won’t need to remember to close the resource:

public byte[] readFile(String fileName) throws IOException {
  return withFile(fileName, file -> {
    // ...
  });
}

  • the responsibility of avoiding the leak is encapsulated in our API

  • if clients are forced to use this API, no leak is possible at all

  • break apart large interfaces into smaller versions

public interface RuleEngineServices {
  Resource newUrlResource();
  Resource newInputStreamResource();
  // ...
  Module getModule();
  Module removeModule();
  // ...
  Command newInsert();
  Command newModify();
  // ...
  RuntimeLogger newFileLogger();
  RuntimeLogger newConsoleLogger();
  // ...
}

Becomes:

public interface RuleEngineServices {
  Resource getResources();
  Repository getRepository();
  Loggers getLoggers();
  Commands getCommands();
}
public interface Commands {
  Command newInsert();
  Command newModify();
  // ...
}
// ...
  • be defensive with your data
    • if necessary return unmodifiable objects to avoid that a client could compromise the consistency of your data
  • return empty collections or optionals
  • prefer enums to boolean parameters
  • use meaningful return types
    • aka “primitive obsession”

API design is an iterative process

  • practice dogfeeding