Skip to content
bitzorcas
EN

Concept

Modular monolith

How BitzOrcas.Modern composes modules into a single deployable process with IAppModule governance, dependency graph verification, and hard module boundaries enforced by architecture tests.

Last updated

BitzOrcas.Modern is a modular monolith. Multiple bounded contexts — Files, Notifications, Webhooks, PlatformBilling, Catalog, Tickets, Chat, and future business modules — live in one repository, build one set of containers, and deploy as one process. Each module is a bounded context with its own contracts, ports, adapters, and feature slices. Communication across modules goes through *.Contracts assemblies only.

The shape

src/
├── BuildingBlocks/ Shared framework (10 libraries)
│ ├── BitzOrcas.Domain/ DDD primitives, `Result<T>`, entity interfaces
│ ├── BitzOrcas.Application/ Mediator pipelines, authorization, tenancy, audit
│ ├── BitzOrcas.Infrastructure/ ORM-agnostic: auditing, caching, seeding, storage
│ ├── BitzOrcas.Infrastructure.SqlSugar/ Primary ORM (trim-clean)
│ ├── BitzOrcas.Infrastructure.EfCore/ Optional ORM adapter
│ ├── BitzOrcas.Infrastructure.Dapper/ Query-only adapter
│ ├── BitzOrcas.Infrastructure.Persistence.Models/ Shared entity models
│ ├── BitzOrcas.Modularity/ IAppModule, dependency graph, boundary verifier
│ ├── BitzOrcas.CodeGeneration.Abstractions/ Code gen metadata model
│ └── BitzOrcas.CodeGeneration.Scriban/ Scriban template engine
├── Platform/ Platform-level services
│ ├── BitzOrcas.Platform.Application/ Module services, ports, default adapters
│ ├── BitzOrcas.Platform.Contracts/ Platform contract definitions
│ └── BitzOrcas.SaaS.Contracts/ SaaS-wide contracts (shared across modules)
├── Hosts/ Entry points
│ ├── BitzOrcas.Api/ HTTP API (composition root)
│ ├── BitzOrcas.AppHost/ .NET Aspire orchestrator (dev only)
│ ├── BitzOrcas.JobHost/ Quartz.NET background worker
│ └── BitzOrcas.ServiceDefaults/ OTel + health checks
└── Modules/ Future business modules (per ADR 0017)
└── <Category>/<Module>/
├── <Module>.Contracts/
├── <Module>.Domain/
├── <Module>.Application/
├── <Module>.Infrastructure/
└── <Module>.Endpoints/

Module governance — IAppModule

Every module declares itself by implementing IAppModule and registering via DI:

public sealed class CasesAppModule : IAppModule
{
public string Name => "Cases";
public string BaseNamespace => "BitzOrcas.Cases";
public IReadOnlyList<string> Dependencies => ["Tenancy", "FileAsset"];
public IReadOnlyList<string> PublishedEvents =>
["CaseOpenedIntegrationEvent", "CaseRelatedPartyChangedIntegrationEvent"];
public IReadOnlyList<string> SubscribedEvents => [];
public IReadOnlyList<string> PublicContractNamespaces => ["BitzOrcas.Cases.Contracts"];
public IReadOnlyList<string> OwnedPermissions =>
["cases.case.view", "cases.case.create", "cases.case.update"];
public IReadOnlyList<string> OwnedFeatures => ["cases.advanced-filter"];
}

Registration:

builder.Services.AddSingleton<IAppModule, CasesAppModule>();

The governance pipeline

  1. Registration — Module implements IAppModule, registered via DI
  2. DiscoveryAppModuleRegistry collects all descriptors
  3. Dependency graphAppModuleDependencyGraph builds adjacency list, provides topological sort (Kahn’s algorithm) and cycle detection
  4. Boundary verificationAppModuleBoundaryVerifier detects: duplicate names, missing dependencies, circular dependencies
  5. Architecture tests — ArchUnitNET enforces namespace boundaries at compile/CI time
  6. Operations APIGET /api/operations/governance exposes runtime module governance report

The boundary rule

Modules talk to each other only through *.Contracts assemblies:

AllowedNot allowed
BitzOrcas.Cases.Application references BitzOrcas.Files.ContractsBitzOrcas.Cases.Application references BitzOrcas.Files.Infrastructure
Platform.Application references SaaS.ContractsAny module references another module’s Domain or Application
Contracts can reference Domain abstractionsContracts references ORM types (SqlSugar, EF Core)

The cardinal rule: runtime modules never reference each other. If module A needs something from module B, it asks module B’s contracts — a thin layer of commands, queries, events, and DTOs.

How modules talk to each other

1. Ports and interfaces

Platform modules expose service interfaces in their Contracts or Application layer. Downstream modules resolve them via DI without referencing the runtime. Default adapters (InMemory/Null) are pre-registered; production adapters (SqlSugar/Local) override them at startup.

2. Domain events (in-module)

Domain events are private to the aggregate’s module. The DomainEventDispatchPipelineBehavior dispatches them after the handler runs. Handlers live in the same module; cross-module dependency is zero.

3. Integration events (cross-module)

The canonical pattern. A module writes an IIntegrationEvent to the outbox (CAP’s Cap.Published table, committed in the same transaction); the CAP background dispatcher publishes it to RabbitMQ; any module with a [CapSubscribe] handler receives a copy, with inbox-backed idempotency.

The receiver doesn’t know the producer. The producer doesn’t know the receivers. That’s the discipline.

The five-piece module suite

Per ADR 0017, every future module follows:

src/Modules/<Category>/<Module>/
BitzOrcas.<Module>.Contracts/ # Public surface (DTOs, Integration Events, Constants)
BitzOrcas.<Module>.Domain/ # Domain model (entities, value objects, domain events)
BitzOrcas.<Module>.Application/ # Use cases (Commands, Queries, Handlers, Pipelines)
BitzOrcas.<Module>.Infrastructure/ # Persistence / external integration
BitzOrcas.<Module>.Endpoints/ # Minimal API endpoints + AddModule extension

Dependency direction (strictly unidirectional):

Endpoints → Application → Domain
Endpoints → Application → Contracts
Infrastructure → Application → Domain
Endpoints → Infrastructure (DI registration only)
Cross-module → Only reference .Contracts

Bounded context map

The system defines 18 bounded contexts across four layers:

Platform (bottom layer): Tenancy, Identity, Authorization, Modularity SaaS Capabilities: Auditing, PlatformBilling, Notification, Webhooks, FileStorage, Tickets, Chat Business (future): Cases, Billing, Workflow Infrastructure: Search (Lucene), Reporting (Mart/OLAP), LocalResourceSync, CodeGeneration, Operations

When a module needs to scale out

The boundaries you’ve already drawn are the contract for extraction:

  1. The module’s runtime moves into its own service
  2. Its *.Contracts assembly is shared (NuGet package or duplicated)
  3. Cross-module communication switches from in-process Mediator and CAP in-memory to HTTP / RabbitMQ
  4. Consumers don’t change — they still publish via IEventBus, still resolve services via DI

You almost certainly don’t need this for a long time. The point is that you don’t have to commit to it on day one.