Monday, January 18, 2016

SOLID Principles: ISP (4 of 4)

If you missed the previous post from this series check it out SOLID Principles: LSP (3 of 4).

Interface segregation principle: ISP

The principle states that
"No client should be forced to depend on methods it does not use."
In other words what the principle says is that components should be discrete: if you ask someone its name you don't want to know that he is divorced four times and likes Italian food.

Imaging you are working with an input mapped to a Camera, the stream of data coming from that source is Read Only, and Non Seekable. You have no way to "write" to the camera feed, or to seek the camera to a specific time; you only get to read or loose the data. But because you are using a general purpose IO framework you end up using an InputSream with reset() and mark() / seek() methods.

What the ISP says for cases like this, is to split the interface into as many smaller ones as needed by the clients. The division is done looking at the "roles" the initial interface must play. In our case the InputStream is playing a both the Read Only and Non Seekable roles. The first one is implicit by the name prefix "Input", so we don't need to create another one to indicate that is an Read Only stream. But for the second role we need a way to say that our stream does not allows seek operations. So we could split the InputStream in two components: InputStream, and SeekableStream. The first one stays as is, with the seek-related methods removed. The second one will only have the seek-only methods on it.

Related Concepts

In the previous example we have an, initial, InputStream playing to many roles: usually those interfaces are called "Fat Interfaces".

After doing the cleaning process, we end up with two more specific interfaces one to only read and another allowing us to seek, those interfaces are called "Slim Interfaces", or "Role Interfaces", meaning they are describing a single role.

Of course, that does not means that we are unable to implement both interfaces in a single concrete component. But for clients expecting just one of the roles we can declare that the given component fulfills that role, and no more, by casting the concrete implementation with the needed role interface.

Something like this:
1
2
3
4
5
CameraOrMediaInputStream camInput = new CameraOrMediaInputStream(...);
CameraReader camReader = new CameraReader((InputStream)camInput);

CameraOrMediaInputStream mediaInput = new CameraOrMediaInputStream(...);
MediaReader mediaReader = new MediaReader((SeekableStream)mediaInput);

Casts are redundant in many OO languages but were added to imply the signature of both constructors expecting the steams.

Be aware that "Role Interfaces", does not mean "Single Method Interfaces", a role may be fulfilled by a single method, or by sequence of calls to related methods.

Code sample

Code samples are on github repo lsolano/blog.solid.demo
The code was written using Scala 2.11.7 using the JVM 1.8.0u20. The example for this principle is an Automatic Teller Machine (ATM). Our ATM is located in an international airport so it must handle withdraws with currency conversions. The ATM also supports deposits using both cash and cheques.

Here you have a summary of the use cases:

The initial class diagram for these use cases are:
Fat Interface
Fat Interface

Here is the code for the "fat" ATMInteractor component:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ATMInteractor(securityService: SecurityService, withdrawalService: WithdrawalService,
    depositService: AnyRef, currencyRatesService: AnyRef) {
  require(securityService != null)
  require(withdrawalService != null)
  require(depositService != null)
  require(currencyRatesService != null)

  def validate(request: CustomerValidationRequest): CustomerValidationResponse = {
    val validation = securityService.validateCustomer(PlasticInfo(request.secret, request.pin))

    CustomerValidationResponse(validation.valid)
  }

  def withdrawal(request: WithdrawalRequest): TransactionResponse = {
    val response = withdrawalService.withdrawal(com.malpeza.solid.isp.model.Withdrawal(request.pin, request.amount))
    val failReason = response.reason match {
      case Some(r) => r match {
        case com.malpeza.solid.isp.model.InsufficientBalance => InsufficientBalance
        case _ => CallBank
      }
      case _ => CallBank
    }

    TransactionResponse(response.done, Option(failReason))
  }
  
  def deposit(request: AnyRef) = ???
}

object ATMInteractor {
  def apply(securityService: SecurityService, withdrawalService: WithdrawalService,
    depositService: AnyRef, currencyRatesService: AnyRef): ATMInteractor = {
    new ATMInteractor(securityService, withdrawalService, depositService, currencyRatesService)
  }
}

What is wrong with this code: ISP?

Simply put, this component is doing to much. It handles all use cases: Customer Validation, Withdrawal, and Deposit. Because of that, it has to many dependencies (services).

If you are validating a customer you don't need to know anything about Deposits or Withdrawals.

Cleaning the code

Honoring the ISP

We need to split all these responsibilities and create specialized components able to handle each interaction (transaction). So we'll end up with three components named CustomerValidation, Withdrawal, and Deposit; each one representing a use case. To keep this post short, the Deposit and Withdrawal with currency conversions scenarios were left out.

This is the revised class diagram showing only the important components:
Clean Interfaces
Clean Interfaces


CustomerValidation code:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class CustomerValidation(securityService: SecurityService) {
  require(securityService != null)

  def validate(request: CustomerValidationRequest): CustomerValidationResponse = {
    val validation = securityService.validateCustomer(PlasticInfo(request.secret, request.pin))

    CustomerValidationResponse(validation.valid)
  }
}

object CustomerValidation {
  def apply(securityService: SecurityService) = new CustomerValidation(securityService)
}

Withdrawal code:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class Withdrawal(withdrawalService: WithdrawalService) {
  require (withdrawalService != null)
  
  def withdrawal(request: WithdrawalRequest): TransactionResponse = {
    val response = withdrawalService.withdrawal(com.malpeza.solid.isp.model.Withdrawal(request.pin, request.amount))
    val failReason = response.reason match {
      case Some(r) => r match {
        case com.malpeza.solid.isp.model.InsufficientBalance => InsufficientBalance
        case _ => CallBank
      }
      case _ => CallBank
    }

    TransactionResponse(response.done, Option(failReason))
  }
}

object Withdrawal {
  def apply(withdrawalService: WithdrawalService) = new Withdrawal(withdrawalService)
}

Agile Link

With the ISP we gain Orthogonality. For that concept I mean the following meanings
statistically independent
or
non-overlapping, uncorrelated, or independent objects of some kind

In other words, we get components with none or little relation. If one must change the other(s) remain untouched. This minimizes the impact of changes.

Orthogonality can be applied to architectural layers such as IO, Persistence, Transactions, Logging, etc.; or to behaviors like the sample project presented here. The last version has a clear separation of Use Cases. We could implement all those behaviors in a single component but that will make the code harder to maintain.

With such a reduced amount of use cases is difficult to see the need for this (over)engineering, but try to imagine the following:

You are working in an online retailing product's back-end API. It's the fifth year since the API initial version, and you have about 500 different use cases, each one with some pretty complicated scenarios such as:
  • Checkout with mixed payment methods: Coupons + Prepaid Card + Credit Card + PayPal, etc.
  • Manage upper bounds for customers by category to protect then from errors or malicious actions
  • Single purchase with multiple items with arbitrary groups allowing for shipment to different addresses
  • Manufacturers promotions by random distribution for customers matching certain profiles
  • ...

How many times you think you would expend each time you need to add a new feature or change an existing one if all these cases are implemented into a single component, or distributed within a small group of components?

Series links

Previous, SOLID Principles: LSP (3 of 4)
Next: none, this is the last one.

No comments:

Post a Comment

enter your comments here...

Subscribe to our mailing list

* indicates required
Email Format