Your AI Agent Forgets Everything Between Sessions. Here's the Fix. (Sponsored)
You build an AI agent that handles requests perfectly — then a user comes back the next day, and it has no idea who they are. Bigger context windows don't fix this: a larger scratchpad still gets wiped when the session ends.
Real agent memory needs four types working together — working, semantic, episodic, and procedural — backed by a database that handles vectors, graphs, and relational data under one ACID-compliant engine, not three fragile systems stitched together.
Oracle's AI Database does exactly that, and the langchain-oracledb integration gets you a production-ready memory store in a few lines of code. The Oracle Developer Blog has a full deep-dive with a runnable notebook to walk you through it.
Building a Modular Monolith gives you clear boundaries between modules, but it also introduces a challenge: how do you query data that lives in different schemas?
And how do you maintain data consistency when a business operation spans multiple modules?
In a traditional monolith, you could join tables across the database.
But in a Modular Monolith, each module owns its schema.
Direct database access between modules breaks the boundaries you worked hard to establish.
I have been working with Modular Monoliths for years, and I have seen teams struggle with this exact problem.
They start with good boundaries, create separate schemas for each module, but then they need to show a report that combines data from three different modules. What do they do?
Some teams give up and start querying across schemas directly. Others try to solve it with complex event chains that are hard to debug.
And some just avoid the problem altogether by keeping everything in one module.
But there is a better way.
Today, I want to show you proven approaches for querying data across schemas and strategies for managing transactions in a Modular Monolith.
In this post, we will explore:
Why You Can't Join Tables Across Multiple Schemas
Recommended Approaches for Cross-Schema Queries
Inter-module API Calls
Domain Events with Eventual Consistency
Database Views
Composite View Pattern (BFF with YARP)
Reporting/Analysis Module
Performing Transactions Across Multiple Schemas
Domain Events for Eventual Consistency
Transaction across EF Core DbContexts for Strong Consistency
In a Modular Monolith, each module has its own database schema and DbContext in EF Core.
This separation is intentional. It enforces boundaries and makes it possible to extract a module into a microservice later.
But this separation creates two main challenges:
First, you cannot simply join tables across schemas in your queries. If you want to show shipment details along with carrier information and stock levels, you cannot write a single SQL or EF Core query that joins all three schemas.
Second, you cannot use a single database transaction that spans multiple modules.
If creating a shipment requires updating stock levels and registering with a carrier, you need a strategy to keep all three operations consistent.
Let me show you a concrete example.
Imagine you need to build a dashboard that shows:
All shipments created today
The carrier assigned to each shipment
Current stock levels for products in those shipments
In a traditional Monolith, you might write something like this:
But in a Modular Monolith, this code will not work. The ShipmentsDbContext does not know about Carriers or Stocks entities. They live in different schemas with different DbContexts.
The most straightforward approach is to call other modules through their public APIs. Each module exposes an interface that other modules can use to query data.
This is the approach is the simplest and the cheapest.
It respects module boundaries and makes dependencies explicit.
Let's say you need to display shipment details with carrier information. Here is how you would implement it:
Now in the Shipments module, you can create a handler that combines data from multiple modules:
csharp
1internalsealedclassGetShipmentDetailsHandler(2ShipmentsDbContext context,3ICarrierModuleApi carrierApi,4IStockModuleApi stockApi)5{6publicasyncTask<ErrorOr<ShipmentDetailsResponse>>HandleAsync(7string shipmentNumber,8CancellationToken cancellationToken)9{10// 1. Get shipment from the local database11var shipment =await context.Shipments
12.Include(s => s.Items)13.FirstOrDefaultAsync(s => s.Number == shipmentNumber, cancellationToken);1415if(shipment isnull)16{17return Error.NotFound("ShipmentNotFound",$"Shipment {shipmentNumber} not found");18}1920// 2. Get carrier details from the Carriers module21var carrierDetails =await carrierApi.GetCarrierByNameAsync(22 shipment.Carrier,23 cancellationToken);2425// 3. Get stock levels for each product from the Stocks module26var stockLevels =newDictionary<string,int>();27foreach(var item in shipment.Items)28{29var stockResponse =await stockApi.GetStockLevelAsync(item.Product, cancellationToken);30if(stockResponse.IsSuccess)31{32 stockLevels[item.Product]= stockResponse.Quantity;33}34}3536// 4. Combine all data into the response37returnnewShipmentDetailsResponse38{39 ShipmentNumber = shipment.Number,40 OrderId = shipment.OrderId,41 Status = shipment.Status.ToString(),42 CreatedAt = shipment.CreatedAt,43 Carrier = carrierDetails,44 Items = shipment.Items.Select(i =>newShipmentItemDetails45{46 Product = i.Product,47 Quantity = i.Quantity,48 CurrentStockLevel = stockLevels.GetValueOrDefault(i.Product,0)49}).ToList()50};51}52}
This approach has several advantages:
Clear boundaries: each module controls what data it exposes
Type safety: you work with strongly typed interfaces
Easy to test: you can mock the module APIs in unit tests
Flexible: each module can change its internal implementation without affecting others
But it also has some drawbacks:
Multiple database queries: you make separate calls to each module
N+1 query problem: if you need to enrich a list of shipments with carrier details, you will make one query per shipment
For most scenarios, this approach works well. If you have a few items, the performance overhead is usually acceptable, especially when you add caching or support for bulk operations.
Sometimes you need to query data that does not change frequently.
In these cases, you can duplicate the data across modules using integration events.
This approach works well when you need to denormalize data for read performance.
Let's say you want to display the carrier name on the shipment list without having to call the Carriers module each time.
You can store the carrier name directly in the Shipments schema.
When a carrier is updated in the Carriers module, it publishes an event:
Now you can query shipments with carrier details in the Shipments module without having to call the Carriers module.
This approach has the following advantages:
Fast queries: all data is in one schema, no joins needed
No runtime dependencies: modules do not need to call each other during queries
Better performance: single database query instead of multiple calls
But it comes with trade-offs:
Eventual consistency: data might be temporarily out of sync
Data duplication: you store the same data in multiple places
More complex: you need to handle events and keep data synchronized
Use this approach when read performance is critical and you can accept eventual consistency.
This approach also allows you to change and scale each module independently.
When using events - I highly recommend using Open Telemetry to monitor your application.
Database views provide a way to query data across multiple schemas at the database level. You create a view that joins tables from different schemas, and then map it to a read-only entity in EF Core.
This approach works well for reporting and analytics scenarios where you need to combine data from multiple modules.
Let's create a view that combines shipments with carrier information:
sql
1CREATEVIEW shipments_report_view AS2SELECT3 s.Id,4 s.Number,5 s.OrderId,6 s.Status,7 s.CreatedAt,8 c.Name AS CarrierName,9 c.ContactEmail AS CarrierContactEmail,10 c.PhoneNumber AS CarrierPhoneNumber,11 c.IsActive AS CarrierIsActive
12FROM Shipments.Shipments s
13LEFTJOIN Carriers.Carriers c ON s.Carrier = c.Name
14WHERE c.IsActive =1;
You can map this view to a read-only entity in EF Core. Create a separate DbContext for read models:
csharp
1publicclassShipmentWithCarrier2{3publicGuid Id {get;set;}4publicstring Number {get;set;}5publicstring OrderId {get;set;}6publicstring Status {get;set;}7publicDateTime CreatedAt {get;set;}8publicstring CarrierName {get;set;}9publicstring CarrierContactEmail {get;set;}10publicstring CarrierPhoneNumber {get;set;}11publicbool CarrierIsActive {get;set;}12}1314publicclassReadModelsDbContext(DbContextOptions<ReadModelsDbContext> options)15:DbContext(options)16{17publicDbSet<ShipmentWithCarrier> ShipmentsWithCarriers {get;set;}1819protectedoverridevoidOnModelCreating(ModelBuilder modelBuilder)20{21base.OnModelCreating(modelBuilder);2223 modelBuilder.Entity<ShipmentWithCarrier>()24.HasNoKey()25.ToView("shipments_report_view");26}27}
The Composite View Pattern, also known as Backend for Frontend (BFF), involves creating a separate service that aggregates data from multiple modules.
This service sits between your frontend and your Modular Monolith.
This approach is particularly useful when you have complex UI requirements that need data from many modules.
In our case, we can create a BFF service that queries multiple modules and combines the results. To access this BFF service, we use YARP as an API Gateway.
YARP (Yet Another Reverse Proxy) is a reverse proxy toolkit from Microsoft that you can use to route requests to different services.
I have written a detailed guide on how to set up YARP as an API Gateway. You can read it here: YARP as API Gateway in .NET.
Here is how the architecture looks:
Frontend calls YARP Gateway
YARP routes requests to either the main Modular Monolith or the BFF service
BFF service queries the Modular Monolith modules and aggregates the data
BFF returns the combined response to the frontend
Let's create a BFF service that provides a dashboard view:
csharp
1publicclassShipmentDashboardService(2IHttpClientFactory httpClientFactory)3{4publicasyncTask<ShipmentDashboardResponse>GetDashboardAsync(5CancellationToken cancellationToken)6{7var client = httpClientFactory.CreateClient("ModularMonolith");89// Query shipments from the Shipments module10var shipmentsResponse =await client.GetAsync(11"/api/shipments?pageSize=10",12 cancellationToken);13var shipments =await shipmentsResponse.Content
14.ReadFromJsonAsync<List<ShipmentResponse>>(cancellationToken);1516// Query carriers from the Carriers module17var carriersResponse =await client.GetAsync(18"/api/carriers",19 cancellationToken);20var carriers =await carriersResponse.Content
21.ReadFromJsonAsync<List<CarrierResponse>>(cancellationToken);2223// Query stock levels from the Stocks module24var stocksResponse =await client.GetAsync(25"/api/stocks/summary",26 cancellationToken);27var stockSummary =await stocksResponse.Content
28.ReadFromJsonAsync<StockSummaryResponse>(cancellationToken);2930// Combine all data into a dashboard view31returnnewShipmentDashboardResponse32{33...34};35}36}
Separation of concerns: the BFF handles UI-specific data aggregation
Reduced frontend complexity: the frontend makes one call instead of many
Optimized for UI: you can shape the response exactly as the UI needs it
Independent scaling: you can scale the BFF separately from the main application
Better performance for frontend: fewer HTTP calls from the browser
But it has some drawbacks:
Additional service: you need to deploy and maintain another application
Network overhead: the BFF makes HTTP calls to the main application
Duplication: you might duplicate some logic between the BFF and the main application
Use the BFF pattern when you have complex UI requirements that need data from many modules, or when you want to optimize the API for specific frontend needs.
P.S.: For BFF, I can highly recommend using GraphQL. It simplifies a lot of things.
If you are interested in GraphQL, you can read my article on HotChocolate GraphQL in .NET.
The final approach is to create a dedicated Reporting or Analysis module that has permission to query multiple schemas.
This module is the only one allowed to break the boundary rules.
This approach works well when you need complex reporting or analytics that require data from multiple modules.
The key is to enforce this rule with architecture tests.
You can use tools like NetArchTest to ensure that only the Reporting module can access multiple schemas.
With this approach you can create complex reports that join data from multiple modules.
To ensure that only the Reporting module can access multiple schemas, use separate database users for each module.
Configure connection strings with specific permissions:
Querying data across schemas is one challenge, but maintaining data consistency when you modify data in multiple modules is another.
When you create a shipment, you need to update stock levels and register with a carrier.
All three operations should succeed or fail together.
But each module has its own DbContext and schema.
How do you ensure consistency?
You have two main strategies: eventual consistency with domain events, or strong consistency with a shared transaction.
The first strategy is to use domain events.
One module completes its local transaction and publishes an event.
Other modules subscribe to this event and perform their own local transactions in response.
This results in eventual consistency.
All parts of the system will eventually be consistent, but they might be temporarily out of sync.
Let's see how this works with the CreateShipment use case:
csharp
1internalsealedclassCreateShipmentHandler(2ShipmentsDbContext context,3IStockModuleApi stockApi,4IEventPublisher eventPublisher)5: ICreateShipmentHandler
6{7publicasyncTask<ErrorOr<ShipmentResponse>>HandleAsync(8CreateShipmentRequest request,9CancellationToken cancellationToken)10{11// 1. Check if the shipment already exists12// 2. Check stock levels (read-only operation)13// 3. Save the shipment in the local database1415var shipment =newShipment{...};1617await context.Shipments.AddAsync(shipment, cancellationToken);1819// 4. Publish an event for other modules to react20var shipmentCreatedEvent =newShipmentCreatedEvent(...);21await eventPublisher.PublishAsync(shipmentCreatedEvent, cancellationToken);2223await context.SaveChangesAsync(cancellationToken);2425return shipment.MapToResponse();26}27}
Now the Carriers and Stocks module subscribes to the ShipmentCreatedEvent.
This approach has the following advantages:
Loose coupling: modules do not depend on each other directly
Resilience: if one module fails, others can continue
Scalability: you can process events asynchronously
Easy to add new handlers: you can add new modules that react to the same event
But it has some challenges:
Eventual consistency: data might be temporarily out of sync
Error handling: if an event handler fails, you need a retry mechanism
Debugging: it is harder to trace the flow of execution
Complexity: you need to handle partial failures and compensating transactions
For better reliability, you should implement the Outbox pattern.
This pattern stores events in the same database transaction as your business data, and then publishes them in a separate process. This ensures that events are never lost.
In a production system, you might use a robust event messaging bus such as RabbitMQ.
The second strategy is to use a shared transaction across multiple DbContexts.
This gives you strong consistency: either all operations succeed, or all fail together.
To implement this, you need a TransactionManager that coordinates the transaction across multiple modules.
Eventual consistency with domain events is the better choice in most scenarios. Here is when you should use it:
Scenario 1: Order Processing
When a customer places an order, you create a shipment, update stock levels, and notify the carrier.
If the carrier notification fails, it does not affect the customer's order. You can retry the notification later.
In this case, eventual consistency is acceptable. The shipment is created, stock is updated, and the carrier will be notified eventually.
Scenario 2: Analytics and Reporting
When you update a shipment status, you might want to update analytics data in a separate module.
The analytics data does not need to be updated immediately. It can be updated within a few seconds or within a few minutes.
Eventual consistency is perfect for this scenario.
Scenario 3: Sending Notifications
When a shipment is created, you want to send an email to the customer.
If the email service is temporarily down, you do not want to fail the entire shipment creation.
You can retry sending the email later.
Again, eventual consistency is the right choice.
Scenario 4: Cross-Module Data Synchronization
When carrier information changes, you want to update the denormalized carrier data in the Shipments module.
This update does not need to happen immediately. It can happen a few seconds later.
Eventual consistency works well here.
The key insight is this: if a failure in one module should not prevent the main operation from succeeding, use eventual consistency.
Eventual consistency gives you better resilience, scalability, and loose coupling. Your system can continue to function even if some parts are temporarily unavailable.
Strong consistency with a shared transaction is necessary when you absolutely cannot accept any temporary inconsistency.
Here is when you should use it:
Scenario 1: Financial Transactions
When you process a payment, you need to deduct money from one account and add it to another.
Both operations must succeed or fail together. You cannot have a situation where money is deducted but not added.
In this case, you need strong consistency with a database transaction.
Scenario 2: Inventory Reservation
When a customer adds a product to their cart and proceeds to checkout, you need to reserve the inventory.
If the reservation fails, the checkout should fail as well. You cannot sell more products than you have in stock.
Strong consistency is required here.
Scenario 3: Booking Systems
When a customer books a hotel room or a flight seat, you need to mark it as unavailable immediately.
You cannot have two customers booking the same room or seat.
You need strong consistency to prevent double booking.
In practice, you will often use a hybrid approach. Use strong consistency for critical operations like inventory updates and financial transactions.
Use eventual consistency for non-critical operations, such as notifications and analytics.
The key is to understand your business requirements and choose the right approach for each scenario.
Do not default to strong consistency everywhere.
Eventual consistency is often good enough and gives you better resilience and scalability.
Remember that a Modular Monolith is about maintaining clear boundaries while keeping everything in a single deployable unit.
These patterns help you maintain those boundaries while still allowing modules to work together effectively.
Hope you find this newsletter useful. See you next time.
Whenever you're ready, here's how I can help you:
The .NET Senior Playbook — 800+ real-world interview questions with expert answers across 50 chapters. You try to answer each question first, then reveal the full solution — and a test after every chapter proves it actually stuck. Finish, and you earn a verifiable certificate for your LinkedIn.