Programming to an Interface
Intro
How often do we as software developers hear the words ‘maintainable’, ‘scalable’ and ‘testable’ when we talk about software solutions? I’ll venture a guess, a lot? And to an extend, how often is it that the proposed answers are murky or overly complex at best? Unlike the previous question, I’ll leave the answer to You, the reader. The one thing I could possibly contribute is one of the more important techniques in object oriented programming (OOP) and that is: programming to an interface.
A lot of object-oriented programming centers around the design principles of programming to an interface with it defining the behavior of an object through an interface (a collection of abstract methods or properties) rather than depending on some concrete implementation of the said interface.
Programming to an interface can provide a variety of benefits in many software solutions, including:
- Encapsulation: By defining a contract through an interface, you can hide the details of an object’s implementation (implementation detail). This level of abstraction focuses only on the functionality that is needed, leaving any internal state and behavior of an object protected.
- Testability: Programming to an interface enables easier testing, as you can create mock implementations of the interface for unit testing. This can be extremely powerful since it enables testing certain components in isolation.
- Decoupling: Using contracts for the purpose of communication between two or more components in a project can make it easier to modify, extend, or even replace entire components, the implementation detail at least, without affecting other dependant components. Needless to say that this can be a large contributor to making more modular and maintainable solutions.
- Flexibility: Hidden behind interface contracts, third-party solutions and libraries can be easily replaced with other ones. The only important thing is that the interface contracts are fulfilled by the new libraries. This is particularly useful when working with third-party libraries or APIs, as it enables you to adapt your code to new requirements or updates without major refactoring.
- Reusability: When you program to an interface, you can reuse code that depends on the interface with any object that implements it.
For this article we’ll try to together explore the concept of programming to an interface. The examples provided are written using the Kotlin programming language since it’s a modern programming language for the JVM that has become increasingly popular over the past few years. It’s perfectly alright If you might not be not familiar with the Kotlin programming language, since most of the example concepts given can be applied to any programming language that has an contract abstraction feature that supports the ideas behind programming to an interface.
Again, why Kotlin?
Because I needed the refresher on the programming language at the time of writing this article. Other than that, Kotlin is a modern, statically-typed programming language that runs on the Java Virtual Machine (JVM). The language has gained in popularity and usage due to its null safety features, modern syntax, and interoperability with the Java programming language. The language has gained a lot of boost when it was officially supported by Google (Alphabet) for Android app development.
In Kotlin, interfaces play a crucial role in supporting the programming to an interface design principle. Kotlin interfaces are similar to Java interfaces. Like Java, In Kotlin interfaces can have default method implementations, which can help reduce code duplication and simplify inheritance structures. Kotlin interfaces allow you to define a contract that classes must adhere to when implementing the interface. They consist of abstract methods and properties, which must be overridden and implemented by the implementing classes.
In the following sections, we will delve into the details of defining, implementing, and working with interfaces in Kotlin, providing examples and use cases to showcase the power of programming to an interface in this modern programming language.
Interfaces explained
In software development, interfaces are abstract types that define a contract or a set of rules for classes that implement them need to follow (hence contract). Interfaces usually declare method signatures and properties which the implementing classes must provide definitions for.
In Kotlin, we can define an interface using the interface keyword, followed by the interface name and an optional list of supertypes. Inside the interface, we can declare abstract properties, methods, and even provide default implementations for some methods.
interface InterfaceName {
// Methods and properties
}
A simple interface with methods and properties can look something like the following:
interface Shape {
val name: String
fun area(): Double
fun perimeter(): Double
}
In the example above the Shape interface has two abstract methods, area and perimeter and one property, name.
With a defined interface, we can proceed with the process implement it in a class. We will use a colon (:) after the class name, followed by the name of the interface:
class ClassName : InterfaceName {
// Class implementation
}
When implementing an interface, we must provide implementations for all abstract methods and properties declared in the interface itself. We do that by using the keyword override to explicitly note that we are providing an implementation for an interface method or property. If we use our previous Shape interface example onto a new implementing class, ti can look something similar to this:
class Circle(val radius: Double) : Shape {
override val name: String = "Circle"
override fun area(): Double {
return Math.PI * radius * radius // Math is a standard class in the java.lang package
}
override fun perimeter(): Double {
return 2 * Math.PI * radius
}
}
The Circle class above needs to fulfill the interface contract in order to satisfy the Kotlin compiler. It should be noted that this can also be done without the actual implementation method contents and easily be replaced with TODO blocks that satisfy the Kotlin compiler,by any method invocation the running application will cause a crash.
Since Kotlin also supports default method feature, they do not need to be overridden by the implementing classes since they provide default implementations. The contract remains fulfilled in this scenario.
Let’s see another example of the Shape implementation:
class Rectangle(private val width: Double, private val height: Double) : Shape {
override val name: String = "Rectangle"
override fun area(): Double {
return width * height
}
override fun perimeter(): Double {
return 2 * (width + height)
}
}
The new class also satisfies the Shape interface contract same as the Circle class before. With both classes adhering to the same contract we are able see the potential benefits of programming to an interface, such as encapsulation, decoupling and other ones mentioned in the introduction segment.
Type Checking
In OOP, the concept of polymorphism allows objects of different classes to be treated as objects of a common superclass or interface. This feature enables us to write more flexible and reusable code by programming to an interface.
To use polymorphism in Kotlin, we can create a function that accepts an interface as a parameter in its signature. This can enable a function to be used with any object that implements that interface. We’ll continue with our Shape example from previous sections:
fun someFunctionName(shape: Shape) {
print(shape.name) // Prints the name of a concrete Shape object to the console
}
Since our function takes Shape as one of the parameters, we can use any object that implements that contract with it:
fun main(){
val circle = Circle(2.0)
val rectangle = Rectangle(2.0,2.0)
someFunctionName(circle) // Prints “Circle”
someFunctionName(rectangle) // Prints “Rectangle”
}
We can already see the potential of programming to an interface. What limits us to change the implementation details of the Circle and Rectangle objects and keep using the same functions and what keeps us from creating new objects that implement the Shape interface? The rest of our project’s code will remain unchanged.
We can utilize interfaces for type checking as well. At times we might need to check if an object implementing an interface is of a specific class. Kotlin provides the is keyword to perform type checking. The smart casting feature casts the object to the target type within the scope of the type check.
We can create a function that type checks an object implementing the Shape interface and performs a custom action for Circle object instances:
fun someDifferentFunction(shape: Shape) {
if (shape is Circle) {
val doubleTheRadius = shape.radius * 2
println("The updated circle diameter is $doubleTheRadius")
} else {
println("This is not a circle!")
}
}
In the above code, if the type check is successful, Kotlin’s smart casting feature automatically casts the shape object to a Circle instance, allowing you to access its radius property. Now, let’s update our main function and see how the new function can be applied:
fun main() {
val circle = Circle(5.0)
val rectangle = Rectangle(4.0, 6.0)
someDifferentFunction(circle) // Prints: The updated circle diameter is 10.0
someDifferentFunction(rectangle) // prints: This is not a circle!
}
Even at this point we can see some of the potential benefits of programming to an interface with a versatile way of implementing different project strategies.
Interface Inheritance and Composition
Interface composition can be a powerful technique that enables us to write clean, modular, and flexible code. With this we can encourage the interface segregation and separation of concerns core principles of SOLID design guidelines. It should be noted that by composing small, focused interfaces our classes should only contain the implementation details it needs.
In Kotlin, we we have to separate the interface names with a comma (,) after the colon (:) in order to inherit from multiple interfaces:
interface MixedInterface : InterfaceA, InterfaceB {
// Additional properties and methods
}
In order to extends the functionality of our Shape objects, let’s create two additional interfaces, Drawable and Resizable, to work with the Shape interface from the previous sections:
interface Drawable {
fun draw()
}
interface Resizable {
fun resize(factor: Double)
}
With the two new interfaces in place, we can now create a new contract for an AdvancedShape that will have the functionality and properties from them:
interface AdvancedShape : Shape, Drawable, Resizable // no need for the body curly braces
We can now create new advanced shapes:
class AdvancedCircle(val radius: Double) : AdvancedShape {
override val name: String = "AdvancedCircle"
override fun area(): Double {
return Math.PI * radius * radius //
}
override fun perimeter(): Double {
return 2 * Math.PI * radius
}
override fun draw() {
println("Drawing a $name with radius $radius.")
}
override fun resize(factor: Double) {
val newRadius = radius * factor
println("Resizing the $name to new radius of $newRadius.")
}
}
As we can see the new AdvancedCircle class provides implementations for the name property and the area, perimeter, draw, and resize methods.
Dependency Injection and Inversion of Control
In order to further solidify programming to an interface can improve our overall project structure and dependency chains, we should look at how interfaces play an important role in implementing these principles.
Dependency Injection
Dependency Injection (DI) is a design pattern that enforces the method of decoupling multiple components in a software system. In DI, dependencies between components are provided (or “injected”) externally rather than being hard-coded. With this method, we can make components more modular, testable, and maintainable.
Interfaces are crucial in implementing DI. If we would define one interface for a dependency, we can swap out different implementations of the dependency without ever modifying the dependent component.
Inversion of Control
Inversion of Control (IoC) is a design principle that delegates the control of dependency management from individual components to an external container or framework. IoC enforces decoupling and flexibility by inverting the traditional flow of control, where components create and manage their dependencies. Using interfaces in combination with the IoC principle enables you to create modular, and maintainable solutions.
In order to demonstrate the true power of DI and IoC let’s see the following example of a proposed payment processor, and don’t worry about the somewhat increased lines of code than in the previous examples, we are progressing aren’t we?
Let’s start with defining the payment processor interface contract:
interface PaymentProcessor {
fun processPaymentTransaction(amount: Double, currency: String): Boolean
}
When we have defined the general contract, now we can define a couple of example payment processors:
class StripeProcessor : PaymentProcessor {
override fun processPaymentTransaction(amount: Double, currency: String): Boolean {
println("Stripe processing: $amount $currency")
return true
}
}
class PayPalProcessor : PaymentProcessor {
override fun processPaymentTransaction(amount: Double, currency: String): Boolean {
println("PayPal processing: $amount $currency")
return true
}
}
Let’s now create an imaginary online store component that depends on the PaymentProcessor contract rather than some concrete payment processor implementation:
class SomeInterestingShop(private val paymentProcessor: PaymentProcessor) {
fun checkoutCart(amount: Double, currency: String): Boolean {
val transactionResult = paymentProcessor.processPaymentTransaction(amount, currency)
if (transactionResult) {
println("Payment successful! Thank you for your purchase.")
} else {
println("Payment failed! Please try again.")
}
return transactionResult;
}
}
Our main function should look something like this now:
fun main() {
val stripeProcessor = StripeProcessor()
val payPalProcessor = PayPalProcessor()
val storeWithStripe = SomeInterestingShop(stripeProcessor)
val storeWithPayPal = SomeInterestingShop(payPalProcessor)
storeWithStripe.checkoutCart(10.0, "EUR") // Prints: Stripe processing: 10.0 EUR
storeWithPayPal.checkoutCart(7500.0, "RSD") // Prints: PayPal processing: 7500.0 RSD
}
If we follow the DI principle, we can inject the required PaymentProcessor implementations (StripeProcessor and PayPalProcessor) at runtime which inevitably makes the SomeInterestingShop component more modular and adaptable to different situations.
Testing and Mocking
When we want to write new unit tests, we aim to isolate the testing unit of code from any dependencies it might have. This can be frustrating at times, especially in legacy code blocks, with some components (units in this case) being dependant on other concrete components. Whatever test we write for a component that depends on some other concrete implementation details won’t be a unit test by definition, it will be an integrated test between two or more components. One possible way to achieve the necessary isolation between two testing components is by using test doubles, such as mocks or stubs. Mocks and stubs replace the actual dependencies during testing. When we use interfaces we make it easy on ourselves to create test doubles since they define the contract that the test double must stick to.
There are several testing frameworks available for Kotlin that can help us create test doubles, such as Mockito or MockK. Any mocking framework should allow you to create mocks and stubs for interfaces and even provide some kind of functionality for verifying interactions between your code and its dependencies.
We’ll continue with our fictional PaymentProcessor solution and provide the next unit test example for our SomeInterestingShop using the MockK framework.
Let’s first add the JUnit5 and MockK frameworks to our project as a Gradle dependency and yes, I do realize that I’ve never specified that this example project uses the Gradle build system, but detailing such steps would definitely fall out of the scope for this, already long-ish, article topic. All the source code for the examples given in this article are available on this GitHub repository.
To add the MockK dependency, we’ll put the following in our build.gradle.kst file:
dependencies {
// Other dependencies
// JUnit5
testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.2") //Check for latest versions
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.2") //Check for latest versions
// MockK
testImplementation("io.mockk:mockk:1.13.5") //Check for latest versions
}
We’ll next sync our project and proceed with creating the SomeInterestingShop unit test class (test project package):
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class SomeInterestingShopTest {
@Test
fun `process payment and return true`() {
// Arrange
val mockPaymentProcessor = mockk<PaymentProcessor>()
every { mockPaymentProcessor.processPaymentTransaction(any(), any()) } returns true
val onlineStore = SomeInterestingShop(mockPaymentProcessor)
// Act
val result = onlineStore.checkoutCart(100.0, "RSD")
// Assert
verify { mockPaymentProcessor.processPaymentTransaction(100.0, "RSD") }
assertTrue(result, "Payment was successful.")
}
}
We can see how easy it is for the MockK framework to create a mock PaymentProcessor. We configure/drive the mock to return true whenever its processPaymentTransaction method is called. We then inject the mock within a new SomeInterestingShop instance and call its checkoutCart method. In the final step, we verify that the processPaymentTransaction method was indeed called with the expected arguments.
Interfaces combined with powerful mocking frameworks like MockK, can deliver more focused and maintainable unit tests for our projects.
Use Cases
So, we slowly approaching the end of our little journey with exploring interfaces and contracts, but we are not finished yet. After laying some ground-work in the previous sections, we can now look at some examples with different design patterns.
Adapter Pattern
The Adapter pattern is a structural design pattern that allows incompatible interfaces to work together. With creating an adapter interface, we can invoke a bridge between classes with incompatible interfaces. This makes it easier to scale our application or integrate with new third-party solutions.
An example would be to integrate our project with different analytics services, such as Google Analytics and Flurry. The new AnalyticsAdapter interface and its implementations can be something like this:
interface AnalyticsAdapter {
fun trackEvent(eventName: String, properties: Map<String, Any>)
}
class GoogleAnalyticsAdapter : AnalyticsAdapter {
// Implementation details...
override fun trackEvent(eventName: String, properties: Map<String, Any>) {
TODO("Not yet implemented")
}
}
class FlurryAdapter : AnalyticsAdapter {
// Implementation details...
override fun trackEvent(eventName: String, properties: Map<String, Any>) {
TODO("Not yet implemented")
}
}
Now we can add different analytics services to our project much more effectively.
Strategy Pattern
The Strategy pattern is a behavioral design pattern that enables us to define a family of algorithms, encapsulate them, and make them interchangeable. With the strategy pattern interface we can easily switch between different algorithms at runtime without modifying the code that uses the algorithms.
Let’s explore a file compression utility that supports different compression algorithms, such as ZIP, RAR, and 7z. You could create a CompressionStrategy interface and specific implementations for each compression algorithm:
package patterns
import java.io.File
interface CompressionStrategy {
fun compress(inputFiles: List<File>, outputFile: File)
}
class ZipCompressionStrategy : CompressionStrategy {
override fun compress(inputFiles: List<File>, outputFile: File) {
TODO("Not yet implemented")
}
}
class RarCompressionStrategy : CompressionStrategy {
override fun compress(inputFiles: List<File>, outputFile: File) {
TODO("Not yet implemented")
}
}
class SevenZipCompressionStrategy : CompressionStrategy {
override fun compress(inputFiles: List<File>, outputFile: File) {
TODO("Not yet implemented")
}
}
Repository Pattern
A widely used design pattern is the repository pattern. With the repository pattern we can abstract abstract any data access logic for a project. With the proposed repository interface we can seemingly switch between different data sources (REST APIs, databases, or in-memory caches) without modifying the code that depends on the repository.
We can play around and create a dummy task management solution that would manage our tasks and support different storage systems, such as local databases, cloud databases, or in-memory storage.
Let’s create a TaskRepository interface and the various implementations for different storage systems:
package patterns
data class Task(val id: String, val title: String, val description: String, val completed: Boolean)
interface TaskRepository {
fun getTask(id: String): Task?
fun addTask(task: Task): Task
fun updateTask(task: Task): Task
fun deleteTask(id: String): Boolean
fun getAllTasks(): List<Task>
}
lass InMemoryTaskRepository : TaskRepository {
// Implementation details...
override fun getTask(id: String): Task? {
TODO("Not yet implemented")
}
override fun addTask(task: Task): Task {
TODO("Not yet implemented")
}
override fun updateTask(task: Task): Task {
TODO("Not yet implemented")
}
override fun deleteTask(id: String): Boolean {
TODO("Not yet implemented")
}
override fun getAllTasks(): List<Task> {
TODO("Not yet implemented")
}
}
class LocalDatabaseTaskRepository : TaskRepository {
// Implementation details...
override fun getTask(id: String): Task? {
TODO("Not yet implemented")
}
override fun addTask(task: Task): Task {
TODO("Not yet implemented")
}
override fun updateTask(task: Task): Task {
TODO("Not yet implemented")
}
override fun deleteTask(id: String): Boolean {
TODO("Not yet implemented")
}
override fun getAllTasks(): List<Task> {
TODO("Not yet implemented")
}
}
class CloudDatabaseTaskRepository : TaskRepository {
// Implementation details...
override fun getTask(id: String): Task? {
TODO("Not yet implemented")
}
override fun addTask(task: Task): Task {
TODO("Not yet implemented")
}
override fun updateTask(task: Task): Task {
TODO("Not yet implemented")
}
override fun deleteTask(id: String): Boolean {
TODO("Not yet implemented")
}
override fun getAllTasks(): List<Task> {
TODO("Not yet implemented")
}
}
When we utilize our TaskRepository interface other parts of the task management solution remain agnostic to storage system implementation detail that’s going to being used. As with the other examples in this sections, this method ensures more scalability, maintainability, and the ability to switch between different data sources without modifying the code that depends on the repository.
These just some use cases that showcase the power of programming to an interface. By going this route in our software solutions, we can that much make our lives as software developers a tad easier to scale and adapt to frequent change requirements.
Final Note
In this article, I tried to introduce to you dear Reader the concept of programming to an interface. With this technique, we all as developers can create software solutions that gain all the befits already mentioned throughout the article.
With adopting programming to an interface in our projects, we cultivate a new design mindset that prioritizes extensibility and modularity. This approach sets the stage for building solutions capable of adapting to new challenges and opportunities, while minimizing the potential for technical debt and codebase stagnation. As we continue working on software projects, we should remember the importance of programming to an interface and apply this technique to design software that is both resilient and flexible, making it easier to scale, modify, and maintain throughout its lifecycle.
I hope that you found this article informative and insightful. Your feedback is important in improving this and any future content. I aim to provide you with the best resources possible. Feel free to share your thoughts, suggestions, or any questions you may have. As mentioned earlier, all the source code examples are available here.
Thank you for reading, and I look forward in hearing your thoughts about the subject matter. Happy coding!