Search code examples
javaexceptiondomain-driven-designclean-architecturehexagonal-architecture

How to force or lead adapters to throw specific exceptions in Hexagonal/ Ports and Adapters Architecture in Java?


While trying to implement Hexagonal/Ports-and-Adapters architecture in Java in practice, I have problems to integrate exception handling.

Say there is a RentBookPort secondary port interface like

public interface RentBookPort {

    void rentBook(String isbn);
}

So the port contract is clearly specified by this interface to rent a book by given isbn. But what if I specifically want a non-existing book to be thrown as EntityNotFoundException or a business rule violation as InvalidInputException (i.e the book is already rented by someone else)? This is (for me) a strong contract statement every secondary adapter for this port should comply with, may it be a real db impl or a mock impl. How can I force/lead implementers of port interfaces to do it?

Also if I change contracts for return value or input parameters, I automatically get compilation issues as warning signals. Is there a similar technique/way/trick/pattern for leading devs to implement expected thrown exceptions?

I searched and the best advices I could find are item 56 ("Write doc comments for all exposed API elements") and item 74 ("Document all exceptions thrown by each method") in Joshua Bloch's Effective Java. So for example:

public interface RentBookPort {

    /**
     * Rent a book for a given ISBN.
     * 
     * @param isbn
     * 
     * @throws EntityNotFoundException if no book for given ISBN can be found
     * @throws InvalidInputException   if the book is already rented by someone else
     */
    void rentBook(String isbn);
}

But is this really the best I can do, utilize javadoc and write adapter independent port interface acceptance tests to ensure compliance with proper exception throwing?


Solution

  • How can I force/lead implementers of port interfaces to do it?

    You can't really force the implementors of an interface to behave like the interface specified it. An implementor might ignore your javadoc and return null instead of throwing an EntityNotFoundException. But it's the same problem for all interfaces.

    The only way to force an implementor to follow some rules is to specify a template method.

    public abstract class RentBookPort {
        
        public final BookRental rentBook(String isbn) {
            Book book = findBook(isbn);
            
            if(book == null) {
                throw new EntityNotFoundException();
            }
            
            return doRentBook(book);
        }
        
        protected abstract Book findBook(String isbn);
        protected abstract BookRental doRentBook(Book book);
    }
    

    But this takes flexibility from the implementor.

    You can provide abstract unit tests to guide the implementors.

    public abstract class  AbstractRentBookPortTest {
    
        private BookRentPort port;
        
        @Before
        public void setup() {
            port = createBookRentPort();
        }
    
        @Test
        void testRentNonExistentBook() {
            String nonExistentBook = "978-0-13-449416-6"
            ensureBookDoesNotExist(nonExistentBook);
            
            assertThrows(EntityNotFoundException.class, () => port.rentBook(nonExistentBook));
            
        }
    
        protected void ensureBookDoesNotExist(String isbn);
    }
    

    Finally you can only find out if the implementor honored the contract by making an integration test.

    PS: To make your code more robust you can introduce an ISBN class that ensures that the string is a valid ISBN (validates in it's constructor). So when you get an ISBN instance you can be sure that it is valid, because otherwise it could not have been created.