Build it as a monolith. Ship it as microservices. Change one environment variable.
Live site · Quick start · One request, end to end · What is in the box · Guardrails · Documentation
The standard advice for a modular monolith is "extract services later". In practice, later is a multi-month project: new repositories, new pipelines, every in-process call rewritten as an HTTP client, retries and circuit breakers added at each call site, tracing wired from scratch.
This template deletes that project. Modules never reference each other. They talk through two contracts that already work across process boundaries (integration events and inter-module requests, both over RabbitMQ), so where a module runs is a deployment setting, not a code change.
# Everything in one process (the default in modules.json)
ModulesOptions__EnabledModules__0=*
# The same image, running only the catalogue module
ModulesOptions__EnabledModules__0=Productsflowchart LR
subgraph mono["One process: EnabledModules = *"]
direction TB
M1[IAM]
M2[Products]
M3[Inventory]
M4[Notifications]
end
subgraph split["Same image, two processes"]
direction TB
subgraph pa["EnabledModules = IAM"]
S1[IAM]
end
subgraph pb["EnabledModules = Products, Inventory"]
S2[Products]
S3[Inventory]
end
end
mono ==> BUS[("RabbitMQ")]
split ==> BUS
The call site that works in-process works across processes without a single changed line, and the trace context travels with every message. A runnable two-process demo lives in docs/split-deployment-poc.md.
This is deployment isolation on demand, not full microservices. docs/ARCHITECTURE.md explains every decision, its trade-offs and where it stops.
A store owner adds a product to their store. Here is everything that happens, and everything you did not have to write:
sequenceDiagram
autonumber
actor Owner as Store owner
participant API as Products endpoint
participant Agg as Store aggregate
participant DB as PostgreSQL
participant Out as OutboxProcessor
participant MQ as RabbitMQ
participant Inv as Inventory consumer
Owner->>API: POST /v1/stores/my/products
API->>API: validate request, check Keycloak scope products:create-own
API->>Agg: Product.Create, store.AddProduct
Agg->>API: domain events raised
API->>DB: SaveChangesAsync, one transaction
Note over API,DB: product row + OutboxMessages row + AuditLog row
Out->>DB: claim a batch (FOR UPDATE SKIP LOCKED)
Out->>MQ: publish ProductCreatedIntegrationEvent
MQ->>Inv: deliver, possibly more than once
Inv->>Inv: FusionCache pre-filter, then transactional inbox
Inv->>DB: create StockLevel, commit together with the inbox row
- The endpoint is a single method chained with
CombineAsync,TapAsyncandMapAsync. A failure anywhere short-circuits into a localizedProblemDetailsresponse. - The aggregate enforces the invariants and records what happened as versioned, frozen domain events.
- The state change, the outbox row and the audit row commit atomically. There is no dual write and nothing to forget.
- The outbox processor publishes at least once. The consumer deduplicates with a cache pre-filter and a database inbox, so a redelivery has no second effect.
- If Inventory runs in another process, nothing above changes.
The Keycloak scope check, the rate limiter, the structured logs, the OpenTelemetry spans and the audit trail come from the platform, not from the feature.
You need the .NET 10 SDK, Docker, GNU Make and the EF Core CLI (dotnet tool install --global dotnet-ef).
git clone https://github.com/baranacikgoz/modular-monolith-ddd-vsa-webapi.git
cd modular-monolith-ddd-vsa-webapi
# 1. One shared Docker network, created once (skip if it already exists)
docker network create local_shared_network
# 2. Backing services. Keycloak imports the realm from keycloak/realm-modular-monolith.json on first start.
docker compose up -d mm.postgres mm.rabbitmq mm.redis mm.keycloak mm.aspire-dashboard
# 3. Create the database and apply every module's migrations (local development only)
make ef-update-all
# 4. Build and start the API on http://localhost:5001
docker compose up --build hostThe API refuses to boot while a module has pending migrations (see Database changes), which is why step 3 comes first.
| What | Where |
|---|---|
| Swagger UI (Development only) | http://localhost:5001/swagger |
| Health probes | /health/live, /health/ready, /health/startup, /health/dependencies |
Hangfire dashboard (needs the hangfire:manage scope) |
http://localhost:5001/hangfire |
| Aspire Dashboard (traces, metrics, structured logs) | http://localhost:18888 |
Keycloak admin console (admin / admin) |
http://localhost:8080 |
RabbitMQ management (guest / guest) |
http://localhost:15672 |
Sign in as the seeded system administrator. In development the SMS provider is Dummy and the one-time code is always 123456:
curl -s -X POST http://localhost:5001/otp/login \
-H 'Content-Type: application/json' \
-d '{"phoneNumber":"901111111111"}'
curl -s -X POST http://localhost:5001/tokens \
-H 'Content-Type: application/json' \
-d '{"phoneNumber":"901111111111","otp":"123456","deviceId":"00000000-0000-4000-8000-000000000001","clientId":"mobile-app-1"}'The second call returns accessToken, refreshToken and their expiry times. Paste the access token into Swagger's Authorize button, or send it as Authorization: Bearer <token>. More seeded users, the email and password login and the whole Keycloak model are in keycloak/README.md. A fuller walkthrough with an IDE, a split deployment and the load test is in docs/getting-started.md.
| Module | What it owns | Database |
|---|---|---|
| IAM | Login, registration, tokens, sessions and permissions, brokered through Keycloak. Phone + SMS OTP or email + password. Also provisions and deprovisions users for other modules (the password travels encrypted), looks up user names in bulk, lists the effective members of a role (composite roles and groups included) and grants the store-owner role when a store is approved. | None. Keycloak is the identity store. |
| Products | Stores (an application with email ownership proof, an approval flow, a status gate on every store and product write, an administration listing of every status), product templates and products (images, SKU, attributes, package dimensions), queued spreadsheet import and CSV export. The exemplar module: copy its shape. | Schema Products |
| Inventory | Stock levels with an immutable, sequenced movement ledger and low stock thresholds, stock reservations (a saga-shaped aggregate whose status follows the warehouse system, with a reconcile for unknown outcomes), per-store warehouse connections with encrypted, rotatable credentials, a per-provider gateway behind an address guard (and an in-memory Dummy provider for development), an HMAC-signed webhook that applies each delivery once and in the provider's time order, a dirty-set stock push, a queued bulk adjustment, owner-scoped my reads and sweep jobs. |
Schema Inventory |
| Notifications | SMS, email, push (Firebase), SignalR hub, OTP issuing and verification, device registry, critical alerts to a role. | Schema Notifications |
| Outbox | Transactional outbox processor, cleanup, lag metrics and the inbox cleanup job. Loads on every instance. | Schema Outbox |
| BackgroundJobs | Hangfire server, dashboard, job metrics and the no-overlap filter. Loads on every instance. | Schema BackgroundJobs, created by Hangfire itself |
Routes, scopes, events, requests, jobs and configuration for each module are in docs/modules.md.
| Capability | What you get |
|---|---|
| Configuration-driven modules | modules.json lists the enabled modules ("*" for all). Modules are found by assembly scan and start in an explicit priority order. Outbox and BackgroundJobs are core modules that always load. |
| Compiler-enforced boundaries | No module project references another. Architecture tests and a commit guard fail the build the moment one does. |
| DDD on writes, vertical slices on reads | Writes go endpoint, aggregate method, domain event, save. Reads project straight into DTOs with AsNoTracking. |
| REPR minimal APIs | One folder per feature: Endpoint.cs, Request.cs with its validator inline, Response.cs. URL-segment API versioning (/v1/...); IAM and Notifications routes are version neutral. |
| Railway-oriented code | A Result<T> monad with BindAsync, TapAsync, MapAsync and CombineAsync. Errors carry a key, a parameter, a value and an HTTP status, and become localized ProblemDetails. |
| Strongly typed ids | Ids are value types with EF converters, route binding and a Swagger schema filter. A test forbids reaching into .Value inside a query. |
| Versioned, frozen domain events | A shipped V1 event never changes. New shapes ship as V2. A contract test enforces which payload types an event may carry. |
| Database per module | Each module owns a DbContext and a PostgreSQL schema. Point its connection string at another server to split the data. |
| Capability | What you get |
|---|---|
| Transactional outbox | Events are written in the same transaction as the state change. The processor claims batches with FOR UPDATE SKIP LOCKED, publishes in parallel, retries with backoff, cleans up after itself and exports lag metrics. |
| Transactional inbox | Consumers inherit IntegrationEventHandlerBase<T>: a FusionCache pre-filter plus a ProcessedMessages row committed with the handler's own save. A scheduled job prunes old rows. |
| Event coalescing | Events implementing ICoalescableIntegrationEvent collapse into one outbox row per type and key per save, so a bulk change does not become a message storm. |
| Inter-module requests | IInterModuleRequestClient<TRequest, TResponse> runs over MassTransit request/response, with per-request timeouts and a dedicated receive endpoint per handler. A timeout reaches the HTTP client as 503 with Retry-After, a handler fault as 502. |
| No ordering assumptions | Delivery order is never guaranteed. Ready-made projection building blocks (ProjectionEntity, UpsertIfNewerAsync) drop stale events, and a reconciliation job heals drift. |
| Trace context across hops | The trace context is stored with each outbox row and sent with the message, so a consumer in another process can continue the same trace. |
| Capability | What you get |
|---|---|
| Keycloak, realm as code | Users, roles, clients, flows, authorization services and seed users live in keycloak/realm-modular-monolith.json. Compose, tests and production start import the same file. Clients only talk to this API. |
| Two login schemes | Phone + SMS OTP for end users, email + password with email verification for staff. identityScheme.json picks which self-service registration and login routes exist (PhoneNumber or Email). |
| Fine-grained permissions | Endpoints declare .RequireScope(KeycloakScopes.X.Y). The API asks Keycloak for a resource#scope decision with the caller's token and caches it per token. An administration read asks -view-all, its owner twin -view-own; one right is held by a realm role policy (store-owner). A test keeps code and realm in sync. |
| Sessions and devices | Every login binds a device and client. Users can list their sessions, revoke one or revoke all. Access tokens live 5 minutes and refresh tokens rotate. |
| Token reuse defence | Replaying a rotated refresh token revokes the whole user session and sends the user a security alert push. |
| Machine callers | A client-credentials sample (ci-integration) shows service-to-service access through a service account policy. |
| Optional captcha | reCAPTCHA with a score threshold behind the IAM.Captcha feature flag. A Dummy provider passes everything in development. |
| Capability | What you get |
|---|---|
| EF Core 10 on PostgreSQL | One DbContext per module, queries tagged with TagWith, unique-violation mapping through EntityFrameworkCore.Exceptions. |
| Multilingual full-text search | Per-row authored language, generated tsvector columns with GIN indexes, accent folding, relevance ranking. See docs/full-text-search.md. |
| Trigram and pattern indexes | HasTrigramIndex and HasPatternIndex plus LikePattern.Contains and LikePattern.StartsWith for names, codes and barcodes a user types a fragment of. |
| Automatic audit trail | Every domain event an aggregate raises is stored in AuditLog (type, version, payload, actor) in the same transaction. A retention job prunes it (90 days by default, per-schema overrides) and history endpoints page it with a keyset cursor. |
| Offset and keyset pagination | PageNumber/PageSize or an opaque After cursor with a tiebreaker. IncludeTotal=false skips the COUNT(*). |
| S3-compatible object storage | A public store for CDN-cached objects and a private store with presigned upload and download URLs. Circuit breaker, retries and multipart uploads are configurable. |
| Caching | FusionCache with a size-bounded in-memory L1. Switch on Redis for an L2 and a backplane, with fail-safe settings for slow or failing sources. |
| Seeding | Ordered IDatabaseSeeders run after the host has fully started. |
| Queued jobs | Accept, queue, poll: JobRow, an atomic claim, 202 answers, bounded batches with a stored cursor, a stuck-job sweep and housekeeping. Worked examples: product import and export, stock bulk adjustment. |
| Spreadsheets and CSV | Uploaded .xlsx and .xls files are read in batches (ExcelDataReader) with a row ceiling, row-level rejections and a stored list of the first errors. A CSV writer guards against formula injection, and the file is served as a download. |
| Secret protection | SecretProtector encrypts credentials a module must read back (AES-GCM, key from secretProtection.json, key id in every value so the key can be rotated through PreviousEncryptionKeys), and SecretGenerator invents the secrets a caller presents back. |
| Capability | What you get |
|---|---|
| Rate limiting | Named fixed-window policies (global, SMS, email, OTP verify, registration, registration check, token create and refresh, own-store creation, store application, warehouse webhook). With Redis configured every replica shares one counter, without it an in-process limiter takes over. Each policy chooses to fail open or closed. |
| Keyed resilience pipelines | Outbound HTTP gets timeouts, retries and circuit breakers from ResiliencyOptions, with keyed overrides per dependency. The warehouse gateway builds one pipeline per store connection, without a retry for calls that change state, and its HTTP client refuses addresses that lead into the server's own network (no private, loopback or link-local target, no redirects, no proxy). |
| Webhook hardening | Constant-time HMAC-SHA256 verification (hex or base64, with or without sha256=), a secret per connection, a request body size limit, a per-connection rate limit, each delivery applied once through a delivery id table, and statuses applied in the order of the provider's own timestamp. |
| Security headers | Configurable response headers from securityHeaders.json: Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy and Permissions-Policy. |
| Reverse proxy support | Forwarded headers honoured only from configured trusted networks. |
| Request and response logging | Size-capped body logging with per-path redaction for tokens, OTP codes, registrations, store applications and warehouse connection credentials, and path exclusions. |
| Feature management | Microsoft.FeatureManagement with targeting and variants, plus a .RequireFeature(...) endpoint filter. |
| Health checks | Separate live, ready, startup and dependency probes. PostgreSQL, RabbitMQ and Redis (when configured) gate readiness. Keycloak is reported on the dependency probe only, and only in a process that runs the IAM module, so an outage never takes the whole API out of rotation. |
| Capability | What you get |
|---|---|
| SMS, email, push | NetGSM, Brevo and Firebase Cloud Messaging adapters. Each has a Dummy provider that logs instead of sending, which startup validation rejects in Production. |
| OTP with quotas | Configurable code length, expiry, resend interval and per-recipient and daily send caps. Templates are localized. |
| SignalR hub | /hubs/notifications for authenticated users, with an optional Redis backplane that production configuration requires by default. |
| Device registry | Device sessions per user, push token updates and a scheduled reconciliation job. |
| Hangfire jobs | PostgreSQL storage, a dashboard guarded by a Keycloak scope, recurring jobs that never overlap themselves, last-success gauges, and an IsServer switch to keep an instance out of job execution. |
| Capability | What you get |
|---|---|
| OpenTelemetry | Traces and metrics over OTLP with ASP.NET Core, HTTP, EF Core, Npgsql, runtime and process instrumentation. Every module has its own ActivitySource and Meter named ModularMonolith.{Module}. |
| Serilog | Console, rolling compact-JSON files and an optional Seq, Elasticsearch or OTLP sink. Logs are enriched with the user, the trace and the span, and use source-generated [LoggerMessage] methods only. |
| Aspire Dashboard | Included in Docker Compose, so traces, metrics and logs work before you configure anything else. |
| Capability | What you get |
|---|---|
| Real-infrastructure tests | xUnit v3 on Microsoft.Testing.Platform with Testcontainers (PostgreSQL, Keycloak), Respawn resets, Bogus data and NSubstitute for external APIs only. |
| CI matrix | Build, migration drift check, then one test job per module on separate runners. Release and hotfix branches, or a manual run with a tag, also build and push the Docker image. |
| Load testing | A k6 suite with buyer, store owner, new store owner and admin scenarios (50 virtual users at peak). make perf runs it with the Aspire Dashboard open. |
| Postman collection | postman/ModularMonolith-API.postman_collection.json covers the IAM, Products, Inventory and Notifications endpoints, including the HMAC-signed warehouse webhook. |
| Compose variants | Full stack, app only, performance run and the two-instance split deployment. |
| Localization | Turkish (default) and English through Aigamo.ResXGenerator, so every message is a strongly typed IResxLocalizer member. |
| Strict compiler | TreatWarningsAsErrors, AnalysisMode all and SonarAnalyzer on every project. |
Conventions rot under deadline pressure. These do not, because the build checks them:
| Rule | Enforced by |
|---|---|
| A module never depends on another module | ModuleBoundaryTests, plus a hook that blocks git commit while a module .csproj references another |
Integration events live only in Common.IntegrationEvents |
ModuleBoundaryTests |
| Domain events carry only frozen payload types | DomainEventContractTests |
Queries never compare a strongly typed id through .Value (EF cannot translate it) |
StronglyTypedIdQueryTests |
| Aggregate methods take child entities, never child ids | AggregateChildEntityParameterTests |
Stock ledger rows are written only by the StockLevel aggregate |
StockMovementWriteGuardTests |
| Each owner and administration route asks the scope it should | StockLevelRouteScopesTests, StockReservationRouteScopesTests, StoreRouteScopesTests |
| Every Inventory error key has a localized text | ErrorKeyLocalizationTests |
| Product writes go through the store status gate | StoreStatusGuardTests, and the internal Product mutators |
| Every endpoint scope exists in the Keycloak realm, and every realm scope is used | PermissionCoverageTests |
Handlers that bind request parts declare [AsParameters] |
EndpointParameterBindingTests |
| Every model migration has a committed SQL script | make check-migration-drift, run in CI |
| The app never starts against an unmigrated database | MigrationGuard |
| Production never boots with a fake provider | Startup validation rejects the Dummy SMS, email, push and captcha providers, a fixed OTP code, a cache without Redis (unless explicitly allowed) and SignalR without a Redis backplane. Outside Development it also rejects the checked-in secret protection key, and in Production it rejects AllowPrivateWarehouseAddresses and refuses a Dummy warehouse connection |
| Zero warnings | TreatWarningsAsErrors with analyzers on |
Schema changes are reviewed, not applied by the application. The workflow:
1. make ef-add-Products name=AddPriceColumn create the migration
2. make ef-script-Products from=<previous> to=<new> generate idempotent SQL into migrations/Products/
3. commit the .cs migration and the .sql script together
4. your DBA or deploy sidecar applies the .sql
5. deploy. The app checks for pending migrations and refuses to start if any remain.
In test runs pending migrations are applied automatically. For local development use make ef-update-all, which creates the database and applies every module's migrations in one command.
Everything goes through the Makefile.
| Command | What it does |
|---|---|
make build |
Builds every project, excluding the Docker Compose project |
make test |
Runs all test projects one after another |
make test-{common,host,iam,products,inventory,outbox,notifications,backgroundjobs} |
Runs one test project |
make ef-add-{Notifications,Products,Inventory,Outbox} name=X |
Adds a migration to a module |
make ef-script-{Module} from=A to=B |
Generates an idempotent SQL script |
make ef-script-all from=A to=B |
Generates scripts for all modules |
make ef-update-all |
Creates the database and applies all migrations (local development only) |
make check-migration-drift |
Fails when a model migration has no committed SQL script |
make perf / make perf-smoke / make perf-down |
Full k6 load test, 30 second smoke test, tear down |
make perf-rider, perf-rider-smoke, perf-rider-down |
The same against infrastructure an IDE already runs |
make sonar |
SonarQube analysis (needs a .env with the SonarQube variables) |
| Area | Choice |
|---|---|
| Runtime | .NET 10, ASP.NET Core Minimal APIs, C# preview language version |
| Data | EF Core 10, Npgsql, PostgreSQL |
| Messaging | MassTransit 8 over RabbitMQ 3.13 |
| Identity | Keycloak 26 with Authorization Services |
| Caching | FusionCache 2 with optional Redis L2 and backplane |
| Background jobs | Hangfire with PostgreSQL storage |
| Validation and API | FluentValidation, Asp.Versioning, Swashbuckle |
| Observability | OpenTelemetry, Serilog, Aspire Dashboard |
| Object storage | AWSSDK.S3 (any S3-compatible service) |
| Spreadsheets | ExcelDataReader (reading .xlsx and .xls uploads) |
| Providers | NetGSM (SMS), Brevo (email), Firebase Admin (push), reCAPTCHA |
| Tests | xUnit v3, Testcontainers, Respawn, NetArchTest, Bogus, NSubstitute, k6 |
This repository treats AI agents as first-class contributors, because strict architecture is exactly what makes them useful.
CLAUDE.mdis the numbered rulebook an agent (or a new teammate) reads first: architecture, platform services, exemplar files to copy, persistence rules, testing rules and the options pattern..claude/commands/holds 15 slash commands that encode the workflow:plan-feature,scaffold-feature,scaffold-module,scaffold-test,execute-feature,add-integration-event,add-inter-module-request,manage-feature-flag,manage-migration,fix-bug,plan-refactor,execute-refactor,audit-architecture,verify-featureandupdate-dependencies.- Two hooks in
.claude/hooks/stop an agent from creating a cross-module project reference, after a project file edit and again before a commit. - A generated knowledge graph in
graphify-out/lets an agent answer "where does this live" without grepping the tree.
Details are in docs/ai-workflow.md. None of it is required: the code is ordinary .NET and works with any editor.
| Document | Read it to |
|---|---|
docs/getting-started.md |
Run the system, try the API, run the tests, split the deployment, run the load test |
docs/ARCHITECTURE.md |
Understand why the architecture looks the way it does |
docs/modules.md |
See every module's routes, scopes, events, requests, jobs and settings |
docs/platform.md |
See how the shared building blocks work and when to use each |
docs/full-text-search.md |
Add or tune multilingual search |
docs/split-deployment-poc.md |
Watch two processes cooperate with no HTTP between them |
docs/ai-workflow.md |
Use or adapt the agent workflow |
keycloak/README.md |
Edit the realm, understand the permission model, rotate secrets |
src/Host/Host/Configurations/README.md |
Configure an environment and deploy |
Issues and pull requests are welcome. CONTRIBUTING.md explains the workflow and the checks a change must pass. Report vulnerabilities privately as described in SECURITY.md.
This template is the backend foundation of randevufast.com, an online appointment platform for barbershops, salons and clinics in Turkey. A second production system built the same way is a dropshipping platform where a product supplier's sellers connect their Trendyol and Hepsiburada stores and sell the supplier's catalogue, with stock tracked across all of them.
- Architecture review. I read your codebase against the concrete rule set this repository enforces with tests and send a prioritised written report with file and line references.
- Adoption sprint. We fork the template together, replace the demo domain with your first real module, wire the guardrail tests into your pipeline and set up how you keep pulling template updates. The agent rulebook and commands come with it if your team uses AI coding agents. Scoped in days, not in outcomes.
- Team workshop. Write to me for the details.
Write to baran-acikgoz@outlook.com.
Apache License 2.0. See LICENSE.