Skip to content

Marten Bounded Contexts

This guide explains how to configure and use bounded contexts with Marten in Raiqub Expressions.

Prerequisites

Before working with bounded contexts, ensure you have:

  1. Marten configured in your project
  2. Raiqub.Expressions.Marten package installed
  3. PostgreSQL database configured
  4. Document stores defined for your contexts

Registering Bounded Contexts

Single Document Store per Context

The recommended approach is to have separate Marten document stores for each bounded context:

csharp
// Define bounded context interfaces
public interface ICatalogContext { }
public interface ISalesContext { }
public interface IReportingContext { }

// Define document store wrappers (optional, for explicit typing)
public class CatalogDocumentStore : IDocumentStore, ICatalogContext
{
    private readonly IDocumentStore _inner;

    public CatalogDocumentStore(IDocumentStore inner)
    {
        _inner = inner;
    }

    // Delegate all IDocumentStore methods to _inner
    public IDocumentSession OpenSession(SessionOptions options = null) =>
        _inner.OpenSession(options);

    // ... other IDocumentStore members
}

Register with Dependency Injection

Using Marten's built-in multi-tenancy or multiple store support:

csharp
// Register multiple Marten stores
services.AddMarten("catalog", options =>
{
    options.Connection("Host=localhost;Database=catalog;Username=postgres;Password=password");
    options.DatabaseSchemaName = "catalog";

    // Configure catalog documents
    options.Schema.For<Product>().Identity(x => x.Id);
    options.Schema.For<Category>().Identity(x => x.Id);
});

services.AddMarten("sales", options =>
{
    options.Connection("Host=localhost;Database=sales;Username=postgres;Password=password");
    options.DatabaseSchemaName = "sales";

    // Configure sales documents
    options.Schema.For<Order>().Identity(x => x.Id);
    options.Schema.For<Customer>().Identity(x => x.Id);
});

// Register bounded context sessions
services.AddMartenExpressions()
    .AddContext<ICatalogContext, IDocumentStore>() // Resolves "catalog" store
    .AddContext<ISalesContext, IDocumentStore>();   // Resolves "sales" store

Single Store with Schema Separation

Alternatively, use a single document store with schema separation:

csharp
services.AddMarten(options =>
{
    options.Connection("Host=localhost;Database=myapp;Username=postgres;Password=password");

    // Catalog documents in 'catalog' schema
    options.Schema.For<Product>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("catalog");

    options.Schema.For<Category>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("catalog");

    // Sales documents in 'sales' schema
    options.Schema.For<Order>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("sales");

    options.Schema.For<Customer>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("sales");
});

services.AddMartenExpressions()
    .AddContext<ICatalogContext, IDocumentStore>()
    .AddContext<ISalesContext, IDocumentStore>();

Configuration Options

Change Tracking

Configure change tracking behavior per context:

csharp
services.AddMartenExpressions()
    // Write context with tracking enabled
    .AddContext<ICatalogContext, IDocumentStore>(ChangeTracking.Enabled)
    // Read-only context with tracking disabled
    .AddContext<IReportingContext, IDocumentStore>(ChangeTracking.Disabled);

Document Configuration

Configure document-specific options:

csharp
services.AddMarten(options =>
{
    options.Connection(connectionString);
    options.DatabaseSchemaName = "catalog";

    // Configure Product document
    options.Schema.For<Product>()
        .Identity(x => x.Id)
        .UseOptimisticConcurrency(true)
        .Index(x => x.Category)
        .Index(x => x.Price)
        .SoftDeleted();

    // Configure Category document
    options.Schema.For<Category>()
        .Identity(x => x.Id)
        .UniqueIndex(x => x.Name);
});

Using Bounded Context Sessions

Injecting Sessions

Inject the typed session interfaces into your services:

csharp
public class CatalogService
{
    private readonly IDbSession<ICatalogContext> _session;
    private readonly ILogger<CatalogService> _logger;

    public CatalogService(
        IDbSession<ICatalogContext> session,
        ILogger<CatalogService> logger)
    {
        _session = session;
        _logger = logger;
    }

    public async Task<Product> CreateProductAsync(
        Product product,
        CancellationToken cancellationToken = default)
    {
        _session.Add(product);
        await _session.SaveChangesAsync(cancellationToken);
        return product;
    }

    public async Task<IReadOnlyList<Product>> GetProductsAsync(
        CancellationToken cancellationToken = default)
    {
        var query = _session.Query<Product>();
        return await query.ToListAsync(cancellationToken);
    }
}

Using Session Factories

For scenarios requiring explicit session creation:

csharp
public class OrderService
{
    private readonly IDbSessionFactory<ISalesContext> _sessionFactory;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IDbSessionFactory<ISalesContext> sessionFactory,
        ILogger<OrderService> logger)
    {
        _sessionFactory = sessionFactory;
        _logger = logger;
    }

    public async Task ProcessOrderAsync(
        Guid orderId,
        CancellationToken cancellationToken = default)
    {
        await using var session = _sessionFactory.Create();

        var query = session.Query<Order>().Where(o => o.Id == orderId);
        var order = await query.FirstAsync(cancellationToken);

        order.Status = OrderStatus.Processed;
        session.Update(order);

        await session.SaveChangesAsync(cancellationToken);
    }
}

Database and Schema Separation

Separate Databases

Use different connection strings for complete database isolation:

csharp
services.AddMarten("catalog", options =>
{
    options.Connection("Host=localhost;Database=catalog_db;...");
    options.Schema.For<Product>().Identity(x => x.Id);
});

services.AddMarten("sales", options =>
{
    options.Connection("Host=localhost;Database=sales_db;...");
    options.Schema.For<Order>().Identity(x => x.Id);
});

services.AddMartenExpressions()
    .AddContext<ICatalogContext, IDocumentStore>()
    .AddContext<ISalesContext, IDocumentStore>();

Same Database, Different Schemas

Use PostgreSQL schemas to separate contexts within a single database:

csharp
var connectionString = "Host=localhost;Database=myapp;Username=postgres;Password=password";

services.AddMarten(options =>
{
    options.Connection(connectionString);

    // Catalog schema
    options.Schema.For<Product>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("catalog");

    options.Schema.For<Category>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("catalog");

    // Sales schema
    options.Schema.For<Order>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("sales");

    options.Schema.For<Customer>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("sales");
});

services.AddMartenExpressions()
    .AddContext<ICatalogContext, IDocumentStore>()
    .AddContext<ISalesContext, IDocumentStore>();

Cross-Context Queries

Querying Across Contexts

When you need data from multiple contexts, coordinate at the application level:

csharp
public class OrderFulfillmentService
{
    private readonly IDbSession<ISalesContext> _salesSession;
    private readonly IDbQuerySession<ICatalogContext> _catalogSession;

    public OrderFulfillmentService(
        IDbSession<ISalesContext> salesSession,
        IDbQuerySession<ICatalogContext> catalogSession)
    {
        _salesSession = salesSession;
        _catalogSession = catalogSession;
    }

    public async Task<OrderFulfillmentResult> FulfillOrderAsync(
        Guid orderId,
        CancellationToken cancellationToken = default)
    {
        // Get order from sales context
        var orderQuery = _salesSession.Query<Order>()
            .Where(o => o.Id == orderId);
        var order = await orderQuery.FirstAsync(cancellationToken);

        var result = new OrderFulfillmentResult { OrderId = orderId };

        // Check product availability in catalog context
        foreach (var item in order.Items)
        {
            var productQuery = _catalogSession.Query<Product>()
                .Where(p => p.Id == item.ProductId);
            var product = await productQuery.FirstOrDefaultAsync(cancellationToken);

            if (product == null || product.AvailableQuantity < item.Quantity)
            {
                result.Errors.Add($"Product {item.ProductId} not available");
            }
        }

        if (result.Errors.Any())
        {
            return result;
        }

        // Update order status
        order.Status = OrderStatus.Fulfilled;
        _salesSession.Update(order);
        await _salesSession.SaveChangesAsync(cancellationToken);

        result.Success = true;
        return result;
    }
}

Multi-Entity Queries Across Contexts

Use IQueryStrategy<TResult> for queries spanning multiple contexts:

csharp
public class GetOrderWithProductDetailsQueryStrategy : IQueryStrategy<OrderWithProducts>
{
    private readonly Guid _orderId;

    public GetOrderWithProductDetailsQueryStrategy(Guid orderId)
    {
        _orderId = orderId;
    }

    public IQueryable<OrderWithProducts> Execute(IQuerySource source)
    {
        // Get orders from sales context
        var orders = source.GetSet<Order>();

        // Get products from catalog context
        var products = source.GetSet<Product>();

        return from order in orders
               where order.Id == _orderId
               from item in order.Items
               join product in products on item.ProductId equals product.Id
               group product by order into g
               select new OrderWithProducts
               {
                   OrderId = g.Key.Id,
                   OrderDate = g.Key.Date,
                   Products = g.ToList()
               };
    }
}

Marten-Specific Features with Bounded Contexts

Event Sourcing per Context

Configure event sourcing separately for each bounded context:

csharp
services.AddMarten("sales", options =>
{
    options.Connection(salesConnectionString);
    options.DatabaseSchemaName = "sales";

    // Enable event sourcing for sales context
    options.Events.DatabaseSchemaName = "sales_events";

    options.Events.AddEventType<OrderPlacedEvent>();
    options.Events.AddEventType<OrderFulfilledEvent>();
});

services.AddMartenExpressions()
    .AddContext<ISalesContext, IDocumentStore>();

Use Marten's full-text search within specific contexts:

csharp
public class SearchCatalogProductsQueryStrategy : EntityQueryStrategy<Product, Product>
{
    private readonly string _searchTerm;

    public SearchCatalogProductsQueryStrategy(string searchTerm)
    {
        _searchTerm = searchTerm;
    }

    protected override IQueryable<Product> ExecuteCore(IQueryable<Product> source)
    {
        // Marten's PlainTextSearch extension
        return source.Where(x => x.Search(_searchTerm));
    }
}

// Usage with catalog context
public class CatalogSearchService
{
    private readonly IDbQuerySession<ICatalogContext> _session;

    public CatalogSearchService(IDbQuerySession<ICatalogContext> session)
    {
        _session = session;
    }

    public async Task<IReadOnlyList<Product>> SearchProductsAsync(
        string searchTerm,
        CancellationToken cancellationToken = default)
    {
        var query = _session.Query(new SearchCatalogProductsQueryStrategy(searchTerm));
        return await query.ToListAsync(cancellationToken);
    }
}

Soft Deletes

Configure soft deletes per context:

csharp
services.AddMarten(options =>
{
    options.Connection(connectionString);

    // Catalog context with soft deletes
    options.Schema.For<Product>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("catalog")
        .SoftDeleted();

    // Sales context without soft deletes (hard delete)
    options.Schema.For<Order>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("sales");
});

Schema Management

Apply Schema Changes

Apply schema changes separately for each bounded context:

csharp
// In development or migration scripts
public async Task ApplySchemaChangesAsync(IServiceProvider services)
{
    // Get catalog document store
    var catalogStore = services.GetRequiredService<IDocumentStore>(); // Resolved with "catalog" name

    // Apply schema changes for catalog context
    await catalogStore.Schema.ApplyAllConfiguredChangesToDatabaseAsync();

    // Repeat for other contexts...
}

Schema Export

Export schema scripts for each context:

csharp
public void ExportSchemas(IServiceProvider services)
{
    var catalogStore = services.GetRequiredService<IDocumentStore>();

    var script = catalogStore.Schema.ToDDL();
    File.WriteAllText("catalog-schema.sql", script);
}

Testing

Integration Tests with Bounded Contexts

csharp
public class BoundedContextIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres;
    private ServiceProvider _serviceProvider;
    private IDbSession<ICatalogContext> _catalogSession;
    private IDbSession<ISalesContext> _salesSession;

    public BoundedContextIntegrationTests()
    {
        _postgres = new PostgreSqlBuilder()
            .WithImage("postgres:15-alpine")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();

        var services = new ServiceCollection();

        // Register catalog context
        services.AddMarten("catalog", options =>
        {
            options.Connection(_postgres.GetConnectionString());
            options.DatabaseSchemaName = "catalog";
            options.Schema.For<Product>().Identity(x => x.Id);
        });

        // Register sales context
        services.AddMarten("sales", options =>
        {
            options.Connection(_postgres.GetConnectionString());
            options.DatabaseSchemaName = "sales";
            options.Schema.For<Order>().Identity(x => x.Id);
        });

        services.AddMartenExpressions()
            .AddContext<ICatalogContext, IDocumentStore>()
            .AddContext<ISalesContext, IDocumentStore>();

        _serviceProvider = services.BuildServiceProvider();

        // Apply schemas
        var catalogStore = _serviceProvider.GetRequiredService<IDocumentStore>();
        await catalogStore.Schema.ApplyAllConfiguredChangesToDatabaseAsync();

        _catalogSession = _serviceProvider.GetRequiredService<IDbSession<ICatalogContext>>();
        _salesSession = _serviceProvider.GetRequiredService<IDbSession<ISalesContext>>();

        // Seed data
        _catalogSession.Add(new Product { Id = Guid.NewGuid(), Name = "Test Product" });
        await _catalogSession.SaveChangesAsync();

        _salesSession.Add(new Order { Id = Guid.NewGuid(), CustomerId = Guid.NewGuid() });
        await _salesSession.SaveChangesAsync();
    }

    [Fact]
    public async Task Should_Isolate_Contexts()
    {
        // Catalog context has products
        var catalogQuery = _catalogSession.Query<Product>();
        var products = await catalogQuery.ToListAsync();
        Assert.NotEmpty(products);

        // Sales context has orders
        var salesQuery = _salesSession.Query<Order>();
        var orders = await salesQuery.ToListAsync();
        Assert.NotEmpty(orders);
    }

    public async Task DisposeAsync()
    {
        await _serviceProvider.DisposeAsync();
        await _postgres.DisposeAsync();
    }
}

Best Practices

Do's

Use separate schemas - Isolate contexts using PostgreSQL schemas
Configure separately - Each context can have its own document configurations
Use event sourcing per context - Keep event streams isolated
Apply schema changes independently - Each context manages its own schema
Test isolation - Verify contexts don't interfere with each other

Don'ts

Don't share document types - Each context should own its documents
Don't mix schemas - Keep document types in their respective schemas
Don't use distributed transactions - Prefer eventual consistency
Don't couple contexts - Keep context implementations independent
Don't share connection pools unsafely - Ensure proper isolation

Performance Considerations

Indexing per Context

Create indexes specific to each context's query patterns:

csharp
services.AddMarten(options =>
{
    options.Connection(connectionString);

    // Catalog context indexes
    options.Schema.For<Product>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("catalog")
        .Index(x => x.Category)
        .Index(x => x.Price)
        .GinIndexJsonData(); // Full-text search on JSON

    // Sales context indexes
    options.Schema.For<Order>()
        .Identity(x => x.Id)
        .DatabaseSchemaName("sales")
        .Index(x => x.CustomerId)
        .Index(x => x.OrderDate);
});

Connection Pooling

Marten uses Npgsql connection pooling automatically. Each context can have independent connection pools:

csharp
services.AddMarten("catalog", options =>
{
    options.Connection("Host=localhost;Database=catalog;Maximum Pool Size=100;...");
});

services.AddMarten("sales", options =>
{
    options.Connection("Host=localhost;Database=sales;Maximum Pool Size=50;...");
});

See Also

Released under the MIT License.