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
- Registration — Module implements
IAppModule, registered via DI - Discovery —
AppModuleRegistrycollects all descriptors - Dependency graph —
AppModuleDependencyGraphbuilds adjacency list, provides topological sort (Kahn’s algorithm) and cycle detection - Boundary verification —
AppModuleBoundaryVerifierdetects: duplicate names, missing dependencies, circular dependencies - Architecture tests — ArchUnitNET enforces namespace boundaries at compile/CI time
- Operations API —
GET /api/operations/governanceexposes runtime module governance report
The boundary rule
Modules talk to each other only through *.Contracts assemblies:
| Allowed | Not allowed |
|---|---|
BitzOrcas.Cases.Application references BitzOrcas.Files.Contracts | BitzOrcas.Cases.Application references BitzOrcas.Files.Infrastructure |
Platform.Application references SaaS.Contracts | Any module references another module’s Domain or Application |
Contracts can reference Domain abstractions | Contracts 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 extensionDependency direction (strictly unidirectional):
Endpoints → Application → DomainEndpoints → Application → ContractsInfrastructure → Application → DomainEndpoints → Infrastructure (DI registration only)Cross-module → Only reference .ContractsBounded 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:
- The module’s runtime moves into its own service
- Its
*.Contractsassembly is shared (NuGet package or duplicated) - Cross-module communication switches from in-process Mediator and CAP in-memory to HTTP / RabbitMQ
- 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.
Related
- Vertical Slice Architecture — what each module looks like internally
- Module Overview — the seven platform modules that ship today
- Architecture Diagrams — visual system overview and module dependency map
- Dependency Injection — how the composition root wires everything