How to modularize a system and avoid 'Siamese twins'

In software architecture, we encounter the term “Siamese twins.” It refers to two or more modules that are inextricably linked. This situation occurs when a change in one module automatically forces changes in the other. Imagine a warehouse where one person, let's call him John, has to process all types of messages: orders, returns, and discounts. If the sales department introduces a new promotion, John has to adjust his processes, even if the discount information is completely unnecessary to him. This is an example of “domain leakage” and the creation of a distributed monolith.

Architecture anti-patterns that lead to disaster

We often see architectures that complicate life instead of making it easier. One of the biggest mistakes is “State-Transfer Events.” In this approach, an event transfers the entire state of an object - for example, “order status changed to confirmed” - which leads to strong coupling and makes systems dependent on each other. Another problem is when the system architecture begins to dictate communication within the organization. This is a reversal of Conway's Law. It makes the system rigid and difficult to change.

Solution: contextual modularization

True modularity involves extracting independent Bounded Contexts. Each context should have its own model and its own language, tailored to a specific problem. For the sales department, a chair is a product with a price and a discount; for the warehouse, it is simply a box with dimensions. Do not try to create a universal “uber-model” that fits everything. Instead, consider what questions a given context needs to answer. In John's case, the question is, “Can I release the goods?” His module should only be interested in the ID and quantity, ignoring all other details.

Implementation and communication patterns

In practice, bounded contexts can be implemented as microservices, keeping in mind that a microservice is an implementation unit and a context is a logical module. To enable contexts to communicate, use proven patterns:

  • Open Host Service: A module (e.g., warehouse) provides an API in its own language (e.g., issue_goods), and other modules must adapt to it.
  • Published Language: We create an independent, standardized language for communication, e.g., instead of “user bought something,” we use “unit came into possession of goods.”

Here is an example in Ruby of what a simple implementation of this approach might look like.

# The "Sales" bounded context
module Sales
  class Order
    # Represents a customer's order
    attr_reader :items, :customer_info, :discount

    def initialize(items:, customer_info:, discount:)
      @items = items
      @customer_info = customer_info
      @discount = discount
    end

    def publish_event
      # This event contains only the necessary information for the "Warehouse" context.
      # It's an example of a "Published Language"
      # The warehouse doesn't need to know about discounts or customer info
      payload = {
        'items' => @items.map { |item| { 'item_id' => item[:id], 'quantity' => item[:quantity] } }
      }
      # A hypothetical method to send the event to a message broker
      send_event_to_broker('item_possession_event', payload)
    end
  end
end

# The "Warehouse" bounded context
module Warehouse
  class GoodsIssuer
    # This module only cares about the goods to be issued
    def receive_event(event_payload)
      # The payload only contains item IDs and quantities.
      # This prevents "domain leakage" and tight coupling.
      event_payload['items'].each do |item|
        puts "Checking stock for item #{item['item_id']} and quantity #{item['quantity']}..."
        # Logic for issuing goods...
      end
    end
  end
end

The code above shows how the Sales module can send an event to the Warehouse module without sharing unnecessary information. This makes the system more resilient to change.

Finally: avoid 'cargo cult'

The biggest pitfall is following popular trends, such as microservices or Kafka, without understanding why we are actually using them. Don't do what others are doing just because it's trendy. Always think about the business context and the problems you are trying to solve. Remember, the right architecture serves the business, not the other way around.

Happy modularizing!