This article describes the different ways you can pass data between isolated sections of your code, known in DDD as bounded contexts. The first two articles used bounded contexts to modularize our monolith application so, when we implement the communication paths between bounded contexts, we don’t want to compromise this modularization.
DDD has lots to say about design view of communication between bounded contexts, and .NET provides some tools to implement these communication channels in modern applications. In this article I give four different approaches to communicate between bounded contexts with varying levels of isolation.
This article is part of the Evolving Modular Monoliths series, the articles are:
- Evolving modular monoliths: 1. An architecture for .NET
- Evolving modular monoliths: 2. Breaking up your app into multiple solutions
- Evolving modular monoliths: 3. Passing data between bounded contexts (this article)
- DDD says that your application should be broken up into separate parts (DDD terms: bounded contexts or domain) and these bounded contexts should be isolated from each other so that each bounded context can focus on its particular business group.
- DDD also describes various ways to communicate between bounded contexts based on the business needs but doesn’t talk too much about how they can be implemented.
- These articles are about .NET monolith applications and describe various ways to implement communicate paths between two bounded contexts:
- Example 1: exchange data via a common database
- Example 2: exchange data via a method call
- Example 3: exchange data using a message broker
- Example 4: communicating from new code to a legacy application
- At the end of example 1 there is information on how to create EF Core DbContexts for each bounded context with individual database migrations.
- The conclusion hives the pros, cons and limitation of each communication example.
Just as DDD’s bounded context helped us when breaking up out monolith into modules, then DDD can also help with mapping the communicated between bounded contexts. DDD defines seven ways to map data when passing data between bounded contexts (read this article for an explanation of each type).
You might have thought that the mappings between two bounded contexts should always isolated, but DDD recognises the isolation comes at the cost of more development time, and possibly slower communications. Therefore, the seven DDD mapping approaches run from tightly coupling right up to complete isolation between the two ends of the communication. DDD warns us that using a mapping design that tightly links the two bounded contexts can cause problems when you want to refactor / improve one of the bounded contexts.
NOTE: I highly recommend Eric Evans’ 30-minute talk about bounded contexts at DDD Europe 2020.
Later in this article I show a number of approaches which use various DDD mappings approaches.
DDD provides an architectural view of communicating between bounded contexts, but .NET provides the tools to implement the mapping and communication. The facts that the application is a monolith makes the implementation very simple and fast. Here are three approaches:
- Having two bounded contexts to map to the same data in the database.
- Calling a method in another bounded context using dependency injection (DI).
- Using a message broker to call a method in another bounded context.
I show examples of all three of these approaches and extracts the pros, cons and limitations of each. I also add a forth example that looks at you can introduce a modular monolith architecture into existing applications whose design is more like “a ball of mud”.
In a monolith you usually have one database used by all the bounded contexts, this provides a way to exchange data between bounded contexts. In a modular monolith you want each bounded context to have its own section of the database that it works with, and you can do that with EF Core. The fact there is one database does allow you to exchange data by sharing tables/columns in the database.
An example of this approach in the BookApp application is that when the Orders bounded context gets a user’s order it only has the book’s SKU (Stock Keeping Unit), but it needs the book price, title etc. Now, the BookApp.Books part of the database has that data in its Books SQL table, so the BookApp.Orders bounded context could also map to that table too. But this tightly links the BookApp.Books and BookApp.Orders bounded context.
One way to reduce the tightly linking is to have the …Orders only map to the few columns it needs. That way the …Books could add more columns and relationships without effecting the …Orders bounded context. Another thing you can do is make the …Orders mapping to the Books table read-only by using EF Core’s ToView configuration command. That makes it completely clear that the ….Orders bounded context isn’t in change of this data. The figure below shows this setup.
In terms of DDD’s mapping approaches this a shared kernel, which makes the two bounded contexts tightly linking. The fact that the …Orders bounded context only accesses a few of the columns in the Books table reduces the amount the linking of the two bounded contexts because the …Books bounded contexts could add new columns without the need to …Orders code.
The positives of this approach it’s easy to set up and it works with NuGet packages (see article 2).
Setting up separate DbContexts for each bounded context does make using EF Core migration feature a little bit complex. Here are the steps you need to do to use migrations:
- Create a DbContext just containing the classes/table in your bounded context (examples: BookDbContext and OrderDbContext)
- If any of the DbContexts access the same SQL table, you need to be careful to ensure two separate migrations try to change the same table. Your options are:
- Have only one DbContext that maps to that SQL table and other DbContexts map to that table using EF Core’s ToView configuration command. This is the recommended way because it allows you to select only the columns you need, and you only have read-only access.
- Choose one DbContext to handle EF Core configuration of that SQL table and the other DbContext’s use EF Core’s ExcludeFromMigrations configuration command.
- Then create a IDesignTimeDbContextFactory<your DbContext> and include the MigrationsHistoryTable option to set a unique name for the migration history file (example: Orders DesignTimeContextFactory)
- When you register each DbContext on startup you need to again add the MigrationsHistoryTable option (example: Startup code in ASP.NET Core)
When you want to create a migration for a DbContext in a bounded context, then you need to do that from the project containing the DbContext: this these comments in the BookApp.All OrderDbContext. This approach should be used for each DbContext in a bounded context.
One big advantage of a monolith is you can call methods, which are quick and don’t have any communication problems. But we have isolated the bounded contexts from each other, so how can we call a method in another bounded context without breaking the isolation rules? The solution is to use interfaces and dependency injection (DI). The interface provides the isolation and DI provides the correct method.
For this example of this approach let’s say that the address that the Order should be sent to is stored in the Users bounded context. To make this work, and not break the isolation we do the following:
- You place the following items in the BookApp.Common.Domain layer because only Common layers can have multiple bounded contexts access it (see the rules about the Common I defined in part 1 of this series)
- The interface IUserAddress that defines the service that the BookApp.Orders can call to obtain an Address class of a specific UserId.
- The Address class that the service will return.
- You create a class called UserAddress in the BookApp.Users bounded context that inherits the IUserAddress interface defined in step 1a. You most likely put that class in the BookApp.Users.Infrastructure layer.
- You arrange for UserAddress / IUserAddress pair to be registered with the DI provider.
- Finally, in your BookApp.Orders you obtain an instance of the UserAddress via DI and use it to get the Address you need.
The figure below shows this setup.
In terms of DDD’s mapping approaches this a customer / supplier mapping approach – the customer is the BookApp.Orders and the supplier is the BookApp.Users. The interface provides good isolation for the service but sharing of the Address class does link the two bounded contexts.
From the development point of view, you have to organise your code in three places, which is bit more work. Also, this approach isn’t that good when working with bounded contexts that have been turned into NuGet packages.
Overall, this approach provides better isolation than the exchange via the database but takes more effort.
In the last mapping implementation used the .NET DI provider, which meant any interfaces and classes used has to be in a .NET project that both bounded contexts. There is another way to automate this using a request/reply message broker. This allows you to set up mapping links between two bounded context while not breaking the isolation rules,
Let’s create the same in example 2, that is getting the address that the Order from the bounded context. Here are the steps:
- Register a request/reply message broker as a singleton to the DI provider.
- In the BookApp.Users bounded context register a get function with the message broker. You will send an Address class which is registered in the BookApp.Users bounded context.
- In the BookApp.Orders bounded context call an ask method in the message broker. You will receive the add into an Address class in the BookApp. Orders bounded context.
The figure below shows this setup.
The message broker allows you to register a getter function (left side of the figure) that can be called by the AskFor method (right side in figure). This is equivalent to calling a method in example 2 but doesn’t need any external interfaces or classes. Also, in this example there are two classes called Address which the message broker can map between to two Address classes, thus removing the need for an extra common layer we needed in example 2.
Initially I couldn’t find a request/reply message broker so I build a simple version you can find here, but more research RabbitMQ has a remote procedure call that does this (but my simple version is easier to understand).
NOTE: Microservice architecture normally use a publish/listen message broker, where apps register to be informed if certain data changes. This is done to improve performance by sending updated of data needed by a Microservice app so that it cache the data locally. However, in a monolith architecture you can access data anywhere in the app in nanoseconds (just an in-memory dictionary lookup and a function call), so a request/reply message broker is more efficient.
This is another customer / supplier mapping approach as used in example 2, but more isolation due to the request/reply message broker being able to copy data from one type to another.
From a development point of view this is easier than example 2 which called a method using DI, because you don’t have to add the BookApp.Common.Domain layer to share the interface and class. The other advantage of this message broker approach is it works with bounded contexts turned into NuGet packages.
There aren’t any downsides other than learning how to use a request/reply message broker.
Overall, I think this approach is quick to implement, provides excellent isolation and works with bounded contexts turned into NuGet packages.
There are lots of existing applications out there, some of which don’t have a good design and have fallen into “a ball of mud” – we call these legacy applications. So, the challenge is to apply a modular monolith architecture to an existing application without the “ball of mud” code “infecting” your new code.
One solution I defined uses three parts to add new code to legacy applications.
- Build your new feature in a separate solution: This gives you have a much better chance of building your code using modern approaches such as modular monolith architecture.
- You install your new feature via a NuGet package: By packaging your new feature into NuGet package makes it much easier to add your new code to the existing application.
- Use DDD’s Anticorruption Layer (ACL) mapping approach: The ACL mapping approach that builds adapters between the existing application’s code and concepts and the new code you have written to add a new feature.
The figure below shows how this might work.
You already know about separate solutions and NuGet packaging from in part 2 of this series so I concentrate an the ACL and how it works.
DDD’s ACL mapping approach is designed for interfaces to a legacy system. It assumes that a) the legacy system’s code cannot be easily changed, and b) the legacy system design suboptimal design. The ACL mapping approach hides the more difficult parts of the legacy system by using the adapter pattern, which allows you to write your new code against a “cleaned up” interface.
Of all the DDD mapping patterns the ACL mapping provides a very high level of separation between the legacy system and your new code. The downside is of all the DDD mapping approach the ACL take the most development effort to create.
While I have described how to add new code to legacy system, I have to say that it isn’t a simple job. My experience is that fully understanding a legacy system’s code is far harder and takes longer that writing the ACL layer code.
The understanding and unscrambling of a legacy system is a big topic and I’m not going to cover it here, but you might like to look a few of links I have listed below:
- Jimmy Bogard recommended the book Working Effectively with Legacy Code in a video in 2020, but a few reviewer says the book is outdated (the book was written in 2004)
- Microsoft’s guidance on Anti-Corruption Layer pattern
NOTE: You might also be interested in the strangler pattern if working with existing applications. This pattern provides a way to progressively change your old code to a more modern code design.
DDD’s ACL mapping approach provides excellent separation between the two parts but takes a lot of development effort to build. Therefore, you should only use this when there is no other way to achieve this. However, it’s not the building the ACL mapping that the hard part, the hard part is working out how the legacy system works so that you can add your new.
I have described four examples of communicating between DDD bounded contexts. From a DDD point of view I didn’t cover all of DDD’s bounded context mapping approaches in this article, but I did cover the four main ways to implement communicating between bounded contexts in .NET monolith applications.
As you have seen it’s a balance between how much the communication ties the design of two bounded contexts together against the amount of development effort it takes to write the communication link. The list below provides a summary of the pros and cons of each approach I cover in this article.
- Exchanging data via the database (example 1)
- Pro: fairly easy to implement
- Con: some linking between bounded contexts (DDD shared kernel)
- Limitations: none
- Exchanges data via a method call (example 2)
- Pro: good performance, easy to implement
- Cons: Needs extra common layer to share interfaces/classes
- Limitations: Doesn’t work with bounded context NuGet packages
- Exchange data using a request/reply message broker (example 3)
- Pro: good performance, easy to implement
- Cons: You need a request/reply message broker
- Limitations: none
- Adding new modular code to a legacy application (example 4)
- Pro: allows you to write new code using a modern design
- Cons: A LOT of work
- Limitations: none
NOTE: The “exchanging data via the database” also contains extra information (link!!!) on how to create individual EF Core DbContexts for each bounded context that has to link the database.
I hope this article, plus others in the series have been useful to you.