The Benefits of Thoughtful Code Organization on Testing

Exploring how good code design and organization can bring practical benefits, including testing.

Bobby Priambodo
Traveloka Engineering Blog
9 min readJul 21, 2020

--

A cup of coffee.
A cup of coffee, because, Java. [source]

Editor’s Note: One of the paramount challenges of maintaining a relatively large codebase as it continues to grow is architecturing a well-planned code organization with foresight and self-certainty early on. Bobby Priambodo witnessed such a streamlined codebase landscape when he joined Traveloka and today, he would like to share in detail his views having had experiences in interfacing with an instance of the Java- or Kotlin-based backend services in creating numerous benefits particularly for testing.

Bobby Priambodo is a lead software engineer overseeing the engineering efforts for both the Accommodation product’s Search team and the Engineering Quality team. He’s passionate about distributed systems and software engineering best practices. In his free time, he enjoys playing piano and with his two puppies at home.

Since its founding in 2012, Traveloka, as a tech company, has had their fair share of refactors and code reorganizations. Having rapid growth as a goal, it should be possible that there were hacks, which cut corners during the early days. I wasn’t around back then to know, but it is a common practice to hear from early stage startups.

However, when I joined in 2017, I was amazed by how neatly the backend Java code was already organized and modularized. In this post, I want to tell you what I have found interesting with the code organization that has enabled engineers to easily write unit tests.

A disclaimer: All views expressed on this post are my own and do not represent the opinions of Traveloka with which I have been or am now affiliated.

High-level Overview: Core Elements

In this post, we will be using two meanings of the term service. A “service” (written in a lowercase) is a service in the context of microservices, which is an app running in a server that provides specific sets of functionalities, typically exposed via a JSONRPC endpoint. A “Service” (written in a Sentence case) is a code element that implements a specific functionality. In the case of small microservice, both term variations may point to the same thing, but the common case is that a service contains multiple Services.

In essence, there are five core elements in the codebase of a typical backend Java or Kotlin service written at Traveloka.

  1. Service. This is the fundamental building block of a service. Services are stateless classes implementing a well-defined interface that provides unit functionalities for a business logic. A Service may depend on other Services, which are passed via dependency injection through constructors.
  2. Component. A Component’s role is to provide one or more Services. This is the level of abstraction applied to the location of a Service: we have embedded Local Components and Client Components that respectively initializes Services locally and gives the RPC implementation (think SDKs) of Services, with a parity interface to the local one.
  3. Service Component. This is the top-level class that defines a service. It declares the port the app uses, loads external configurations, as well as initializes the Components, Local or Client, required by the service to run.
  4. Accessor. This is the abstraction we use for accessing external dependencies such as databases or third-party APIs. They typically provide dumb facades or conversion logics to the underlying database drivers or HTTP calls.
  5. Data Model. This is only a simple Java/Kotlin class such as “bean” that has private properties and getter-setter methods. Data Models, such as User, Hotel, RequestSpec, Booking, and other behavior-less data, are used to pass around data throughout the code flow.

The following diagram gives a hierarchy view of those four core elements. We don’t include the Data Model as the fifth core element because they’re ubiquitous and can be used anywhere, most often in Services. As you can see below, this particular Service Component initializes three Components, two Local and one Client.

Figure 1. Elements hierarchy

Let’s now see the rules that are applied to this code organization and how it helps engineers write unit tests.

Element Dependencies

There are two basic rules regarding element dependencies:

  1. Element dependencies are only allowed to be passed via dependency injection and not via global state or self-initialization with the new keyword for example.
  2. Element dependencies via dependency injection are only allowed for elements on the same level and are only allowed to be depended using their interfaces as explained in Programming to an Interface.

For example in Figure 1 above, Component A can only depend on Component B and Component C interfaces, not on their implementation of Local or Client Components, and not on the Services directly. Similarly, in Figure 2 below, Service A Implementation is allowed to depend on Accessor B, Service B, and Service C interfaces, not on their implementations or Components. This assumes that Accessor B, Service B, and Service C are exposed by their respective Component interfaces; it’s very possible for a Component to initialize several Services only to expose one Service. It is also becoming increasingly rare that a Component exposes Accessors directly; engineers usually just encapsulate Accessors with a wrapper Service for better abstraction.

Figure 2. Depends on interfaces and not on implementations.

These rules bring the practical benefit of easily swapping any implementation of a service or an accessor, such as from local to remote or from Mongo to Postgres, without having to change the implementation of the depending client class. For example in Figure 2 above, to swap Service B Implementation, which may have a slow performance, for a faster implementation that makes use of the latest shiny tech, the code in Service A Implementation won’t need to even be touched as long as the interface Service B stays the same.

Violating these rules means that we won’t be able to achieve the freedom that we want. If we use global states such as globally available singletons, we may be able to swap implementations easily during creation at the expense, however, of swapping it for all of the clients that use it, which may not be typically ideal given the preferred flexibility on selectively matching dependencies and services. Using the new keyword inside the constructor creates a tight coupling between the service and the dependency implementation. Similarly, using dependency injection without using interfaces such as using concrete classes, also couples them tightly and we wouldn’t be able to easily swap them for alternative implementations.

How Does This Help Testing?

Our experience using both programming to an interface and dependency injection techniques attested the benefits in supporting testing.

Since the code that contains functionality logic resides in a Service, we can write tests for Service Implementation against the contract of their interface (not to be confused with Testing Implementation Details, which is highly discouraged). Since Service Implementation depends on Service interfaces, we can easily write stub implementations for each interface to produce the behavior that we want. My team uses the great Mockito library to provide us with such functionality.

With Mockito, we can even properly differentiate between testing state and testing interactions. We can assert that for a given input and its dependency output, our Service can produce the correct output or correctly call a dependency N times resulting in higher confidence that our code works as intended.

Having the code-in-test set up in such a way definitely brought the joy of creating test cases. There were no longer as many hoops to jump and more tests resulted in more confidence of correct code.

Modules Organization

At Traveloka, we organize our code into modules, following the common Maven multi-module project structure and each module contains code in a typical Maven standard directory layout. In addition, we use Gradle as our build system.

Having talked about the logical view of things above, we’ll talk about how that logical view is implemented into a concrete structure below.

There are four module types that we use:

  1. API module (e.g. hotel-search-api). This is the module that contains public interfaces that other modules depend on. Here we have the Service, Accessor and Component interfaces, as well as Data Models. This module should not depend on any other module except on some common libraries interface. Note that not all interfaces and data models should be put here; if they’re only used in the implementation, put them in the implementation module for a cleaner API.
  2. Implementation module (e.g. hotel-search-impl). This module has a dependency on the API module. The idea of this module is to fulfill the contracts and interface provided by the API module by having the Service Implementation, the Local Component implementation, as well as other interfaces and classes necessary to get the implementation running. Therefore, since the bulk of the logic is in this module, this is the logical place to put our test classes.
  3. RPC module (e.g. hotel-search-rpc). This module also has a dependency on the API module and serves as an alternative implementation to the API module. In this module, we have the Client Component (SDK, if you will) that encapsulates remote calls to the actual service via network connection. Since Client Component implements the same interface, the calling clients are unaware of the fact that it’s making network calls; a very good abstraction to have.
  4. Service module (e.g. hotel-search-service). This module contains the Service Component of a service. It is the only standalone module with no inward dependency of the four as it ties a service into an executable artifact to be run on our servers.

The relationship between the modules are illustrated with the following diagram.

Figure 3. Modules relationship.

In Figure 3 above, you can see that we have two services, A and B, and B has a physical dependency such as via network to A as illustrated by the dashed arrow.

In the implementation, the service module for A depends on the implementation module and API module. The service module for B depends on the RPC module and the API module. As such, service A and service B work with the same interface, but with different implementations.

Why is this useful? With such separation of concerns, the specificity of modules’ dependencies is clear. Modules only depend on the code they exactly need. Suppose that the API and Implementation modules are lumped together into a single module, service B would not be able to get only the API code it needs, but also the unneeded implementation code.

Clear and specific dependencies translate to faster builds. When we build service B, we won’t need to bring in the whole implementation of A, but instead only the API and RPC (SDK). We can avoid transitive dependencies that often slow down our integration pipeline.

How Does This Help Testing?

Since testing code is strictly placed in the implementation module as stated above, it is consequently devoid of any dependency, resulting in faster build overall.

Faster build brings another benefit to the table: when the build is fast, running the tests is also fast. I cannot appreciate enough how many hours we have saved by ensuring our tests can run fast enough. If your unit tests run slow, it can easily become mundane, which may result in engineers becoming uninterested to write tests and resorting to testing in prod — uh, I mean, staging.

Make your engineers happier by making your tests run faster.

Summary

In this short post, we have talked about how Traveloka organizes the backend Java and Kotlin code both logically and concretely. We also have looked at the two rules alongside these concepts, why they were designed as such, and how they can help engineers with writing tests.

What’s interesting to note here is that such code organization was not specifically designed for testing. It brought other benefits and solved other practical problems as well, such as code decoupling that enables swapping implementations as well as module decoupling to get cleaner dependencies and enable faster builds. It’s just that it was such a good design that it also improves the ease of testing.

Hopefully this post could inspire you to strive for good design because it definitely has brought us many advantages. If you have any feedback about how Traveloka organizes its codebase, please feel free to get in touch with me! I’m sure the team and I would appreciate constructive feedback to improve our development methodology further.

If you’re reading this far, congrats! You have the curiosity and eagerness to learn expected of Traveloka engineers.

I acquired all this knowledge as I’m dealing with real-life engineering problems with my team while working at Traveloka, one of the largest online travel companies in Southeast Asia. If you’re a software engineer interested in tackling real scalability and reliability issues in large distributed systems which allow users to create moments with their loved ones, have a look at the opportunities on Traveloka’s careers page!

Suggestions and corrections for this article are welcome.

--

--

Bobby Priambodo
Traveloka Engineering Blog

Software Engineer. Functional programming and distributed systems enthusiast. Java, JavaScript, Elixir, OCaml, Haskell.