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

Cryptic to Clean: Architecture for Projects tha...

Cryptic to Clean: Architecture for Projects that Never Quite got around to it

Clean architecture is increasingly popular in Android development, and you’ve probably seen elegant examples of it in inspiring talks and blog posts by experts in the field. But what about the app your team works on? The one that was started in a hurry, with the intention of doing it right later on - but has instead had features continuously piled on top. Where you secretly hope the codebase will be consumed by a giant space goat so that you can start again...

Andrew and Catalina will talk about Clean Architecture, why it might make your life easier, and its relevance to the direction that Android’s heading in. They will discuss how it can fit with your current codebase and existing endpoints. And they will share with you some practical tips on how to bring your team with you on the journey. But no giant space goats. Sorry.

Speakers:
Catalina Chioveanu (https://twitter.com/hiimcatalina)
Andrew Fulcher (https://twitter.com/ajmfulcher)

Avatar for Andrew Fulcher

Andrew Fulcher

October 26, 2018
Tweet

More Decks by Andrew Fulcher

Other Decks in Technology

Transcript

  1. Cryptic to Clean* *Architecture for projects that never quite got

    around to it Catalina Chioveanu American Express @hiimcatalina Andrew Fulcher BBC @ajmfulcher
  2. 3

  3. 4 { "flights": [ { "from": "LHR", "to": "EDI", "fromTime":

    "2018-08-23T10:00:00+00:00", "toTime": "2018-08-23T11:30:00+00:00", "airline": "UK Airways", "link": "http://air.co.uk/flights/lhr" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T11:30:00+00:00", "toTime": "2018-08-23T13:00:00+00:00", "airline": "Spanish Airways", "link": "http://espana.air.es/ourflights/lhr" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T12:06:00+00:00", "toTime": "2018-08-23T13:36:00+00:00", "airline": "French Airways", "link": "http://le-vol.fr/flight/list?airport=lhr" } … }
  4. 5 A Reviewer What have you done??? I hate ads.

    Another Reviewer Bring back the old version. The new version has ads Yet Another Reviewer You are literally the worst.
  5. 6

  6. 7

  7. { "flights": [ { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T10:00:00+00:00",

    "toTime": "2018-08-23T11:30:00+00:00", "airline": "UK Airways", "link": "http://air.co.uk/flights/lhr", "flightNumber": "UK-1234" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T11:30:00+00:00", "toTime": "2018-08-23T13:00:00+00:00", "airline": "Spanish Airways", "link": "http://espana.air.es/ourflights/lhr", "flightNumber": "ES-4444" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T12:06:00+00:00", "toTime": "2018-08-23T13:36:00+00:00", "airline": "French Airways", "link": "http://le-vol.fr/flight/list?airport=lhr", "uniqueId": "45FD-8776-12DF-B644" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T10:37:00+00:00", "toTime": "2018-08-23T12:07:00+00:00", "airline": "German Airways", "link": "http://deutsch-air.de/start/london-heathrow", "flightNumber": "DE-8228" } ] } { "bookingProviders": [ { "name": "UK Airways", "flightEndpoint": "https://flights.air.co.uk/flight/{flightNumber}", "bookingEndpoint": "https://bookings.air.co.uk/flight/{flightNumber}" }, { "name": "Spanish Airways", "flightEndpoint": "https://flights.espana.air.es/flight/id/{flightNumber}", "bookingEndpoint": "https://bookings.espana.air.es/flight/{flightNumber}" }, { "name": "French Airways", "flightEndpoint": "https://le-vol.fr/flight-info?id={uniqueId}", "flightPriceEndpoint": "https://le-vol.fr/flight-prix?flightNumber={flightNumber}", "bookingEndpoint": "https://le-vol.fr/flight-bookings?flightNumber={flightNumber}" }, { "name": "German Airways", "flightEndpoint": "https://deutsch-air.de/flight-info/{flightNumber}", "bookingEndpoint": "https://bookings.deutsch-air.de/flight/{flightNumber}" } ] }
  8. public class FlightDetailApi { interface Callback { void onSuccess(FlightDetails flightDetails);

    } void request(String id, Callback callback) { makeFlightDetailApiVolleyRequest(id, callback); }
  9. class FlightDetailApi { interface Callback { void onSuccess(Object flightDetails); }

    void request(String id, Callback callback, boolean isFrenchAirways, boolean isPriceRequest) { if (isFrenchAirways && isPriceRequest) { makeFrenchAirwaysFlightPriceVolleyRequest(id, callback); } else if (isFrenchAirways) { makeFrenchAirwaysFlightDetailVolleyRequest(id, callback); } else { makeFlightDetailApiVolleyRequest(id, callback); } }
  10. private void requestPrice(final String flightNumber, boolean isFrenchAir) { mFlightDetailApi.request(flightNumber, new

    FlightDetailApi.Callback() { @Override public void onSuccess(Object flightDetails) { if (flightDetails instanceof FrenchAirwaysFlightDetails) { String flightNumber = ((FrenchAirwaysFlightDetails) flightDetails).getMetadata().getFlightNumber(); mFlightDetailApi.request(flightNumber, new FlightDetailApi.Callback() { @Override public void onSuccess(Object flightDetails) { handlePrice(((FrenchAirwaysPriceDetails) flightDetails).getPrice().getEconomy()); } }, true, true); } else { handlePrice(((FlightDetails) flightDetails).getCost()); } } }, isFrenchAir, false); } private void handlePrice(String price) {}
  11. public class FlightDetailApi { enum Airline { FRENCH_AIRWAYS, SPANISH_AIRWAYS }

    interface Callback { void onSuccess(Object flightDetails); } void request(String id, Callback callback, Airline airline, boolean isPriceRequest) { switch(airline) { case FRENCH_AIRWAYS: if (isPriceRequest) { makeFrenchAirwaysFlightPriceVolleyRequest(id, callback); } else { makeFrenchAirwaysFlightDetailVolleyRequest(id, callback); } case SPANISH_AIRWAYS: makeSpanishAirwaysFlightDetailApiVolleyRequest(id, callback); default: makeFlightDetailApiVolleyRequest(id, callback); } }
  12. private void requestPrice(String flightNumber, @Nullable FlightDetailApi.Airline airline) { mFlightDetailApi.request(flightNumber, new

    FlightDetailApi.Callback() { @Override public void onSuccess(Object flightDetails) { if (flightDetails instanceof FrenchAirwaysFlightDetails) { String flightNumber = ((FrenchAirwaysFlightDetails) flightDetails).getMetadata().getFlightNumber(); mFlightDetailApi.request(flightNumber, new FlightDetailApi.Callback() { @Override public void onSuccess(Object flightDetails) { handlePrice(((FrenchAirwaysPriceDetails) flightDetails).getPrice().getEconomy()); } }, FlightDetailApi.Airline.FRENCH_AIRWAYS, true); } else if (flightDetails instanceof String) { handlePrice(priceInPounds(parseXml((String) flightDetails).getPrice().getAmount())); } else { handlePrice(((FlightDetails) flightDetails).getCost()); } } }, airline, false); }
  13. New ventures - hotels { "flights": [ { "from": "LHR",

    "to": "EDI", "fromTime": "2018-08-23T10:00:00+00:00", "toTime": "2018-08-23T11:30:00+00:00", "airline": "UK Airways", "link": "http://air.co.uk/flights/lhr", "flightNumber": "UK-1234" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T11:30:00+00:00", "toTime": "2018-08-23T13:00:00+00:00", "airline": "Spanish Airways", "link": "http://espana.air.es/ourflights/lhr", "flightNumber": "ES-4444" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T12:06:00+00:00", "toTime": "2018-08-23T13:36:00+00:00", "airline": "French Airways", "link": "http://le-vol.fr/flight/list?airport=lhr", "uniqueId": "45FD-8776-12DF-B644" }, …. { "hotels": [ { "fromTime": "2018-08-23T10:00:00+00:00", "toTime": "2018-08-23T11:30:00+00:00", "location": "7 Cosy Neuk, Larkhall, ML9 2RH", "link": "http://ukflights.co.uk/hotels/" }, { "fromTime": "2018-08-23T11:30:00+00:00", "toTime": "2018-08-23T13:00:00+00:00", "location": "308a, Smithy St, Preston, PR5 6EY", "link": "http://espana.hotels.es/ourhotels/preston" }, { "fromTime": "2018-08-23T12:06:00+00:00", "toTime": "2018-08-23T13:36:00+00:00", "location": "51 Muir Wood Rd, Currie, EH14 5JE", "link": "http://hotelgo.fr/hotels/list" }, { "fromTime": "2018-08-23T10:37:00+00:00", "toTime": "2018-08-23T12:07:00+00:00", "location": "92, St. Peters Street, Town Centre, Derby, DE1 1SR", "link": "http://hotelsupermarket.de/start/uk" }, ] }
  14. New ventures - hotels { "flights": [ { "from": "LHR",

    "to": "EDI", "fromTime": "2018-08-23T10:00:00+00:00", "toTime": "2018-08-23T11:30:00+00:00", "airline": "UK Airways", "link": "http://air.co.uk/flights/lhr", "flightNumber": "UK-1234" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T11:30:00+00:00", "toTime": "2018-08-23T13:00:00+00:00", "airline": "Spanish Airways", "link": "http://espana.air.es/ourflights/lhr", "flightNumber": "ES-4444" }, { "from": "LHR", "to": "EDI", "fromTime": "2018-08-23T12:06:00+00:00", "toTime": "2018-08-23T13:36:00+00:00", "airline": "French Airways", "link": "http://le-vol.fr/flight/list?airport=lhr", "uniqueId": "45FD-8776-12DF-B644" }, …. { "hotels": [ { "fromTime": "2018-08-23T10:00:00+00:00", "toTime": "2018-08-23T11:30:00+00:00", "location": "7 Cosy Neuk, Larkhall, ML9 2RH", "link": "http://ukflights.co.uk/hotels/" }, { "fromTime": "2018-08-23T11:30:00+00:00", "toTime": "2018-08-23T13:00:00+00:00", "location": "308a, Smithy St, Preston, PR5 6EY", "link": "http://espana.hotels.es/ourhotels/preston" }, { "fromTime": "2018-08-23T12:06:00+00:00", "toTime": "2018-08-23T13:36:00+00:00", "location": "51 Muir Wood Rd, Currie, EH14 5JE", "link": "http://hotelgo.fr/hotels/list" }, { "fromTime": "2018-08-23T10:37:00+00:00", "toTime": "2018-08-23T12:07:00+00:00", "location": "92, St. Peters Street, Town Centre, Derby, DE1 1SR", "link": "http://hotelsupermarket.de/start/uk" }, ] }
  15. New ventures - hotels public class Flight { public String

    mFrom; public String mTo; public String mFromTime; public String mToTime; public String mAirline; public String mLink; public String mFlightNumber; } public class Hotel { public String mFromTime; public String mToTime; public String mLocation; public String mLink; } public class FlightOrHotel { public String mFrom; public String mTo; public String mFromTime; public String mToTime; public String mAirline; public String mLink; public String mFlightNumber; public String mLocation; }
  16. private void searchForFlightsAndHotels(String from, String to, String fromTime, String toTime)

    { new SearchAsyncTask(new SearchAsyncTask.Listener() { @Override public void onTaskCompleted(List<FlightOrHotel> flights) { mHotelSearchService.fetch(to, fromTime, toTime) .map(hotels -> { flights.addAll(hotels); return flights; }) .map(flightsAndHotels -> PersonalisationEngine.get().personalise(flightsAndHotels)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(MainActivity.this::updateRecyclerView); } }).execute(from, to, fromTime, toTime); }
  17. private void requestPrice(FlightOrHotel flightOrHotel, @Nullable FlightDetailApi.Airline airline) { if (flightOrHotel.maybeIsHotel())

    { mDisposables.add( mHotelDetailsService.fetch(extractPostcodeFromLocation(flightOrHotel.mLocation)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .map(result -> priceInPounds(result.getPrice(), result.getCurrency())) .subscribe(this::handlePrice) ); } else { String flightNumber = flightOrHotel.mUniqueId != null ? flightOrHotel.mUniqueId : flightOrHotel.mFlightNumber; mFlightDetailApi.request(flightOrHotel.mFlightNumber, new FlightDetailApi.Callback() { @Override public void onSuccess(Object flightDetails) { if (flightDetails instanceof FrenchAirwaysFlightDetails) { String flightNumber = ((FrenchAirwaysFlightDetails) flightDetails).getMetadata().getFlightNumber(); mFlightDetailApi.request(flightNumber, new FlightDetailApi.Callback() { @Override public void onSuccess(Object flightDetails) { handlePrice(((FrenchAirwaysPriceDetails) flightDetails).getPrice().getEconomy()); } }, FlightDetailApi.Airline.FRENCH_AIRWAYS, true); } else if (flightDetails instanceof String) { handlePrice(priceInPounds(parseXml((String) flightDetails).getPrice().getAmount())); } else { handlePrice(((FlightDetails) flightDetails).getCost()); } } }, airline, false); } }
  18. 24

  19. Can the hotel cards show room types? Airlines want their

    own in-app screens Why aren’t we using blockchain? We’re thinking about offering zero gravity shoe hire And what about images?
  20. 27 Divide and conquer sealed class SearchResult { data class

    Flight( val from: String, val to: String, val fromTime: String, val toTime: String, val airline: String, val link: String, val flightNumber: String?, val uniqueId: String? ) : SearchResult() data class Hotel( val fromTime: String, val toTime: String, val location: String, val link: String ) : SearchResult() } interface Searcher { fun search(request: SearchRequest): Single<List<SearchResult>> }
  21. Divide and conquer class Wrapper(private val hotelService: HotelSearchService) { fun

    search(request: SearchRequest): Single<List<FlightOrHotel>> { return Single.create { source -> SearchAsyncTask(SearchAsyncTask.Listener { flights -> hotelService.fetch(request.to, request.fromTime, request.toTime) .map { hotels -> flights.addAll(hotels) flights }.map { merged -> PersonalisationEngine.get().personalise(merged) }.subscribe(source::onSuccess) }).execute(request.from, request.to, request.fromTime, request.toTime) } } } class LegacySearcher(private val wrapper: Wrapper) : Searcher { override fun search(request: SearchRequest): Single<List<SearchResult>> { return wrapper.search(request) .map(::mapToSearchResult) } private fun mapToSearchResult(flightOrHotels: List<FlightOrHotel>): List<SearchResult> { ... } }
  22. Divide and conquer class LegacySearcher(private val wrapper: Wrapper) : Searcher

    { override fun search(request: SearchRequest): Single<List<SearchResult>> { return wrapper.search(request) .map(::mapToSearchResult) .map(PersonalisationEngine::apply) } } private fun mapToSearchResult(flightOrHotels: List<FlightOrHotel>): List<SearchResult> { ... } class Wrapper(private val hotels: HotelSearchService) { fun search(request: SearchRequest): Single<List<FlightOrHotel>> { return Single.create { source -> SearchAsyncTask(SearchAsyncTask.Listener { flights -> hotels.fetch(request.to, request.fromTime, request.toTime) .map { hotels -> flights.addAll(hotels) flights } .subscribe(source::onSuccess) }).execute(request.from, request.to, request.fromTime, request.toTime) } } }
  23. Divide and conquer class LegacySearcher(private val wrapper: Wrapper) : Searcher

    { override fun search(request: SearchRequest): Single<List<SearchResult>> { return wrapper.search(request) .map(PersonalisationEngine::apply) } } private fun mapToSearchResult(flightOrHotel: List<FlightOrHotel>):List<SearchResult> { ... } class Wrapper(private val hotelService: HotelSearchService, private val flightService: FlightSearchService) { fun search(request: SearchRequest): Single<List<SearchResult>> { return Single.zip<List<SearchResult>, List<FlightOrHotel>, List<SearchResult>>( flightService.fetch(request), hotelService.fetch(request.to, request.fromTime, request.toTime), BiFunction { flightList, hotelList -> flightList + mapToSearchResult(hotelList) }) }
  24. Divide and conquer class LegacySearcher(private val wrapper: Wrapper) : Searcher

    { override fun search(request: SearchRequest): Single<List<SearchResult>> { return wrapper.search(request) .map(PersonalisationEngine::apply) } } class Wrapper(private val hotelService: HotelSearchService, private val flightService: FlightSearchService) { fun search(request: SearchRequest): Single<List<SearchResult>> { return Single.zip<List<SearchResult>, List<FlightOrHotel>, List<SearchResult>>( flightService.fetch(request), hotelService.fetch(request.to, request.fromTime, request.toTime), BiFunction { flightList, hotelList -> flightList + mapToSearchResult(hotelList) }) } private fun mapToSearchResult(flightOrHotel: List<FlightOrHotel>):List<SearchResult> { ... }
  25. Divide and conquer class RefactoredSearcher() : Searcher { override fun

    search(request: SearchRequest, flightService: FlightSearchService, hotelService: HotelSearchService) :Single<List<SearchResult>> { return Single.zip<List<SearchResult>, List<SearchResult>, List<SearchResult>>( flightService.fetch(request), hotelService.fetch(request), BiFunction { flights, hotels -> flights + hotels }).map(PersonalisationEngine::apply) } class Wrapper(private val hotelService: HotelSearchService, private val flightService: FlightSearchService) { fun search(request: SearchRequest): Single<List<SearchResult>> { return Single.zip<List<SearchResult>, List<FlightOrHotel>, List<SearchResult>>( flightService.fetch(request), hotelService.fetch(request.to, request.fromTime, request.toTime), BiFunction { flightList, hotelList -> flightList + hotelList }) }
  26. Different points of view class FlightViewHolder(val view: FlightView, private val

    details: FlightDetailApi) : RecyclerView.ViewHolder(view) { fun bind(flight: SearchResult.Flight) { val flightNumber = if (flight.uniqueId != null) flight.uniqueId else flight.flightNumber details.request(flightNumber, { flightDetails -> when(flightDetails) { is FrenchAirwaysFlightDetails -> { details.request(flightDetails.metadata.flightNumber, { response -> view.setPrice((response as FrenchAirwaysPriceDetails).price.economy) }, FlightDetailApi.Airline.FRENCH_AIRWAYS, true) } is String -> { view.setPrice(priceInPounds(parseXml(flightDetails).price.amount)) } else -> view.setPrice((flightDetails as FlightDetails).cost) } }, flight.airlineId(), false) } fun unbind() {} private fun SearchResult.Flight.airlineId(): FlightDetailApi.Airline {...} private fun parseXml(string: String): GermanAirwaysFlightDetails {...} private fun priceInPounds(priceInEuros: Int): String {...} } class HotelViewHolder(val view: HotelView, private val details: HotelDetailsService) : RecyclerView.ViewHolder(view) { private val disposables = CompositeDisposable() fun bind(hotel: SearchResult.Hotel) { disposables += details.fetch(hotel.extractPostcode()) .map { it.priceInPounds() } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(view::setPrice) } fun unbind() { disposables.clear() } private fun SearchResult.Hotel.extractPostcode():String {} private fun HotelDetails.priceInPounds(): String {} }
  27. Different points of view class FlightViewHolder(val view: FlightView, private val

    details: FlightDetailApi) : RecyclerView.ViewHolder(view) { fun bind(flight: SearchResult.Flight) { val flightNumber = if (flight.uniqueId != null) flight.uniqueId else flight.flightNumber details.request(flightNumber, { flightDetails -> when(flightDetails) { is FrenchAirwaysFlightDetails -> { details.request(flightDetails.metadata.flightNumber, { response -> view.setPrice((response as FrenchAirwaysPriceDetails).price.economy) }, FlightDetailApi.Airline.FRENCH_AIRWAYS, true) } is String -> { view.setPrice(priceInPounds(parseXml(flightDetails).price.amount)) } else -> view.setPrice((flightDetails as FlightDetails).cost) } }, flight.airlineId(), false) } fun unbind() {} private fun SearchResult.Flight.airlineId(): FlightDetailApi.Airline {...} private fun parseXml(string: String): GermanAirwaysFlightDetails {...} private fun priceInPounds(priceInEuros: Int): String {...} } class HotelViewHolder(val view: HotelView, private val details: HotelDetailsService) : RecyclerView.ViewHolder(view) { private val disposables = CompositeDisposable() fun bind(hotel: SearchResult.Hotel) { disposables += details.fetch(hotel.extractPostcode()) .map { it.priceInPounds() } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(view::setPrice) } fun unbind() { disposables.clear() } private fun SearchResult.Hotel.extractPostcode():String {} private fun HotelDetails.priceInPounds(): String {} }
  28. interface SearchUseCase { fun search(request: SearchRequest): Observable<SearchItem> } data class

    SearchItem( val searchResult: SearchResult, val price: String) Keeping it Clean
  29. class SearchWithPrice(private val searcher: Searcher, private val priceUseCase: PriceUseCaseAggregator) :

    SearchUseCase { override fun search(request: SearchRequest): Observable<SearchItem> { return searcher.search(request) .flattenAsObservable { it } .switchMapSingle { searchResult -> priceUseCase.getPrice(searchResult).map { price -> SearchItem(searchResult, price) } } } } Keeping it Clean
  30. class PriceUseCaseAggregator(private vararg val useCases: PriceUseCase) { fun getPrice(searchResult: SearchResult):

    Single<String> { return useCases .first { it.canHandle(searchResult) } .getPrice(searchResult) } } interface PriceUseCase { fun canHandle(result: SearchResult): Boolean fun getPrice(result: SearchResult): Single<String> } Keeping it Clean class SearchWithPrice(private val searcher: Searcher, private val priceUseCase: PriceUseCaseAggregator) : SearchUseCase { override fun search(request: SearchRequest): Observable<SearchItem> { return searcher.search(request) .flattenAsObservable { it } .switchMapSingle { searchResult -> priceUseCase.getPrice(searchResult).map { price -> SearchItem(searchResult, price) } } } }
  31. class PriceUseCaseAggregator(private vararg val useCases: PriceUseCase) { fun getPrice(searchResult: SearchResult):

    Single<String> { return useCases .first { it.canHandle(searchResult) } .getPrice(searchResult) } } interface PriceUseCase { fun canHandle(result: SearchResult): Boolean fun getPrice(result: SearchResult): Single<String> } Keeping it Clean class SearchWithPrice(private val searcher: Searcher, private val priceUseCase: PriceUseCaseAggregator) : SearchUseCase { override fun search(request: SearchRequest): Observable<SearchItem> { return searcher.search(request) .flattenAsObservable { it } .switchMapSingle { searchResult -> priceUseCase.getPrice(searchResult).map { price -> SearchItem(searchResult, price) } } } }
  32. Keeping it Clean class FrenchPriceUseCase(private val api: FrenchAirwaysApi) : PriceUseCase

    { override fun canHandle(result: SearchResult): Boolean { return (result as? SearchResult.Flight)?.airline == "French Airways" } override fun getPrice(result: SearchResult): Single<String> { return (result as? SearchResult.Flight)?.uniqueId?.let { uniqueId -> api.details(uniqueId) .flatMap { details -> api.price(details.metadata.flightNumber) } .map { priceDetails -> priceDetails.price.economy } } ?: Single.error(RuntimeException()) } } interface PriceUseCase { fun getPrice(result: SearchResult): Single<String> fun canHandle(result: SearchResult): Boolean } interface FrenchAirwaysApi { @GET("/flightDetails") fun details(@Query("uniqueId") uniqueId: String): Single<FrenchAirwaysFlightDetails> @GET("/flightPrice") fun price(@Query("flightNumber") flightNumber: String): Single<FrenchAirwaysPriceDetails> }
  33. Keeping it Clean class GermanPriceUseCase(private val api: GermanAirwaysApi) : PriceUseCase

    { override fun canHandle(result: SearchResult): Boolean { return (result as? SearchResult.Flight)?.airline == "German Airways" } override fun getPrice(result: SearchResult): Single<String> { return (result as? SearchResult.Flight)?.flightNumber?.let { id -> api.details(id).map { it.priceInPounds() } } ?: Single.error(RuntimeException()) } private fun GermanAirwaysFlightDetails.priceInPounds(): String {} } interface PriceUseCase { fun getPrice(result: SearchResult): Single<String> fun canHandle(result: SearchResult): Boolean } interface GermanAirwaysApi { @GET("/flight/{flightNumber}") fun details(@Path("flightNumber") flightNumber: String): Single<GermanAirwaysFlightDetails> fun createApi(): GermanAirwaysApi { return Retrofit .Builder() .addConverterFactory(SimpleXmlConverterFactory.create()) .build().create(GermanAirwaysApi::class.java) }
  34. Keeping it Clean class UKPriceUseCase(private val api: UKAirwaysApi) : PriceUseCase

    { override fun canHandle(result: SearchResult): Boolean { return (result as? SearchResult.Flight)?.airline == "UK Airways" } override fun getPrice(result: SearchResult): Single<String> {} } class SpanishPriceUseCase(private val api: SpanishAirwaysApi) : PriceUseCase { override fun canHandle(result: SearchResult): Boolean { return (result as? SearchResult.Flight)?.airline == "Spanish Airways" } override fun getPrice(result: SearchResult): Single<String> {} } class HotelPriceUseCase(private val api: HotelApi) : PriceUseCase { override fun canHandle(result: SearchResult): Boolean { return result is SearchResult.Hotel } override fun getPrice(result: SearchResult): Single<String> {} } interface UKAirwaysApi { @GET("/flight/{flightNumber}") fun details(@Path("flightNumber") flightNumber: String): Single<UKAirwaysFlightDetails> } interface SpanishAirwaysApi { @GET("/flight/{flightNumber}") fun details(@Path("flightNumber") flightNumber: String): Single<SpanishAirwaysFlightDetails> } interface HotelApi { @GET("/flight/{flightNumber}") fun details(@Path("flightNumber") flightNumber: String): Single<HotelDetails> }
  35. Keeping it Clean class UKPriceUseCase(private val api: UKAirwaysApi) : PriceUseCase

    { override fun canHandle(result: SearchResult): Boolean { return (result as? SearchResult.Flight)?.airline == "UK Airways" } override fun getPrice(result: SearchResult): Single<String> {} } interface UKAirwaysApi { @GET("/flight/{flightNumber}") fun details(@Path("flightNumber") flightNumber: String): Single<UKAirwaysFlightDetails> } class CapturingMockUKAirwaysApi : UKAirwaysApi { var capturedFlightNumber: String? = null override fun details(flightNumber: String): Single<UKAirwaysFlightDetails> { capturedFlightNumber = flightNumber return Single.just(MockUKAirwaysFlightDetails()) } } class UKPriceUseCaseTest { @Test fun testCorrectPriceIsReturned() { val searchResult = mockSearchResult() val mockApi = CapturingMockUKAirwaysApi() val useCase = UKPriceUseCase(mockApi) useCase.getPrice(searchResult).test().assertValue("£48") assertEquals(searchResult.flightNumber, mockApi.capturedFlightNumber) } fun mockSearchResult(): SearchResult.Flight {} }
  36. Keeping it Clean UI Domain Data Handle data retrieval from

    sources (network, disk, etc), deserialisation, and caching. Business logic applied by use cases UI-related components (views, fragments, activities) and patterns (MVVM, MVI) display the data
  37. New markets productFlavors { earth { dimension "mode" applicationIdSuffix ".earth"

    versionNameSuffix "-earth" } mars { dimension "mode" applicationIdSuffix ".mars" versionNameSuffix "-mars" } } earthImplementation project(':hotel-interactor') implementation project(':flight-interactor') implementation project(':french-air-interactor') implementation project(':german-air-interactor') implementation project(':spanish-air-interactor') implementation project(':uk-air-interactor')
  38. class PriceItemInteractor { val providersClasses = arrayOf( "com.droidcon.travelapp.frenchairinteractor.FrenchUseCaseFactory", "com.droidcon.travelapp.germanairinteractor.GermanUseCaseFactory", "com.droidcon.travelapp.spanishairinteractor.SpanishUseCaseFactory",

    "com.droidcon.travelapp.ukairinteractor.UkUseCaseFactory") fun getPriceUseCases(): ArrayList<PriceUseCase> { val providers = ArrayList<PriceUseCase>() for (providerString in providersClasses) { try { val providerClass = Class.forName(providerString) val provider = providerClass.newInstance() as PriceUseCase providers.add(provider) } catch (e: Exception) { e.printStackTrace() } } return providers } }
  39. @Module class EarthInteractorModule { @Provides fun provideAggregator(): PriceUseCaseAggregator { return

    PriceUseCaseAggregator( FrenchAirPriceUseCase(), GermanAirPriceUseCase(), SpanishAirPriceUseCase(), UKAirPriceUseCase(), HotelPriceUseCase() ) } } @Module class MarsInteractorModule { @Provides fun provideAggregator(): PriceUseCaseAggregator { return PriceUseCaseAggregator( SpaceYPriceUseCase(), RedDestinationPriceUseCase(), GalacticPicklePriceUseCase() ) } } @Module(includes = [EarthInteractorModule::class]) class FlavouredModules @Module(includes = [MarsInteractorModule::class]) class FlavouredModules
  40. public class ClassicDaggerActivity extends AppCompatActivity { @Inject MyClass myClass; @Override

    protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); ((MyCustomApplication) getApplication()).getObjectGraph().inject(this); } } public class AndroidInjectionDaggerActivity extends AppCompatActivity { @Inject MyClass myClass; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); AndroidInjection.inject(this); } }
  41. Airlines want their own in-app screens Why aren’t we using

    blockchain? We’re thinking about offering zero gravity shoe hire
  42. The Repository public interface Repository<K, T> { /** * Observable

    for returning the response based on a request */ Observable<T> fetch(@NonNull K path); }
  43. class SimpleRepository<K, T>(private val cache: Cache<K, T>, private val source:

    Source<K>, private val deserialiser: Deserialiser<T>) : Repository<K, T> { override fun fetch(key: K): Observable<T> { return fetchFromCache(key)?.let { Observable.just(it) } ?: fetchFromDatasource(key) } private fun fetchFromCache(key: K): T? { return cache.getFresh(key) ?: cache.getStale(key)?.let { updateCache(key) it } } private fun updateCache(key: K) { fetchFromDatasource(key) .subscribe() } private fun fetchFromDatasource(key: K): Observable<T> { return source.fetch(key) .map(deserialiser::deserialise) .doOnNext { result -> cache.put(key, result) } } } The Repository interface Cache<K, T> { fun getFresh(key: K): T? fun getStale(key: K): T? fun put(key: K, entry: T) } interface Source<K> { fun fetch(key: K): Observable<T> } interface Deserialiser<T> { fun deserialise(body: String): T }
  44. class FrenchAirPriceUseCase(private val flightDetailRepository: Repository<String, FrenchAirwaysFlightDetail>, private val flightPriceRepository: Repository<String,

    FrenchAirwaysFlightPrice>) : PriceUseCase() { override fun fetch(flightNumber: String): Single<PriceItem> { return flightDetailRepository.fetch("/flightDetail/$flightNumber") .flatMap { result -> flightPriceRepository.fetch("/flightPrice/$result.flightId") .map { PriceItem(it.price) } } } override fun canHandle(airline: String): Boolean { return airline == "French Airways" } }
  45. A Google user The space goat Android McDroidyFace 20 October

    2018 20 October 2018 20 October 2018 Yayy the app doesn’t randomly crash anymore Love the zero gravity shoe hire feature The new version is much more stable
  46. A Google user The space goat Android McDroidyFace 20 October

    2018 20 October 2018 20 October 2018 Yayy the app doesn’t randomly crash anymore Love the zero gravity shoe hire feature The new version is much more stable