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.