Keep Options Open

Keeping options open is one of the most useful principles of Clean Architecture. Let’s dive deeper with code examples in Java and Spring framework.


I bet you’ve read Robert Martin’s Clean Architecture. Sure you have. And if you haven’t, you definitely should. It’s a gentle book about basic architecture principles and best practices, every developer must know by heart.

Some chapters might be a bit outdated and beautifully impractical, but the book does give you a nice overview of how to build a good software architecture, in its purest ideal.

Software architecture

Software architecture is about trade-offs. It's up to you to find a balance between all pros and cons that meets your current situation and the context. Needless to say, the tension will change as the product changes and grows over time. A good architecture minimizes the effort to make the changes happen. A good architecture can evolve.

In this post, I will leave all the SOLID stuff, stability metrics, and dependency rules aside. Rather, I focus on one aspect only: keep options open.

Keeping options open is an important architectural rule that deserves our utmost attention. We will discuss a few good practices and put them into context with some code examples.

Defer hard decisions

It seems that a software architect must foresee the evolution of the software product to sketch a good architecture up-front. But is it even possible? Can engineers see the future?

The world of business is a wild place that changes rapidly in unexpected ways. The requirements on architecture change as the business changes. That’s why every attempt to design the whole system from the beginning fails (remember waterfall).

Of course, some up-front design must be done, but it is not much more than intelligent guessing.

The Agile movement teaches us to react to frequent feedback and do small incremental steps that are easy to revert. Software architecture is an ongoing activity that must be continuously checked whether it works as expected.

Making decisions immediately would be counterproductive, as the necessary information is not available yet. Thus, it is wise to defer the decisions until the last possible moment.

Of course, it is not possible to defer all decisions. The architecture would become very expensive. It’s the job of the software architect to find a sweet spot in this tense. Details that have nothing to do with the business could and should be deferred because a good software architecture doesn’t depend on them.

Once again, software architecture is about trade-offs. The You aren’t gonna need it principle is to keep in mind.

Note: I’m not talking about the “software architect” as about a job title. A software architect is a role that should be distributed in the team, based on the current setup. Every developer should be a software architect at some level.

Leaving the options open will give us time to retrieve all the necessary information to make the best possible decision when it’s really needed. This approach will result in a flexible and clean architecture, open to extension, and easy to maintain.

One useful technique, mentioned in the Clean Architecture book, is to do all the work and then to skip the last step.

Skip the last step

Making architecture clean requires explicit defining of the boundaries between modules. Boundaries are the architectural decision that is hard to make because the boundaries often change as you gain more insight into the product.

Fixing boundaries right in the beginning will make it expensive to change later. Therefore, it is a good idea to sketch lines without fixing them until it’s necessary.

That’s why I don’t recommend the microservices-first approach. Microservices make boundaries fixed, as the code is usually completely physically separated (in different repositories). Such a separation is hard to refactor later, which often leads to keeping wrong boundaries on the place. And, voilà, a distributed monolith was born!

Clean Architecture advises to do all work of separation of boundaries and then leave them live together in a single modular monolithic application, in a single-repository codebase.

This approach gives us good boundaries for a truly clean architecture, but it is still easy to refactor (with a little help of your IDE).

Now, we have a single-process program with clean boundaries and modular codebase, but no administration costs that are typical for distributed systems. The best of both worlds!

When the physical separation is needed, there is just the last step to be done.

Example

As an example, consider an eCommerce system with several services: Sales, Billing, Shipping, Warehouse, etc.

In the beginning, the business is small (hundreds of users a day). There is no need to set up any complicated infrastructure. The priority is to bring the product to its customers.

A monolith is your best bet for the start.

As the business grows and the number of users increases to hundreds of thousands, we can think of separating the most critical services into individual processes.

Microservices or a hybrid approach will solve your scalability issues.

Boundaries

In Java, packages are the most simple mechanism we can implement our boundaries with.

org.ecommerce
  sales
  billing
  shipping
  warehouse
  …

A step further would be to build services as separate artifacts (Maven modules or Gradle sub-projects). Doing so, we make our dependencies explicit. This prevents developers from accidentally crossing boundaries. That is, to cross a boundary, an explicit dependency to that service artifact must be included in the module. This will make a developer think if she does the right thing or not.

ecommerce/
  sales/
    src/main/java/
      org.ecommerce.sales
      …
  billing/
    src/main/java/
      org.ecommerce.billing
      …
  shipping/
    src/main/java/
      org.ecommerce.shipping
      …
  warehouse/
    src/main/java/
      org.ecommerce.warehouse
      …
  …

Now, how difficult could it be to move one module to a separate repository? Not at all!

Linking together

A service should be autonomous. We can use Spring Boot to implement every service as a self-contained custom component.

The application (Main component) will assemble all services simply by referencing them as dependencies:

dependencies {
  implementation project(':sales-spring-boot-starter')
  implementation project(':billing-spring-boot-starter')
  implementation project(':shipping-spring-boot-starter')
  implementation project(':warehouse-spring-boot-starter')
  …
}

When adopting microservices, an application module must be created per service. No change is required in the services code. The Open-Closed Principle in action.

When things change later, the system can be deployed as a monolith again.

Communication

Components in a monolithic application typically use inter-process communication. It is quick, cheap, and as robust as the application itself. The simplest form is just an ordinary method call.

This approach has several disadvantages: It’s blocking and couples the caller directly to the callie.

The idea of messaging addresses this issue.

But, do we need to spin up a message broker to do messaging in a single-process application? No, even sending messages via a network is usually unnecessary.

Spring framework offers a neat feature: Application Events. Messaging in Spring is a capability provided by the Application Context and doesn’t need any networking or other special setup. We can easily use it for communication across service boundaries.

Now, the question is, how to make this solution open for communication between services? Well, let’s introduce an interface:

public interface Publisher {

  void publish(Object msg);
}

For our monolithic application we simply provide an implementation based on Spring Application Events:

@Configuration
class MessagingConfig {

  @Bean
  Publisher publisher(
      ApplicationEventPublisher pub) {
    return pub::publishEvent;
  }
}

For microservices, we create a different configuration based on an external message broker (such as RabbitMQ):

@Configuration
class RabbitMqConfig {

  @Primary
  @Bean
  Publisher rabbitPublisher(
      RabbitTemplate rabbit) {
    return m -> rabbit.convertAndSend(
        TOPIC, ROUTING, m);
  }
  …
}

Using the @Primary annotation we overwrite the default configuration. It’s a good practice to use Spring profiles to control the setup of the components:

@Profile("rabbitmq")
@Configuration
class RabbitMqConfig {
  …
}

With a setup like that, we can run the monolithic application with the default Spring profile:

gradle :application:bootRun

Or run every service independently so they communicate remotely over a RabbitMQ message broker:

gradle :sales:application:bootRun \
  --args='--spring.profiles.active=rabbitmq'

The source code could be found on my GitHub.

Conclusion

Don’t decide about details that don’t matter right now. Delaying such decisions will give you the necessary information needed to make good decisions.

As Robert Martin states:

A good architect maximizes the number of decisions not made.

Like any other architectural rule, keeping options open mustn't be taken dogmatically. Time, budget, team size, and skills should be taken into account. Otherwise, the architecture could easily become too expensive.

Keep your options open
Keep your options open