All the same you will not manage! - Use of interfaces and dependency injection for durable design

Original author: dm03514
  • Transfer
Hello!

We finally have a contract to update the book by Mark Siman " Dependency Injection in .NET " - the main thing is that he finish it as soon as possible. In addition, we have in the editorial book of the respected Dinesh Rajput on design patterns in Spring 5, where one of the chapters is also devoted to dependency injection.

We have long been looking for interesting material that will remind you of the strengths of the DI paradigm and clarify our interest in it - and now it has been found. True, the author chose to give examples in the Go language. We hope this does not hurt you to follow the course of his thoughts and help to understand the general principles of inversion of control and work with interfaces, if this topic is close to you.

The emotional color of the original is slightly calmed down, the number of exclamation marks in translation is reduced. Enjoy reading!

The use of interfaces is a clear technique that allows you to create easy-to-test and easily extensible code. I have repeatedly been convinced that this is the most powerful design tool of all existing architectures.

The purpose of this article is to explain what interfaces are, how they are used and how they provide extensibility and testability of the code. Finally, the article should show how interfaces can help optimize software delivery management and simplify planning!

Interfaces The

interface describes a contract. Depending on the language or framework, the use of interfaces may be dictated explicitly or implicitly. So, in the Go language, interfaces are dictated explicitly.. If you try to use an entity as an interface, but it does not fully comply with the rules of this interface, then a compile-time error will occur. For example, following the above example, we get the following error:

prog.go:22:85: cannot use BadPricer literal (type BadPricer) astype StockPricer in argument to isPricerHigherThan100:
	BadPricer does not implement StockPricer (missing CurrentPrice method)
Program exited.

Interfaces are a tool to help detach the caller from the callee; this is done using a contract.

Let's elaborate this problem on the example of the program for automatic exchange trading. The trader program will be called with the set purchase price and ticker symbol. Then the program will go to the exchange to find out the current quotation of this ticker. Further, if the purchase price for this ticker does not exceed the specified one, the program will make a purchase.



In a simplified way, the architecture of this program can be represented like this. From the above example, it is clear that the operation of obtaining the actual price depends on the HTTP protocol, through which the program contacts the exchange service.

conditionActionalso directly dependent on http. Thus, both states must fully understand how to use HTTP to retrieve stock data and / or conduct transactions.

Here is what the implementation might look like:

funcanalyze(ticker string, maxTradePrice float64)(bool, err) {
  resp, err := http.Get(
      "http://stock-service.com/currentprice/" + ticker
  )
  if err != nil {
  	// обработать ошибку
  }
  defer resp.Body.Close()
  body, err := ioutil.ReadAll(resp.Body)
  // ...
  currentPrice := parsePriceFromBody(body)
  var hasTraded boolvar err error
  if currentPrice <= maximumTradePrice {
    err = doTrade(ticker, currentPrice)
    if err == nil {
      hasTraded = true
    }
  }
  return hasTraded, err
}

Here the caller ( analyze) has a direct hard dependency on HTTP. She needs to know how HTTP requests are formulated. How to parse them. How to handle retries, timeouts, authentication, etc. She has a close grip with http. Every time we call analyze, we must also call the library http .

How can the interface help us here? In the contract provided by the interface, you can describe the behavior , and not the specific implementation .

type StockExchange interface {
  CurrentPrice(ticker string) float64
}

The above concept is defined StockExchange. It says here that it StockExchangesupports calling a single function CurrentPrice. These three lines seem to me the most powerful architectural technique of all. They help us to control application dependencies much more confidently. Provide testing. Provide extensibility.

Dependency Injection

To fully understand the value of interfaces, you need to master a technique called “dependency injection”.

Dependency injectionmeans that the caller provides something the caller needs. This usually looks like this: the caller configures the object and then passes it to the callee. Then the called party abstracts from the configuration and implementation. In this case, there is a known mediation. Consider a request to the HTTP Rest service. To implement the client, we need to use an HTTP library that can formulate, send, and receive HTTP requests.

If we placed the HTTP request behind the interface, the caller could be detached, and it would be “not aware” that the HTTP request did indeed take place.

The caller should only make a generic function call. This may be a local call, remote call, HTTP call, RPC call, etc. The caller is not aware of what is happening, and usually this suits her perfectly, as long as she gets the expected results. The following shows how dependency injection in our method may look like analyze.

funcanalyze(se StockExchange, ticker string, maxTradePrice float64)(bool, error) {
  currentPrice := se.CurrentPrice(ticker)
  var hasTraded boolvar err error
  if currentPrice <= maximumTradePrice {
    err = doTrade(ticker, currentPrice)
    if err == nil {
      hasTraded = true
    }
  }
  return hasTraded, err
}

I never cease to be surprised at what is happening here. We completely turned on our dependency tree and became more in control of the whole program. Moreover, even visually the whole implementation has become cleaner and clearer. We clearly see that the analysis method should choose the current price, check whether this price is right for us, and if so, make a deal.

Most importantly, in this case, we detach the caller from the callee. Since the caller and the entire implementation are separate from the callee using the interface, you can extend the interface by creating many different implementations of it. Interfaces allow you to create many different specific implementations, without the need to change the code of the called party!



The state of "get current price" in this program depends only on the interface StockExchange. This implementationnothing is known about how to contact the exchange service, how prices are stored or how requests are made. Real blissful ignorance. And bilateral. The implementations are HTTPStockExchangealso not aware of the analysis. About the context in which the analysis will be performed when it is executed - since calls occur indirectly.

Since program fragments (those that depend on interfaces) do not need to be changed when changing / adding / deleting specific implementations, such a design turns out to be durable . Suppose we find that StockServicevery often inaccessible.

How does the above example differ from a function call? When applying a function call, the implementation will also become cleaner. The difference is that when calling a function, we still have to resort to HTTP. Methodanalyzeit will simply delegate the task of the function that is supposed to call http, and will not call httpitself directly. The whole power of this technique lies in the "injection", that is, that the caller provides the interface of the callee. This is exactly how dependence inversion happens, where get prices depend only on the interface, and not on the implementation.

Multiple out-of-box implementations

At this stage, we have a function analyzeand an interface StockExchange, but we actually cannot do anything useful. Just announced our program. At the moment, it is impossible to call it, because we still do not have any specific implementation that would meet the requirements of our interface.

The main emphasis in the following diagram is on the state of “get current price” and its dependence on the interface StockExchange. The following shows how two completely different implementations coexist, and get current price “not in the know” of this. In addition, both implementations are not related to each other, each of them depends only on the interface StockExchange.



Production

The original HTTP implementation already exists in the primary implementation analyze; we can only extract it and encapsulate a specific implementation of the interface.

type HTTPStockExchange struct {}
func(se HTTPStockExchange)CurrentPrice(ticker string)float64 {
  resp, err := http.Get(
      "http://stock-service.com/currentprice/" + ticker
  )
  if err != nil {
  	// обработать ошибку
  }
  defer resp.Body.Close()
  body, err := ioutil.ReadAll(resp.Body)
  // ...return parsePriceFromBody(body)
}

The code that we previously tied to the analyze function is now autonomous and satisfies the interface StockExchange, that is, we can now pass it analyze. As you remember from the above schemes, analyze is no longer dependent on HTTP. When using the interface analyze"does not represent" what happens behind the scenes. He only knows that he is guaranteed to be given an object with which he can call CurrentPrice.

Also here we use the typical advantages of encapsulation. Before, when http-requests were tied to analyze, the only way to communicate with the exchange via http was indirect — through the methodanalyze. Yes, we could encapsulate these calls in a function and perform the function independently, however, the interfaces force us to detach the caller from the callee. Now we can test HTTPStockExchangeregardless of the caller. This drastically affects the field of application of our tests and how we understand the failures of tests and react to them.

Testing

In the existing code, we have a structure HTTPStockServicethat allows us to separately ensure that it can communicate with the exchange service and parse the responses received from it. But now let's make sure that analyze can correctly process the response from the interface StockExchange, and that this operation is reliable and reproducible.

currentPrice := se.CurrentPrice(ticker)
 if currentPrice <= maxTradePrice {
    err := doTrade(ticker, currentPrice)
  }

We WOULD LIKE to use the HTTP implementation, but it would have a lot of flaws. Making network calls with unit testing could be slow, especially with external services. Due to delays and unstable network connections, tests could be unreliable. In addition, if we needed tests with the statement that we can complete the transaction, and tests with the statement that we can filter out such cases in which the transaction should NOT be closed, it would be difficult to find real production data that reliably satisfy these two conditions. One could choose maxTradePriceby artificially simulating in this way each of the conditions, for example, when a maxTradePrice := -100transaction should not be made, but maxTradePrice := 10000000obviously should end with a transaction.

But what happens if we are allocated a certain quota on the exchange service? Or if we have to pay for access? Will we really (and should) pay or spend our quota when it comes to just unit tests? Ideally, tests should be run as often as possible, so they should be fast, cheap and reliable. I think from this paragraph it is clear why it is irrational to use the version with pure HTTP from the point of view of testing!

There is a more optimal way, and it is associated with the use of interfaces!

Having an interface, you can carefully make an implementation StockExchangethat will allow us to perform analyzequickly, safely and reliably.

type StubExchange struct {
   Price float64
}
func(se StubExchange)CurrentPrice(ticker string)float64 {
   return se.Price
}
funcTestAnalyze_MakeTrade(t *testing.T) {
  se := StubExchange{Price: 10}
  maxTradePrice := 11
  traded, err := analyze(se, "TSLA", maxTradePrice)
  if err != nil {
     t.Errorf("expected err == nil received: %s", err)
  }
  if !traded {
    t.Error("expected traded == true")
  } 
}
funcTestAnalyze_DontTrade(t *testing.T) {
  se := StubExchange{Price: 10}
  maxTradePrice := 9
  traded, err := analyze(se, "TSLA", maxTradePrice)
  // утверждение
}

Above, the exchange service stub is used, thanks to which the branch we are interested in runs analyze. Then, statements are made in each of the tests to make sure that analyze does the right thing. Although this is a test program, my experience suggests that components / architecture, where interfaces are used in approximately the same way, are tested for durability and in the combat code in exactly this way !!! Thanks to the interfaces, we can use memory-controlled StockExchange, which provides reliable, easily configurable, easy-to-understand, reproducible, lightning-fast tests !!!

Detachment — Caller Configuration

Now, having discussed how to use interfaces to detach the caller from the callee, and how to do multiple implementations, we still haven't touched on a critical aspect. How to configure and provide a specific implementation at a specific time? You can directly call the analyze function, but what to do in production configuration?

This is where dependency injection comes in handy.

funcmain() {
   var ticker = flag.String("ticker", "", "stock ticker symbol to trade for")
   var maxTradePrice = flag.Float64("maxtradeprice", "", "max price to pay for a share of the ticker symbol."
   se := HTTPStockExchange{}
  analyze(se, *ticker, *maxTradePrice)
}

Just like in our test case, the specific concrete implementation of StockExchange that will be used with analyzeis configured by the caller outside the analyze. It is then transmitted (embedded) in analyze. This ensures that analyze ANYTHING is not aware of how it is configured HTTPStockExchange. Perhaps, we would like to provide the http-domain that we are going to use in the form of a command line flag, and then the analysis will not have to change. Or, what to do if we would need to provide this or that authentication or token to access HTTPStockExchangewhich will be retrieved from the environment? Again, analyze should not change.

Configuration occurs at a level outsideanalyze, thereby completely freeing the analyze from the need to configure its own dependencies. This achieves a strict separation of duties.



Postponing decisions

Perhaps the above examples are quite enough, but after all, interfaces and dependency injection still have many other advantages. Interfaces allow you to postpone decisions on specific implementations. Although decisions require us to decide what behavior we will support, they still allow us to make decisions about specific implementations later. Suppose we knew that we wanted to make automated transactions, but we were not yet sure which quotes provider we would use. With a similar class of solutions constantly have to deal with when working with data warehouses. What should our program use: mysql, postgres, redis, file system, cassandra? Ultimately, all of this is implementation details, and the interfaces allow us to postpone final decisions on these issues.

While this technique in itself leaves a lot of possibilities, at the project planning level something magical happens. Imagine what happens if we add another dependency to the exchange interface.



Here we reconfigure our architecture in the form of a directed acyclic graph, so that as soon as we agree on the details of the exchange interface, we will be able to COMPETITIVELY continue working with the pipeline, usingHTTPStockExchange. We created a situation in which the addition of a new person to a project helps us move faster. Having corrected our architecture in this way, we can better see where, when, and for how long we can use additional people on the project to speed up the delivery of the entire project. In addition, since the communication between our interfaces is weak, it is usually easy to get involved in work, starting with the implementation interfaces. You can develop, test and test HTTPStockExchangecompletely independently of our program!

Analysis of architectural dependencies and planning according to these dependencies can radically speed up projects. Using this particular technique I was able to very quickly complete projects that were allotted for several months.

Reserve for the future

Now it should be clearer how the interfaces and dependency injection ensure the durability of the designed program. Suppose we change our supplier of quotes, or start streaming quotas and saving them in real time; there are plenty of other possibilities. The analyze method in its current form will support any implementation suitable for combining with an interface StockExchange.

se.CurrentPrice(ticker)

Thus, in many cases, you can do without changes. Not in all, but in those predictable cases that we may encounter. We are not only insured against the need to change the code analyzeand recheck its key functionality, but we can easily offer new implementations or switch between suppliers. We can also smoothly expand or update the specific implementations that we already have without having to change or recheck analyze!

I hope the examples above convincingly demonstrate how weakening the connection between entities in a program through the use of interfaces completely reorients dependencies and separates the caller from the callee. Due to such a detachment, the program does not depend on the specific implementation, but it strongly depends on the specificbehavior . This behavior can be provided by a wide variety of implementations. This most important design principle is also called duck typing .

The concept of interfaces and dependence on behavior, and not on the implementation is so powerful that I regard interfaces as a language primitive - yes, this is quite radical. I hope that the examples discussed above turned out to be quite convincing, and you will agree that interfaces and dependency injection should be used from the very beginning of the project. In almost all the projects I have worked on, not one, but at least two implementations were required: for production and for testing.

Also popular now: