4

I have been learning about modular monolith project structure in this article: https://codewithmukesh.com/blog/modular-architecture-in-aspnet-core

Most of it makes sense to me but something I don't quite get is:

Cross Module communication can happen only via Interfaces/events/in-memory bus. Cross Module DB Writes should be kept minimal or avoided completely.

How exactly does that cross-module communication look?

Let's say I have 3 modules:

  • Product
  • User
  • Security

My security module registers an endpoint for DisableUser. It's this endpoint's job to update a User and every Product associated with the user with a disabled status.

How does the Security module call User & Product update status method in a unit of work?

My understanding is that this pattern is intended to make it easier to extract a module to a microservice at a later date so I guess having it as a task of some sort makes it easier to change to a message broker but I am just not sure how this is supposed to look.

My example is obviously contrived, my main point is how do modules communicate together when read/writes are involved?

Guerrilla
  • 13,375
  • 31
  • 109
  • 210
  • 2
    The meaning (my interpretation) is that communication isn't going to be going through a message broker like Kafka. Instead, you would define messages you want to subscribe to in a shared project for the individual modules to register to. This could be done via a traditional event or multicast delegate, or if you're using MediatR as the project suggests you would define some interface `IMyEventNofitication : INotificationHandler` in the shared project, and implement it with your logic in each module that wants to subscribe to the event. You then Publish said event through MediatR. – Jonathon Chase Jun 15 '22 at 00:37
  • Just to add to the above: While MediatR encourages command-query separation, it does not directly enforce it. In cases like this, an INotificationHandler likely _should_ be considered a Command, and therefore should only be registered by Commands that would dispatch to it assuming the intention is to change state. If using this infrastructure you at some point decide to transition to microservices, you would redefine the notification handlers to instead push to your message broker to be picked up by the other services in place, and have those messages consumed by the other relevant services. – Jonathon Chase Jun 15 '22 at 06:52

3 Answers3

6

Theory

There are lot of misunderstandings about terminology in such questions, so let's mark 2 completely different architectures - monolith architecture and microservices architecture. So one architecture that stands between these both is a modular monolith architecture.

Monolith architecture mostly has a huge problem - high coupling and low cohesion because you have no strong methods to avoid it. So programmers decide to think about new ways of building different architectures to make really hard to fall down in high coupling low cohesion problem.

Microservices architecture was a solution (despite other problems it solve too). Main point in microservices architecture is all about separation services from each other to avoid high coupling (because it is not so easy to setup communication between services as in monolith architecture).

But programmers can't move from one architecture to completely different in "one click", so one (but not only one) way to build microservices architecture from monolith architecture is to make modular monolith first (just solve high coupling low cohesion problem but in monolith) and then extract modules to microservices easily.

Communication

To made coupling low we should focus on communication between services. Lets work with sample you put in your question.

Imagine we have this monolith architecture: Monolith architecture

We definitely see high coupling problem here. Let's say we want to build it more modular. To make that, we need to add something between modules to separate them from each other, also we want modules to communicate, so the only thing we must to add is a bus.

Something like that: enter image description here

P.S. Is could be completely separated not im-memory bus (like kafka or rabbitmq)

So your main question was about how to make communication between modules, there are few ways to do that.

Communication via interfaces (synchronous way)

Modules could call each other directly (synchronously) through interfaces. Interface is an abstraction, so we don't know what stands behind that interface. It could be mock or real working module. It means that one module doesn't know nothing about other modules, it knows only about some interfaces it communicate with.

public interface ISecurityModule { }
public interface IUserModule { }
public interface IProfileModule { }

public class SecurityModule : ISecurityModule
{
    public SecurityModule(IUserModule userModule) { } // Does not know about UserModule class directly
}

public class UserModule : IUserModule
{
    public UserModule(IProfileModule profileModule) { } // Does not know about ProfileModule class directly
}

public class ProfileModule : IProfileModule
{
    public ProfileModule(ISecurityModule securityModule) { } // Does not know about SecurityModule class directly
}

You can communicate between interfaces through methods call with no doubt but this solution doesn't help well to solve high coupling problem.

Communication via bus (asynchronous way)

Bus is a better way to build communication between modules because it forces you use Events/Messages/Commands to make communication. You can't use methods call directly anymore.

To achieve that you should use some bus (separated or in-memory library). I recommend to check other questions (like this) to find proper way to build such communication for your architecture.

But be aware - using bus you make communication between modules asynchronous, so it forces you to rewrite inner module behaviour to support such communication way.

About your example with DisableUser endpoint. SecurityModule could just send command/event/message in bus that user was disabled in security module - so other services could handle this command/event/message and "disable" it using current module logic.

What's next

Next is a microservice architecture with completely separated services communicating through separated bus with separated databases too: enter image description here

Example

Not long time ago I've done project completely in microservices architecture after course.
Check it here if you need good microservices architecture example.

Images were created using Excalidraw

picolino
  • 4,856
  • 1
  • 18
  • 31
  • Thanks for your response. When building the modular monolith approach would an in-memory bus be appropriate? To me it seems so. It doesn't slow things down with serialization and would make it easier to swap to a persistent message bus if you convert the module into a microservice. I ask this because everywhere in-memory message busses are discussed they point out it's not for production but I assume they are referring to microservice structure and not a monolith. – Guerrilla Jun 23 '22 at 09:08
  • @Guerrilla I don't see any drawbacks with using in-memory bus in production if you have monolith, but, again, it surely depends on architecture of your monolith. You should think about scalability, errors handling, sync/async communication between modules, but as a step to build microservices in the future - it is a good step. – picolino Jun 23 '22 at 17:19
4

First glance, I think one approach is to use Mediator events since the project already uses that. It would work well and keep everything separate.

To define Mediator event check this.
You define your events in shared core, for your example:

public class UserDisabled : INotification
{
    public string UserId { get; set; }
}

From the User modules you will publish the event when the user get disabled

await mediator.Publish(new UserDisabled{UserId = "Your userId"});

And Finally declare event handlers in every modules that need to react to the event

public class UserDisabledHandler : INotificationHandler<UserDisabled>
{
    public UserDisabledHandler()
    {
        //You can use depency injection here
    }
    public Task Handle(UserDisabled notification, CancellationToken cancellationToken)
    {
        throw new NotImplementedException();
    }
}

However it is worth noting that this won't work if you want to switch to actual micro-services. I'm not very familiar with micro services, but I think you need some form of event bus and that's where micro-services becomes complicated.
There is information about that in this Microsoft book.

yousif
  • 532
  • 5
  • 13
  • 2
    definitely mediator is the best pattern to put in place and MediatR is the best .net library that implements this pattern. When switching to microservices you need to define how you want your communications to be like. You have 2 choices: - Synchronous communication: your User microservice will need to have a "DisableUser" Rest (or similar) endpoint that you Product Microservice will call - Asynchronous communication: you need a message handler / message queue like Apache Kafka, Rabbit MQ or similar that will propagate the messages from one microservice to another – ddfra Jun 15 '22 at 07:15
1

Side note about overall example

!!! Scroll down if interested only in specific approach for communication between modules.

I find the whole example and architecture problematic when all modules sound like these "modules" are just repositories around some entity, e. g. "User" module is actually a repository for entity "User" and nothing more.

To my taste, User is one of entities related to Security module. They belong to same domain, after all.

I am not sure why you need to somehow store "product disabled for user X", but if "User account is disabled", then thing that check access to products, Security module, I guess, would just reply that product is disabled, without checking for per-product flag?

So, may be, it all should happen in Security module, as other modules would ask it whether user X can use product Y?

Communication between modules

Modules, logically in monolith and "physically" for microservices, are self-contained.

So they are must be designed in a way that only module's public APIs can be called outside of a module. If module A can call some internal thing of module B in modular monolith, it will become a disaster eventually.

Approaches

Actually, there are just 2 approaches for communication between modules:

  1. events - messages sent to module for processing in "throw and forget manner", without waiting for the response.
  2. commands - requests sent to module with expectation of "synchronous" processing and obtaining a result for the processing.

Implementations may vary - from some in-memory events bus and some in-memory commands dispatcher, to Kafka for events and http(s) requests for commands.

Kote Isaev
  • 273
  • 4
  • 13