Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Let's make a contract: the art of designing a J...

Let's make a contract: the art of designing a Java API

An API is what developers use to achieve some task. More precisely it establishes a contract between them and the designers of the software exposing its services through that API. In this sense we're all API designers: our software doesn't work in isolation, but becomes useful only when it interacts with other software written by other developers. When writing software we're not only consumers, but also providers of one or more API and that's why every developer should know the features of a good API. During this presentation we will go through real-world examples, also taken from the standard Java API, of good and bad API and comment them in order to show the dos and don'ts of API design. More in general we will discuss the characteristics of an easy and pleasant to use API, like consistency, discoverability and understandability, together with some basic concepts like the principle of least astonishment, and find out how to achieve them.

Mario Fusco

May 08, 2022
Tweet

More Decks by Mario Fusco

Other Decks in Programming

Transcript

  1. What is an API? An API is a contract between

    its implementors and its users
  2. What is an API? An API defines and captures the

    boundaries of a piece of software …
  3. What is an API? An API defines and captures the

    boundaries of a piece of software … … thus enabling and facilitating modular reasoning
  4. And why should I care? We are all API designers

    Our software doesn't work in isolation, but becomes useful only when it interacts with other software written by other developers
  5. 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
  6. Be ready for changes Software being 'done' is like lawn

    being 'mowed' – Jim Benson Change is the only constant in software Add features sparingly and carefully so that they won’t become obstacles for the evolution of your API
  7. If in doubt, leave it out A feature that only

    takes a few hours to be implemented can ➢ create hundreds of hours of support and maintenance in future ➢ bloat your software and confuse your users ➢ become a burden and prevent future improvements “it’s easy to build” is NOT a good enough reason to add it to your product
  8. Convenience methods Use overloading judiciously and sparingly Are these all

    necessary? Primitives often cause methods proliferation {
  9. Convenience methods public interface StockOrder { void sell(String symbol, double

    price, int quantity); void buy(String symbol, int quantity, double price); void buy(String symbol, int quantity, double price, double commission); void buy(String symbol, int quantity, double minPrice, double maxPrice, double commission); } What’s wrong with this?
  10. Convenience methods public interface StockOrder { void sell(String symbol, double

    price, int quantity); void buy(String symbol, int quantity, double price); void buy(String symbol, int quantity, double price, double commission); void buy(String symbol, int quantity, double minPrice, double maxPrice, double commission); } Too many overloads What’s wrong with this?
  11. Convenience methods public interface StockOrder { void sell(String symbol, double

    price, int quantity); void buy(String symbol, int quantity, double price); void buy(String symbol, int quantity, double price, double commission); void buy(String symbol, int quantity, double minPrice, double maxPrice, double commission); } Too many overloads Inconsistent argument order What’s wrong with this?
  12. Convenience methods Long arguments lists (especially of same type) public

    interface StockOrder { void sell(String symbol, double price, int quantity); void buy(String symbol, int quantity, double price); void buy(String symbol, int quantity, double price, double commission); void buy(String symbol, int quantity, double minPrice, double maxPrice, double commission); } Too many overloads Inconsistent argument order What’s wrong with this?
  13. Convenience methods Long arguments lists (especially of same type) public

    interface StockOrder { void sell(String symbol, double price, int quantity); void buy(String symbol, int quantity, double price); void buy(String symbol, int quantity, double price, double commission); void buy(String symbol, int quantity, double minPrice, double maxPrice, double commission); } public interface StockOrder { void sell(String symbol, int quantity, Price price); void buy(String symbol, int quantity, Price price); } Too many overloads Inconsistent argument order What’s wrong with this? How to do better
  14. Consider static factories public interface Price { static Price price(

    double price ) { if (price < 0) return Malformed.INSTANCE; return new Fixed(price); } static Price price( double minPrice, double maxPrice ) { if (minPrice > maxPrice) return Malformed.INSTANCE; return new Range(minPrice, maxPrice); } class Fixed implements Price { private final double price; private Fixed( double price ) { this.price = price; } } class Range implements Price { private final double minPrice; private final double maxPrice; private Range( double minPrice, double maxPrice ) { this.minPrice = minPrice; this.maxPrice = maxPrice; } } enum Malformed implements Price { INSTANCE } } ➢ nicer syntax for users (no need of new keyword)
  15. Consider static factories public interface Price { static Price price(

    double price ) { if (price < 0) return Malformed.INSTANCE; return new Fixed(price); } static Price price( double minPrice, double maxPrice ) { if (minPrice > maxPrice) return Malformed.INSTANCE; return new Range(minPrice, maxPrice); } class Fixed implements Price { private final double price; private Fixed( double price ) { this.price = price; } } class Range implements Price { private final double minPrice; private final double maxPrice; private Range( double minPrice, double maxPrice ) { this.minPrice = minPrice; this.maxPrice = maxPrice; } } enum Malformed implements Price { INSTANCE } } ➢ nicer syntax for users (no need of new keyword) ➢ can return different subclasses
  16. Consider static factories public interface Price { static Price price(

    double price ) { if (price < 0) return Malformed.INSTANCE; return new Fixed(price); } static Price price( double minPrice, double maxPrice ) { if (minPrice > maxPrice) return Malformed.INSTANCE; return new Range(minPrice, maxPrice); } class Fixed implements Price { private final double price; private Fixed( double price ) { this.price = price; } } class Range implements Price { private final double minPrice; private final double maxPrice; private Range( double minPrice, double maxPrice ) { this.minPrice = minPrice; this.maxPrice = maxPrice; } } enum Malformed implements Price { INSTANCE } } ➢ nicer syntax for users (no need of new keyword) ➢ can return different subclasses ➢ can check preconditions and edge cases returning different implementations accordingly
  17. Promote fluent API public interface Price { Price withCommission(double commission);

    Price gross(); } public interface Price { void setCommission(double commission); void setGross(); }
  18. Promote fluent API public interface Price { Price withCommission(double commission);

    Price gross(); } stockOrder.buy( "IBM", 100, price(150.0).withCommission(0.7).gross() ); public interface Price { void setCommission(double commission); void setGross(); } Price price = price(150.0); price.setCommission(0.7); price.setGross(); stockOrder.buy( "IBM", 100, price );
  19. Promote fluent API public interface Price { Price withCommission(double commission);

    Price gross(); } stockOrder.buy( "IBM", 100, price(150.0).withCommission(0.7).gross() ); public interface Price { void setCommission(double commission); void setGross(); } Price price = price(150.0); price.setCommission(0.7); price.setGross(); stockOrder.buy( "IBM", 100, price ); Concatenate multiple invocations Use result directly
  20. Promote fluent API without abusing them Fluency is nice, but

    it can be overdone, and seems to be a "fashion" that people follow blindly Fluency works really well for builders And yes, a fluent API works well for Streams since it’s basically a builder
  21. Use the weakest possible type public String concatenate( ArrayList<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); }
  22. Use the weakest possible type public String concatenate( ArrayList<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); } Do I care of the actual List implementation?
  23. Use the weakest possible type public String concatenate( List<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); }
  24. Use the weakest possible type public String concatenate( List<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); } Do I care of the elements’ order?
  25. Use the weakest possible type public String concatenate( Collection<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); }
  26. Use the weakest possible type public String concatenate( Collection<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); } Do I care of the Collection’s size?
  27. Use the weakest possible type public String concatenate( Iterable<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); }
  28. Using the weakest possible type... public String concatenate( Iterable<String> strings

    ) { StringBuilder sb = new StringBuilder(); for (String s : strings) { sb.append( s ); } return sb.toString(); } … enlarges the applicability of your method, avoiding to restrict your client to a particular implementation or forcing it to perform an unnecessary and potentially expensive copy operation if the input data exists in other forms
  29. Use the weakest possible type also for returned value public

    List<Address> getFamilyAddresses( Person person ) { List<Address> addresses = new ArrayList<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return addresses; }
  30. Use the weakest possible type also for returned value public

    List<Address> getFamilyAddresses( Person person ) { List<Address> addresses = new ArrayList<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return addresses; } Is the order of this List meaningful for client?
  31. Use the weakest possible type also for returned value public

    List<Address> getFamilyAddresses( Person person ) { List<Address> addresses = new ArrayList<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return addresses; } Is the order of this List meaningful for client? … and shouldn’t we maybe return only the distinct addresses? Yeah, that will be easy let’s do this!
  32. Use the weakest possible type also for returned value public

    List<Address> getFamilyAddresses( Person person ) { Set<Address> addresses = new HashSet<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return addresses; } It should be enough to change this List into a Set
  33. Use the weakest possible type also for returned value public

    List<Address> getFamilyAddresses( Person person ) { Set<Address> addresses = new HashSet<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return addresses; } It should be enough to change this List into a Set But this doesn’t compile :(
  34. Use the weakest possible type also for returned value public

    List<Address> getFamilyAddresses( Person person ) { Set<Address> addresses = new HashSet<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return addresses; } It should be enough to change this List into a Set But this doesn’t compile :( and I cannot change the returned type to avoid breaking backward compatibility :(((
  35. Use the weakest possible type also for returned value public

    List<Address> getFamilyAddresses( Person person ) { Set<Address> addresses = new HashSet<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return new ArrayList<>( addresses ); } I’m obliged to uselessly create an expensive copy of data before returning them
  36. Use the weakest possible type also for returned value public

    Collection<Address> getFamilyAddresses( Person person ) { List<Address> addresses = new ArrayList<>(); addresses.add(person.getAddress()); for (Person sibling : person.getSiblings()) { addresses.add(sibling.getAddress()); } return addresses; } Returning a more generic type (if this is acceptable for your client) provides better flexibility in future
  37. Support lambdas public interface Listener { void beforeEvent(Event e); void

    afterEvent(Event e); } class EventProducer { public void registerListener(Listener listener) { // register listener } } public interface Listener { void beforeEvent(Event e); void afterEvent(Event e); } public interface Listener { void beforeEvent(Event e); void afterEvent(Event e); } EventProducer producer = new EventProducer(); producer.registerListener( new Listener() { @Override public void beforeEvent( Event e ) { // ignore } @Override public void afterEvent( Event e ) { System.out.println(e); } } );
  38. Support lambdas class EventProducer { public void registerBefore(BeforeListener before) {

    // register listener } public void registerAfter(AfterListener after) { // register listener } } @FunctionalInterface interface BeforeListener { void beforeEvent( Event e ); } @FunctionalInterface interface AfterListener { void afterEvent( Event e ); } EventProducer producer = new EventProducer(); producer.registerAfter( System.out::println ); Taking functional interfaces as argument of your API enables clients to use lambdas
  39. Support lambdas class EventProducer { public void registerBefore(Consumer<Event> before) {

    // register listener } public void registerAfter(Consumer<Event> after) { // register listener } } @FunctionalInterface interface BeforeListener { void beforeEvent( Event e ); } @FunctionalInterface interface AfterListener { void afterEvent( Event e ); } EventProducer producer = new EventProducer(); producer.registerAfter( System.out::println ); Taking functional interfaces as argument of your API enables clients to use lambdas In many cases you don’t need to define your own functional interfaces and use Java’s one
  40. public void writeList( Writer writer, Collection<String> strings ) { strings.stream().forEach(

    writer::write ); } Writer writer = new StringWriter(); List<String> strings = asList("one", "two", "three"); writeList( writer, strings ); Avoid checked exceptions
  41. Avoid checked exceptions public void writeList( Writer writer, Collection<String> strings

    ) { strings.stream().forEach( writer::write ); } Writer writer = new StringWriter(); List<String> strings = asList("one", "two", "three"); writeList( writer, strings ); public void writeList( Writer writer, Collection<String> strings ) { strings.stream().forEach( str -> { try { writer.write( str ); } catch (IOException e) { throw new RuntimeException( e ); } } ); }
  42. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { FileInputStream file = new FileInputStream( filename ); byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); }
  43. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { FileInputStream file = new FileInputStream( filename ); byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); } File descriptor leak
  44. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { FileInputStream file = new FileInputStream( filename ); try { byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); } finally { file.close(); } }
  45. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { FileInputStream file = new FileInputStream( filename ); try { byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); } finally { file.close(); } } We can do better using try-with-resource
  46. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { try ( FileInputStream file = new FileInputStream( filename ) ) { byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); } }
  47. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { try ( FileInputStream file = new FileInputStream( filename ) ) { byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); } } Better, but we’re still transferring to our users the burden to use our API correctly
  48. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { try ( FileInputStream file = new FileInputStream( filename ) ) { byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); } } Better, but we’re still transferring to our users the burden to use our API correctly That’s a leaky abstraction!
  49. Stay in control (loan pattern) public static <T> T withFile(

    String filename, ThrowingFunction<FileInputStream, T> consumer ) throws IOException { try ( FileInputStream file = new FileInputStream( filename ) ) { return consumer.apply( file ); } } @FunctionalInterface public interface ThrowingFunction<T, R> { R apply(T t) throws IOException; } Yeah, checked exceptions suck :(
  50. Stay in control (loan pattern) public byte[] readFile(String filename) throws

    IOException { return withFile( filename, file -> { byte[] buffer = new byte[4096]; ByteArrayOutputStream out = new ByteArrayOutputStream( buffer.length ); int n = 0; while ( (n = file.read( buffer )) > 0 ) { out.write( buffer, 0, n ); } return out.toByteArray(); }); } Now 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!
  51. Break apart large interfaces into smaller versions public interface RuleEngineServices

    { Resource newUrlResource( URL url ); Resource newByteArrayResource( byte[] bytes ); Resource newFileSystemResource( File file ); Resource newInputStreamResource( InputStream stream ); Resource newClassPathResource( String path ); void addModule( Module kModule ); Module getModule( ReleaseId releaseId ); Module removeModule( ReleaseId releaseId ); Command newInsert( Object object ); Command newModify( FactHandle factHandle ); Command newDelete( FactHandle factHandle ); Command newFireAllRules( int max ); RuntimeLogger newFileLogger( RuntimeEventManager session, String fileName, int maxEventsInMemory ); RuntimeLogger newThreadedFileLogger( RuntimeEventManager session, String fileName, int interval ); RuntimeLogger newConsoleLogger( RuntimeEventManager session ); }
  52. Break apart large interfaces into smaller versions public interface RuleEngineServices

    { Resources getResources(); Repository getRepository(); Loggers getLoggers(); Commands getCommands(); } public interface Resources { Resource newUrlResource( URL url ); Resource newByteArrayResource( byte[] bytes ); Resource newFileSystemResource( File file ); Resource newInputStreamResource( InputStream stream ); Resource newClassPathResource( String path ); } public interface Repository { void addModule( Module module ); Module getModule( ReleaseId releaseId ); Module removeModule( ReleaseId releaseId ); } public interface Commands { Command newInsert( Object object ); Command newModify( FactHandle factHandle ); Command newDelete( FactHandle factHandle ); Command newFireAllRules( int max ); }
  53. Break apart large interfaces into smaller versions public interface RuleEngineServices

    { Resources getResources(); Repository getRepository(); Loggers getLoggers(); Commands getCommands(); } public interface Resources { Resource newUrlResource( URL url ); Resource newByteArrayResource( byte[] bytes ); Resource newFileSystemResource( File file ); Resource newInputStreamResource( InputStream stream ); Resource newClassPathResource( String path ); } public interface Repository { void addModule( Module module ); Module getModule( ReleaseId releaseId ); Module removeModule( ReleaseId releaseId ); } public interface Commands { Command newInsert( Object object ); Command newModify( FactHandle factHandle ); Command newDelete( FactHandle factHandle ); Command newFireAllRules( int max ); } Divide et Impera
  54. Be defensive with your data public class Person { private

    List<Person> siblings; public List<Person> getSiblings() { return siblings; } } What’s the problem here?
  55. public class Person { private List<Person> siblings; public List<Person> getSiblings()

    { return siblings; } } person.getSiblings().add(randomPerson); What’s the problem here? Be defensive with your data
  56. public class Person { private List<Person> siblings; public List<Person> getSiblings()

    { return siblings; } } public class Person { private List<Person> siblings; public List<Person> getSiblings() { return Collections.unmodifiableList( siblings ); } } If necessary return unmodifiable objects to avoid that a client could compromise the consistency of your data. person.getSiblings().add(randomPerson); What’s the problem here? Be defensive with your data
  57. Return empty Collections or Optionals public class Person { private

    Car car; private List<Person> siblings; public Car getCar() { return car; } public List<Person> getSiblings() { return siblings; } } What’s the problem here?
  58. Return empty Collections or Optionals public class Person { private

    Car car; private List<Person> siblings; public Car getCar() { return car; } public List<Person> getSiblings() { return siblings; } } What’s the problem here? for (Person sibling : person.getSiblings()) { ... } NPE!!!
  59. Return empty Collections or Optionals public class Person { private

    Car car; private List<Person> siblings; public Car getCar() { return car; } public List<Person> getSiblings() { return siblings; } } public class Person { private Car car; private List<Person> siblings; public Optional<Car> getCar() { return Optional.ofNullable(car); } public List<Person> getSiblings() { return siblings == null ? Collections.emptyList() : Collections.unmodifiableList( siblings ); } } What’s the problem here? for (Person sibling : person.getSiblings()) { ... } NPE!!!
  60. contacts.getPhoneNumber( ... ); Prefer enums to boolean parameters public interface

    EmployeeContacts { String getPhoneNumber(boolean mobile); }
  61. contacts.getPhoneNumber( ... ); Prefer enums to boolean parameters public interface

    EmployeeContacts { String getPhoneNumber(boolean mobile); } Should I use true or false here?
  62. contacts.getPhoneNumber( ... ); Prefer enums to boolean parameters public interface

    EmployeeContacts { String getPhoneNumber(boolean mobile); } Should I use true or false here? What if I may need to add a third type of phone number in future?
  63. public interface EmployeeContacts { String getPhoneNumber(PhoneType type); enum PhoneType {

    HOME, MOBILE, OFFICE; } } Prefer enums to boolean parameters contacts.getPhoneNumber(PhoneType.HOME);
  64. Use meaningful return types public interface EmployeesRegistry { enum PhoneType

    { HOME, MOBILE, OFFICE; } Map<String, Map<PhoneType, List<String>>> getEmployeesPhoneNumbers(); }
  65. Use meaningful return types public interface EmployeesRegistry { enum PhoneType

    { HOME, MOBILE, OFFICE; } Map<String, Map<PhoneType, List<String>>> getEmployeesPhoneNumbers(); } Employee name Employee’s phone numbers grouped by type List of phone numbers of a give type for a given employee Primitive obsession
  66. Use meaningful return types public interface EmployeesRegistry { enum PhoneType

    { HOME, MOBILE, OFFICE; } PhoneBook getPhoneBook(); } public class PhoneBook { private Map<String, EmployeeContacts> contacts; public EmployeeContacts getEmployeeContacts(String name) { return Optional.ofNullable( contacts.get(name) ) .orElse( EmptyContacts.INSTANCE ); } } public class EmployeeContacts { private Map<PhoneType, List<String>> numbers; public List<String> getNumbers(PhoneType type) { return Optional.ofNullable( numbers.get(type) ) .orElse( emptyList() ); } public static EmptyContacts INSTANCE = new EmptyContacts(); static class EmptyContacts extends EmployeeContacts { @Override public List<String> getNumbers(PhoneType type) { return emptyList(); } } }
  67. Optional – the mother of all bikeshedding Principle of least

    astonishment??? "If a necessary feature has a high astonishment factor, it may be necessary to redesign the feature." - Cowlishaw, M. F. (1984). "The design of the REXX language"
  68. Optional – the mother of all bikeshedding Principle of least

    astonishment??? Wrong default This could be removed if the other was correctly implemented
  69. … that could be driven by the fact that different

    people may weigh possible use cases differently...
  70. … and they could be conflicting so you may need

    to trade off one to privilege another
  71. What should always drive the final decision is the intent

    of the API … but even there it could be hard to find an agreement
  72. • Write lots of tests and examples against your API

    • Discuss it with colleagues and end users • Iterates multiple times to eliminate ➢ Unclear intentions ➢ Duplicated or redundant code ➢ Leaky abstraction API design is an iterative process
  73. • Write lots of tests and examples against your API

    • Discuss it with colleagues and end users • Iterates multiple times to eliminate ➢ Unclear intentions ➢ Duplicated or redundant code ➢ Leaky abstraction Practice Dogfeeding API design is an iterative process
  74. And that’s all what you were getting wrong :) …

    questions? Mario Fusco Red Hat – Principal Software Engineer [email protected] twitter: @mariofusco