Skip to main content

Inter-Module Communication: Decoupling Concepts and Benefits

Introduction​

In a Modular Monolith architecture, the primary challenge is maintaining module independence while enabling necessary inter-module communication. This document explores the concepts, concerns, and benefits of the decoupling strategy implemented in the Planet project.

What is Decoupling?​

Decoupling is the architectural practice of reducing dependencies between modules, allowing them to evolve independently without affecting each other. In the context of inter-module communication, it means:

  • Modules don't directly depend on each other's internal implementations
  • Changes in one module don't require changes in dependent modules
  • Modules can be tested, deployed, and maintained independently
  • The system remains flexible and adaptable to future requirements

The Problem: Tight Coupling​

Traditional Approach Issues​

In a typical modular application without proper decoupling, modules often communicate directly:

❌ PROBLEM: Module A directly depends on Module B's internals
class AccountingService
{
public function __construct(
private CartRepository $cartRepository, // Direct dependency
private PaymentRepository $paymentRepository, // Direct dependency
private UserRepository $userRepository // Direct dependency
) {}

public function createDocument(int $cartId): void
{
// Directly accessing other modules' repositories
$cart = $this->cartRepository->find($cartId);
$payment = $this->paymentRepository->findByCart($cartId);
$user = $this->userRepository->find($cart->user_id);

// Business logic...
}
}

Consequences of Tight Coupling​

Critical Issues

The following problems arise from tight coupling between modules:

  • Module A depends on Module B's repository
  • Module B depends on Module C's entities
  • Changes ripple across multiple modules
  • Circular dependencies become common
Additional Consequences

Violation of Architectural Boundaries

  • Modules know too much about each other
  • Internal implementation details leak across boundaries
  • Domain logic spreads across modules
  • Single Responsibility Principle violated

Maintenance Nightmare

  • Changes in one module break others
  • Developers need to understand multiple modules
  • Refactoring becomes risky and expensive
  • Technical debt accumulates rapidly

The Solution: Decoupling Strategy​

The Planet project implements a two-pronged decoupling strategy based on the Command Query Separation (CQS) principle:

1. Query Infrastructure (Read Operations)​

For reading data from other modules, we use the Dependency Inversion Principle:

Key Characteristics
  • Consumer depends on abstraction (interface), not implementation
  • Provider implements the interface within its own domain
  • Data transferred via immutable DTOs
  • No direct access to repositories or entities

2. Command Infrastructure (Write Operations)​

For changing state or triggering operations in other modules, we use Event-Driven Architecture:

Key Characteristics
  • Publisher doesn't know who listens to events
  • Subscribers react independently
  • Asynchronous by default (queue-based)
  • Returns minimal data (ID, boolean, or void)

Core Concerns and Motivations​

1. Maintainability​

Concern: As the system grows, changes become increasingly difficult and risky.

Motivation: Enable safe refactoring and evolution of individual modules without system-wide impact.

How Decoupling Helps:

  • Changes in Module B's internal structure don't affect Module A
  • Interface contracts provide stability guarantees
  • Refactoring can be done incrementally and safely
  • Regression risk is minimized

Example:

// Module B changes its database schema
// Before: Module A breaks because it directly used Module B's repository
// After: Module A continues working because it only depends on the interface

2. Testability​

Concern: Testing modules in isolation is difficult when they have direct dependencies.

Motivation: Enable fast, reliable unit tests without database or external dependencies.

How Decoupling Helps:

  • Interfaces can be easily mocked
  • Tests run in milliseconds instead of seconds
  • No need for test databases or fixtures
  • True unit testing becomes possible

Example:

Fast, Isolated Unit Test
class AccountingServiceTest extends TestCase
{
public function test_creates_document_successfully()
{
// Mock the interface, not the repository
$userQuery = Mockery::mock(UserQuery::class);
$userQuery->shouldReceive('getById')
->andReturn(new UserDTO(...));

$service = new AccountingService($userQuery);

// Fast, isolated test
$result = $service->createDocument(1);

$this->assertTrue($result->isSuccess());
}
}

3. Scalability​

Migration Path

Concern: The system needs to support future growth and architectural evolution.

Motivation: Enable migration to microservices or distributed systems when needed.

How Decoupling Helps:

  • Modules are already independent
  • Communication happens through well-defined contracts
  • Can extract modules to separate services
  • Event-driven architecture supports distributed systems

4. Team Autonomy​

Benefits for Development Teams
  • Teams own their module's implementation
  • Interface changes require explicit agreement
  • Teams can deploy independently
  • Reduces merge conflicts and coordination needs

5. Domain Integrity​

Concern: Business logic should remain within appropriate domain boundaries.

Motivation: Maintain clean domain models and prevent logic leakage.

How Decoupling Helps:

  • Each module encapsulates its domain logic
  • DTOs prevent entity leakage across boundaries
  • Value Objects enforce domain rules
  • Domain models remain pure and focused

Architectural Benefits​

1. Dependency Inversion (SOLID)​

Benefit: High-level modules don't depend on low-level modules; both depend on abstractions.

✅ GOOD: Both depend on abstraction
interface UserQuery { }

class AccountingService {
public function __construct(
private UserQuery $userQuery // Depends on abstraction
) {}
}

class UserQueryImplementation implements UserQuery { }
Impact
  • Modules can evolve independently
  • Implementations can be swapped
  • Testing becomes straightforward
  • Architectural boundaries are enforced

2. Single Responsibility (SOLID)​

Benefit: Each module has one reason to change.

Manages user data and authentication

3. Open/Closed Principle (SOLID)​

Benefit: Open for extension, closed for modification.

Adding New Functionality Without Modifying Existing Code
// Adding a new listener doesn't modify existing code
class NewFeatureListener
{
public function handle(OrderCompletedEvent $event): void
{
// New functionality without touching existing modules
}
}

4. Type Safety​

Benefit: Compile-time guarantees through strong typing.

Value Objects Ensure Type Safety
// Value Objects ensure type safety
public function getById(UserId $id): UserDTO;

// Cannot pass wrong type
$user = $userQuery->getById(new UserId(123)); // ✅
$user = $userQuery->getById(123); // ❌ Type error

5. Explicit Contracts​

Self-Documenting Code

Clear, documented interfaces define what's available:

  • Self-documenting code
  • IDE autocomplete support
  • Compile-time validation
  • Clear API boundaries

6. Immutability​

Benefit: DTOs are immutable, preventing accidental modifications.

Immutable DTOs
class UserDTO
{
public function __construct(
public readonly UserId $id, // Cannot be modified
public readonly string $name, // Cannot be modified
) {}
}
Impact
  • Thread-safe by design
  • Prevents bugs from unexpected mutations
  • Easier to reason about data flow
  • Supports functional programming patterns

7. Reusability​

Benefit: Query interfaces can be used by multiple modules.

Shared Query Interface Across Modules
// UserQuery used by multiple modules
class AccountingService {
public function __construct(private UserQuery $userQuery) {}
}

class NotificationService {
public function __construct(private UserQuery $userQuery) {}
}

class ReportingService {
public function __construct(private UserQuery $userQuery) {}
}

8. Observability​

Natural Audit Trail

Event-driven architecture provides natural audit trail:

  • All important business events are explicit
  • Easy to add logging and monitoring
  • Supports event sourcing if needed
  • Debugging becomes easier

Trade-offs and Considerations​

Strong Decoupling​

  • Modules are truly independent
  • Can be tested in isolation
  • Can be deployed separately
  • Can evolve independently

Type Safety​

  • Compile-time error detection
  • IDE support and autocomplete
  • Refactoring confidence
  • Self-documenting code

Testability​

  • Fast unit tests
  • Easy mocking
  • No database dependencies
  • High test coverage possible

Scalability​

  • Ready for microservices migration
  • Supports distributed systems
  • Horizontal scaling possible
  • Performance isolation

Maintainability​

  • Clear boundaries
  • Easier refactoring
  • Reduced regression risk
  • Better code organization

When Advantages Outweigh Disadvantages​

Use This Architecture When:
  • ✅ System has multiple modules with clear boundaries
  • ✅ Long-term maintainability is critical
  • ✅ Multiple teams work on different modules
  • ✅ System needs to scale or evolve to microservices
  • ✅ High test coverage is required
  • ✅ Domain integrity is important
May Be Overkill When:
  • ❌ Simple CRUD application
  • ❌ Single developer or small team
  • ❌ Short-lived project
  • ❌ Modules are tightly related by nature
  • ❌ Performance is critical and overhead unacceptable

When to Use This Architecture​

Use Query Infrastructure When:​

✅ Good Use Case: Reading Data from Another Module
class InvoiceService {
public function __construct(
private UserQuery $userQuery,
private OrderQuery $orderQuery
) {}
}
Ideal Scenarios
  1. Reading Data from Another Module
  2. Multiple Modules Need Same Data - Reusable across modules
  3. Testing Requires Isolation - Easy to mock

Use Command/Event Infrastructure When:​

✅ Good Use Case: Triggering Operations
// Triggering operations in other modules
event(new OrderCompletedEvent($order));
Ideal Scenarios
  1. Triggering Operations in Other Modules
  2. Multiple Modules React to Same Event - Multiple listeners (Invoice, Notification, Analytics)
  3. Asynchronous Processing Needed - Queue-based processing

Don't Use When:​

❌ Within Same Module - Use Direct Dependencies
class UserService {
public function __construct(
private UserRepository $repository // Direct dependency OK
) {}
}
Avoid When:
  • Within same module
  • Simple CRUD operations
  • Synchronous response with complex data required

Comparison with Alternative Approaches​

Approach:

class AccountingService {
public function __construct(
private UserRepository $userRepository
) {}
}

Pros:

  • Simple and straightforward
  • Less code to write
  • Faster initial development

Cons:

  • Tight coupling between modules
  • Hard to test in isolation
  • Violates architectural boundaries
  • Difficult to refactor

When to Use: Only within the same module


Conclusion​

The decoupling strategy implemented in the Planet project addresses the fundamental challenge of maintaining module independence while enabling necessary communication. By separating read operations (Queries) from write operations (Commands/Events), and by using interfaces and DTOs to define clear contracts, the architecture achieves:

Key Achievements
  1. Strong module independence
  2. High testability
  3. Type safety
  4. Maintainability
  5. Scalability

While this approach introduces additional complexity and boilerplate code, the long-term benefits in terms of maintainability, testability, and architectural flexibility make it the right choice for a modular monolith that needs to scale and evolve over time.

Golden Rule

The key is understanding when to apply this architecture: use it for inter-module communication where decoupling is critical, but avoid it for intra-module operations where direct dependencies are simpler and more appropriate.