Designing Modular Systems

From NovaOrdis Knowledge Base
Jump to navigation Jump to search

External

Internal

Overview

The larger and tightly coupled a system is, the harder it is to change, and the easier it is to break. The alternative is to compose a system from small, simpler pieces. Each piece has a clearly defined interface and it is easy to understand and test. The component can be tested and deployed in isolation. Microservices fall into this category.

The systems should be designed for modularity. The goal of modularity is to make it easier and safer to make changes to the system. The hope is that, for a well-designed modular system, you can make a change to a component without needing to change other parts of the system. A well-designed modular system performs well on these operational performance metrics.

Designing components is the art of deciding which elements of your system to group together and which to separate. Two important characteristics of a component are coupling and cohesion. The goal of good design is to create low coupling and high cohesion.

Coupling

Coupling describes how often a change to one component requires a change to another component. Zero coupling isn't a realistic goal for two parts of a system, zero coupling means they aren't part of the same system at all. Instead we aim for low, or loose coupling. In software system engineering, especially in systems based on microservices, we aim for loose coupling. In a microservices context, we want to avoid coupling the microservice and its consumers such that a small change to the microservice itself can cause unnecessary changes to the consumer. It should be easy to replace one side of the dependency relationship without disturbing the other.

Shared modules and libraries introduce coupling between their dependents: changing the shared library (module) to accommodate a requirement of one dependent may affect the other dependents using the same library (module).

DRY and Coupling

Duplication Considered Useful

Cohesion

Cohesion describes the relationship between the elements within a component. As with coupling, the concept of cohesion relates to patterns of change. Components with high cohesion are easier to change because they are smaller, simpler and cleaner with a lower blast radius.

Cohesion is the quality of code base to have related code - code that describes a certain logical behavior - grouped together. The concept of cohesion is reinforced by the Single Responsibility Principle espoused by Robert C. Martin. Any given component should have responsibility for one thing, so it is focused, so its contents are cohesive. The component "should fit in your head".

In software system engineering, especially in systems based on microservices, we aim for high cohesion. High cohesion allows us, if we want to change behavior, to change it in only one place, and then deliver the change via a simpler release process than required if the behavior were to be changed in a lot of places. Making changes in many places is slower, and the consequent release process is riskier.

High cohesion software systems are designed by finding the right boundaries of our problem domain. Right boundaries ensure that related behavior resides in one place, and the communication with other boundaries is as loose as possible. All decisions about what changes are allowed on the state maintained within the bounded context must be taken inside the bounded context. If the decision about what changes are allowed leak out, we are losing cohesion. Such a component can then be reused within different contexts, exposing the underlying abstraction.

Modularization Benefits

Reuse

The knowledge that goes into implementing a particular construct, or concept, goes into a component, so the component can be reused as a unit, without exposing and duplicating low level code that goes into the implementation. Reusability encourages sharing.

Composition

Components that represent the same concept, and have the same interface, but different implementations, can be swapped amongst themselves, offering flexibility in building systems.

Testability

Components can be tested separately and then can be integrated. When composing a system from components, integration between components can be tested by replacing some of the components with test doubles, reaching testing costs. Also see Use Testing to Drive Design Decisions below.

Sharing

Reusable, composable and well-tested components can be shared between projects and teams, so people can build better systems more quickly.

Modularization Downsides

Breaking a large system into smaller components does not make the overall system any smaller or simpler, it still has to implement the same concepts and provide the same functionality. Components have the potential to make things worse by obscuring the underlying complexity. That is why you should understand what lies beneath the abstractions exposed by the components you use. Wrong abstractions make a system difficult to understand, maintain and evolve. Components are a convenience that lets you focus on higher-level tasks. They should not be a substitute for fully understanding how your system is implemented.

There should be no need for creating a component and exposing it through an interface if that component is only used once. This kind of component is referred to as an "unshared component". Building a reusable component when you don't need to reuse it is an example of YAGNI. A bit of wisdom that might apply in these situations is the Rule of Three.

Kent Beck's Rules of Simple Design

Beck Design Rules by Martin Fowler

A well designed system should:

  • Pass its tests (do what is supposed to do).
  • Reveal its intentions (be clear and easy to understand).
  • Have no duplication. Don't Repeat Yourself.
  • Include the fewest elements.

Design Components around Domain Concepts, not Technical Ones

Shared components create coupling between their dependents, and components based on technical concepts introduce artificial coupling.

Define Interfaces between Components

This is a direct consequence of the Law of Demeter.

Circular Dependencies

Components should not define circular dependencies.

Use Testing to Drive Design Decisions

Test Driven Development (TDD) and automated testing drive better design. Loosely coupled, high cohesion component systems are naturally automatically testable. It's easier to build and use mocks for a component with cleaner, simpler interfaces. Also see Testability above.

Boundaries between Components

Find natural places to draw boundaries between parts of your system, where you can create clean integration points. Components such defined should be exposed externally via interfaces. There are several strategies that can be used:

Align Boundaries with Natural Change Patterns

For an existing system, insight into what things change together is given by examining historical changes: code commits, for example. By understanding what components tens to change together as part of a single commit, or closely related commits across components, you can find patterns that suggest how to refactor the code for more cohesion and less coupling. Good sources of information are artifacts around higher levels or work: tickets, stories and projects. However, you should optimize for small, frequent changes.

Align Boundaries with Component Life Cycles

Understand how components will be created, modified and destroyed. In case of infrastructure, organizing infrastructure resources in deployable components according to their life cycle can simplify their management and may help saving costs (components not used often can be shut down when not used). Moreover, optimizing infrastructure components for life cycle is useful for automated testing in pipelines.

Align Boundaries with Organizational Structures

Alignment of system architecture to organizational structure is is worth considering especially for infrastructure. Aligning component boundaries to the organizational structures is an acknowledgment of Conway's Law. Avoid designing components that multiple teams need to make changes on. Or, consider restructuring teams to reflect architectural boundaries according to the inverse Conway maneuver. The goal is to make the change less disruptive: rather than negotiating a single change window with all of the teams using a shared instance, separate windows can be negotiated for each team.

Create Boundaries that Support Resilience

Components need to be designed to make it possible to quickly rebuild and recover then, in case of failure. If component boundaries are designed based on the component lifecycle, take the recovery case into account.

Create Boundaries that Support Scaling

IaC Chapter 15. Core Practice: Small, Simple Pieces → Drawing Boundaries Between Components → Create Boundaries That Support Scaling

Align Boundaries to Security and Governance Concerns

IaC Chapter 15. Core Practice: Small, Simple Pieces → Drawing Boundaries Between Components → Align Boundaries to Security and Governance Concerns

Modular Infrastructure

Infrastructure as Code | Modular Infrastructure