One thing I like about studying software architecture is that solving one problem usually reveals the next one.
I recently spent more time learning about modular monolith architecture. One lesson had already become clearer to me: modularity is not primarily about folders or projects. It is about ownership.
A module should own its domain logic, its data, and the implementation details behind its boundary.
That sounds straightforward—until the modules need to talk to each other.
If the Ticketing module needs information owned by Users, should it query the Users database directly? If a new user registers and Ticketing needs to create its own customer representation, should Users call Ticketing directly? Should everything use events? Should everything be synchronous?
Modularity isn't about preventing modules from communicating. It's about controlling how they communicate.
That became the next thing worth learning.
The problem starts after we create the boundary
Imagine an application containing several business modules:
Events
Users
Ticketing
Orders
Payments
Each module owns a particular business capability. Keeping those responsibilities separate gives us useful boundaries. But eventually Ticketing needs to know something about a user, or something happens in Users that Ticketing needs to react to.
At that point, the easy solution is often: just access the other module.
And that's where modularity can slowly disappear.
Don't reach into another module's internals
Suppose Ticketing needs user information. Because everything lives inside the same application, it might technically be possible for Ticketing to access the Users persistence model directly:
Ticketing
↓
UsersDbContext
↓
Users table
That may work, but now Ticketing understands details about how Users stores its data. Change the Users implementation and Ticketing may also need to change.
A better approach is to give the module an explicit public API:
Ticketing
↓
IUsersApi
↓
Users Module
The same idea applies to Events:
Ticketing
↓
IEventsApi
↓
Events Module
A module can expose capabilities without exposing its internals.
Synchronous communication has a place
It would be easy to learn asynchronous messaging and conclude that modules should always communicate through events. I don't think that's a useful rule.
Sometimes one module genuinely needs information from another module right now.
Consider adding a ticket to a customer's cart. Ticketing may need to know whether the customer exists, whether the ticket type exists, its price, and its currency. The operation cannot reasonably continue until those questions have answers.
AddItemToCart
│
├────► Users Module
│ Get customer
│◄────
│
├────► Events Module
│ Get ticket type
│◄────
│
▼
Continue operation
That's synchronous module communication, and there is nothing inherently wrong with it. The important part is where the dependency points: Ticketing depends on the module's public contract, not its internal implementation.
A public contract should really be public
Another detail I found important is that a module does not have to expose its internal application response model directly to another module.
Internal Users query
↓
Internal response
↓
Mapping
↓
Public API response
↓
Ticketing
That may look like additional mapping code, but it protects the boundary. If the internal Users application model changes, consumers should not automatically be forced to change with it.
A module's public contract should belong to the module boundary, not leak its internal implementation model.
Some duplication between internal and public models can therefore be intentional. It is the price of encapsulation.
But synchronous communication still creates coupling
A public API protects implementation boundaries. It does not eliminate coupling.
If Ticketing cannot complete an operation without Users responding, Ticketing still has a runtime dependency on Users. Within a modular monolith that may be perfectly acceptable. But if we let synchronous dependencies grow without discipline, we can end up with:
Module A → Module B → Module C → Module D → Module E
We've preserved project boundaries while creating a dependency chain.
So the useful question isn't whether synchronous communication is good or bad. It is:
Does the consuming module actually need an answer from the other module before it can continue?
If yes, synchronous communication may be exactly what we want. But sometimes the requirement is different. Sometimes something has simply happened.
Something happened in the domain
Before communication crosses module boundaries, there is another useful concept: domain events.
Suppose an Event aggregate is published. Instead of the application layer manually changing state and deciding everything that should happen afterward, the aggregate performs its business operation:
event.Publish();
Internally, the aggregate changes its state and raises something conceptually like:
EventPublishedDomainEvent
The aggregate knows that it was published. It doesn't necessarily need to know every application behavior that may react afterward.
Business events should originate where the business decision is made.
Raising an event isn't the same as publishing it
This distinction was subtle but important. An entity can record domain events without taking responsibility for dispatching them.
DOMAIN
Aggregate
↓
Raise domain event
↓
Store event temporarily
INFRASTRUCTURE
SaveChanges
↓
Find domain events
↓
Publish them
↓
Handlers react
In the implementation I studied, EF Core's save pipeline participates in collecting domain events and publishing them through MediatR.
Recording a domain event and dispatching a domain event are different responsibilities.
The domain records what happened. Infrastructure helps deliver that information to interested application behavior.
Domain events don't automatically belong to everyone
This becomes even more important when another module needs to know what happened.
Imagine a user registers. Inside Users, that may produce a UserRegisteredDomainEvent. Ticketing also needs to react by creating its own customer representation.
It might be tempting to expose the domain event directly to Ticketing. But then an internal concept from Users becomes part of the contract between modules.
Instead, we can translate the internal event into an explicit integration event:
USERS MODULE
User.Register()
↓
UserRegisteredDomainEvent
↓
Domain Event Handler
↓
UserRegisteredIntegrationEvent
│
──────┼──── MODULE BOUNDARY ──────
│
▼
EVENT BUS
│
▼
TICKETING MODULE
Integration Event Consumer
↓
CreateCustomerCommand
A domain event represents something meaningful that happened inside a domain or module. An integration event represents something the module intentionally exposes so other modules can react.
Domain events describe what happened inside a boundary. Integration events communicate what other boundaries are allowed to know about it.
Not every internal domain event needs to become an integration contract.
The producer owns the fact. The consumer owns the reaction.
This may be my favorite lesson from the whole exercise.
Users knows: a user registered.
Ticketing decides: when a user registers, I need to create a customer.
Those are different responsibilities.
UserRegistered
│
┌───────────┼───────────┐
▼ ▼ ▼
Ticketing Notifications Analytics
Users publishes the fact. Each consumer decides what that fact means to its own domain.
The producer owns the fact. The consumer owns the reaction.
This is where asynchronous communication becomes less about messaging technology and more about module autonomy.
The message broker isn't the main idea
The asynchronous implementation uses an event-bus abstraction backed by MassTransit, and at this stage the transport can even be configured in-memory.
It's easy to see names such as MassTransit, RabbitMQ, Kafka, or Azure Service Bus and think that's what event-driven architecture is about. Those are infrastructure choices.
The more important architectural seam already exists:
Domain Event
↓
Integration Event
↓
Event Bus abstraction
↓
Consumer
Architecture can establish the seam before infrastructure needs to occupy it.
Good architecture doesn't necessarily introduce maximum infrastructure immediately. It creates boundaries that allow infrastructure to evolve when the application actually needs it.
Synchronous or asynchronous?
After looking at both approaches, I don't think the useful question is which one is better.
A better question is:
What does the consuming module need?
If it needs information immediately, synchronous communication through an explicit public module API can be a natural fit. If it needs to react independently to something that already happened, an integration event may be a better fit.
Neither approach eliminates complexity. It changes where the complexity lives.
SYNCHRONOUS
Question / immediate decision
↓
Public Module API
↓
Immediate response
ASYNCHRONOUS
Something happened
↓
Integration Event
↓
Independent reaction
Independence has a price
Asynchronous communication gives us useful properties: independent consumers, reduced temporal coupling, and easier extensibility.
But it also introduces new questions.
What if the consumer fails? What if the same event arrives twice? What if processing succeeds but acknowledgement fails? What if the database transaction succeeds but publishing the integration event fails? How do we retry safely? How do we trace one business operation across asynchronous boundaries?
Asynchronous Communication
↓
Reliability
↓
Consistency
↓
Retries
↓
Idempotency
↓
Observability
Asynchronous communication reduces temporal and structural coupling, but increases operational complexity.
That's a trade-off worth making when the problem requires it. It shouldn't be introduced merely because messaging feels architecturally sophisticated.
The next problem is already waiting
This is probably the part I enjoy most about learning architecture. Every solution seems to reveal another problem.
Module Boundaries
↓
Module Communication
↓
Domain Events
↓
Integration Events
↓
Asynchronous Processing
↓
Reliability
↓
Outbox
↓
Idempotency
↓
Observability
↓
???
We separated the modules, then had to decide how they communicate. We introduced integration events, then had to think about reliability. Once communication becomes asynchronous, questions about the Outbox Pattern, duplicate delivery, idempotency, tracing, structured logging, and correlation naturally follow.
The concepts aren't isolated. One architectural decision exposes the next engineering problem worth understanding.
Architecture is a chain of trade-offs
I'm increasingly finding that learning architecture isn't about memorizing patterns.
It's easy to collect terminology: DDD, CQRS, Domain Events, Integration Events, MassTransit, Outbox, OpenTelemetry.
Knowing the names doesn't necessarily mean understanding the architecture.
The questions I find more useful are:
- What problem am I solving?
- What boundary am I protecting?
- What coupling am I introducing?
- What new failure mode does this decision create?
- What complexity am I accepting in return for the benefit?
So I wouldn't say always use events between modules, or never call another module synchronously.
Use synchronous communication when the current operation genuinely needs an answer.
Use asynchronous communication when another module needs to react independently to something that happened.
Use domain events to represent meaningful events inside the domain.
Use explicit integration contracts when those events cross module boundaries.
And most importantly:
Don't introduce a pattern because the architecture diagram looks better with it. Introduce it because you understand the problem it solves—and the problems it creates.
Continue learning
This article reflects lessons I took from studying modular monolith architecture and experimenting with domain events, synchronous module APIs, integration events, and asynchronous communication.
If you want to study these concepts in greater depth, I recommend Milan Jovanović's Modular Monolith Architecture course.
The course goes deeper into module boundaries, communication patterns, domain and integration events, messaging, reliability, and the architectural decisions behind them.
Disclosure: This is an affiliate link. If you purchase through it, I may earn a commission at no additional cost to you. I've personally enrolled in Milan's architecture courses and use them as part of my continuing software architecture learning.
Every time I understand one architectural decision a little better, it seems to uncover another question.
And that's fine.
That's usually where the next lesson begins.
Learning the Next Thing.
