Architecture Decisions
This document records BaseForge's architectural decisions and the reasoning behind them in detail. It is updated before a new feature is added.
1. General Approach: Opinionated Library
BaseForge is not a framework — it is an opinionated library. The goal is to keep the flexibility of a library while offering the convenience of a framework. In practice this means one-line DI integration:
builder.Services.AddBaseForge(options =>
{
options.UsePostgreSQL(connectionString);
options.EnableCQRS();
options.EnableAuditLog();
});Rationale: Every microservice should not have to rebuild the same infrastructure decisions (CQRS, repository, audit, exception handling) — yet it must still be able to override behavior when it needs to.
2. Layering (Clean Architecture)
Three packages, with dependencies pointing from the outside in:
BaseForge.API ──► BaseForge.Infrastructure ──► BaseForge.Core| Layer | Depends on | Contents |
|---|---|---|
Core | Nothing (MediatR contracts only) | Entity bases, interfaces, CQRS contracts, exceptions |
Infrastructure | Core | GenericRepository, Dapper query helpers, DbContext base, DI extensions |
API | Core + Infrastructure | BaseController, middleware, AddBaseForge() |
Rules:
Coremust not depend on any concrete infrastructure (DB, HTTP, MediatR implementation). It only references MediatR's marker interfaces (IRequest, etc.) — the full MediatR package is registered in Infrastructure/API.Infrastructuremust never depend onAPI.APImay depend on both layers.
Rationale: Testability and package independence. Because Core has no dependencies, services that only want to consume contracts can pull in just that package.
3. CQRS — Built on MediatR
- CQRS is not written from scratch; it is built on MediatR.
Coredefines theICommand,IQueryandIHandlerbase contracts, which wrap MediatR'sIRequest/IRequestHandlertypes.- Every service extends these contracts.
- Decision: No CQRS/mediator library other than MediatR is added.
4. Data Access — EF Core 10 (ORM) + Dapper (raw SQL)
Decision change (2026-06-24): The original specification's "no ORM, prefer ADO.NET" rule was revised by the project owner. Rationale: the productivity of EF Core's LINQ + change tracking and the flexibility of raw SQL can be had at the same time; the boilerplate cost of pure ADO.NET hurts productivity.
A hybrid approach is used:
- EF Core 10 is the primary ORM. Responsibilities: writes (insert/update/delete), change tracking (identity map / first-level cache), migrations and most CRUD via LINQ.
- Dapper (micro-ORM) is used for raw SQL in heavy reads and complex joins; it maps results to DTOs quickly. Dapper is not a query builder/ORM — SQL is written by hand, it only provides mapping.
- Dapper runs over the EF Core
DbContext'sDbConnection(Database.GetDbConnection()), so the same connection and transaction are shared. GenericRepositoryimplements theIRepository<TEntity, TKey>contract with EF Core. For complex read scenarios a Dapper-based query helper (ISqlQuery-style) is provided.
Division of roles:
| Need | Tool |
|---|---|
| CRUD, loading relations, LINQ | EF Core |
| Change tracking, migrations | EF Core |
| Complex join / projection / report query | Dapper (raw SQL) or EF FromSql |
| Bulk set-based update/delete | EF ExecuteUpdate / ExecuteDelete |
| Full control / bare connection | DbContext.Database.GetDbConnection() |
PostgreSQL provider: Npgsql.EntityFrameworkCore.PostgreSQL.
Audit & Soft Delete
BaseEntitydefinesCreatedAt,UpdatedAt,CreatedBy(audit) andIsDeleted/DeletedAt(soft delete).- Audit fields are filled automatically in the EF Core
SaveChangesoverride. - Soft delete is applied with an EF Core global query filter; deleted records do not appear in default queries.
- Note: Dapper does not know about EF's query filter; in raw SQL written with Dapper the soft-delete condition (
WHERE is_deleted = false) must be added by hand.
5. Microservice Communication
- Database per Service: Each microservice owns its PostgreSQL database; services never access each other's DB directly.
- Synchronous: gRPC.
- Asynchronous: RabbitMQ (fire-and-forget, event-driven) — see §5.2.
5.1. gRPC — Automatic Proto Generation
Every via: grpc external reference (ExternalRefSpec) generates real gRPC client+server code during baseforge new-service (previously only an empty, Id-only interface skeleton was generated).
- Server side (automatic, no opt-out): Every generated service exposes ALL of its entities as a gRPC service (
Protos/{entity}.proto+Grpc/{Entity}GrpcService.cs). The server implementation calls the existing CQRSGet{Entity}ByIdQuerythrough MediatR — data access is not rewritten. To avoid ordering dependencies this behavior is unconditional (a provider exposes all its entities even before its consumer is generated). - Client-side resolution: Apart from
ExternalRefSpec.Target("service/Entity"), CodeGen does not know the shape of the target. Solution: using the target's service segment, a sibling{service}.yamlis looked up in the folder containing the spec file (viaSpecLoader). If found, the target entity's realPropsare read and a rich (real-field) proto+client is generated; if not, a warning is written toConsole.Errorand it silently falls back to a minimal (Id-only) stub — it never throws. identity/Userspecial case: Because Identity does not use its ownServiceSpec(it has a separateAuthSpec), sibling-spec reading does not apply. Theuser.protois generated fromauth.yaml(see §6.3); bothIdentityGeneratorand theCodeGenerator'starget: identity/Userspecial case read the same source (single source, no drift). Fixed fields (ApplicationUser): Id, UserName, Email, FullName.- Kestrel — two ports required: Without TLS (h2c), ASP.NET Core Kestrel cannot automatically distinguish HTTP/1.1 and HTTP/2 on the same port (verified in a live test:
EndpointDefaults: Http1AndHttp2alone makes REST work but silently downgrades gRPC to HTTP/1.1). So every generated service defines two separate endpoints:Http(8080, REST/Scalar) andGrpc(8081, h2c). On the client side, TLS-less HTTP/2 is enabled withAppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true). - Limitations: External references to entities with the same name from two different source services collide (the second is skipped). No JWT propagation on gRPC calls (trust relies on the Docker network boundary). decimal/datetime/date/guid are carried as
stringin proto (no native equivalent). - Cross-service host address: Each generated service runs in its own isolated
docker-compose.yml(its own Docker network), so a provider's bare name (e.g."identity") cannot be resolved via network DNS. Rich gRPC clients'ProviderHost(and the RabbitMQ broker address, see §5.2) is therefore generated ashost.docker.internal— Docker Desktop's container-to-host address. - Reference scenario:
samples/products.yaml+samples/warehouse.yaml+samples/orders.yaml→services/BaseForge.Products,services/BaseForge.Warehouse,services/BaseForge.Orders(committed, real and compilable). Orders connects to Products (sibling spec) and Identity (identity/User) over gRPC; Warehouse also connects to Products.
5.2. RabbitMQ — Automatic Event Pub/Sub
ExternalRefSpec.Via = "event" is not implemented and is reserved for a different future feature (read-model synchronization — a producer publishes CRUD events, a consumer updates a local shadow table); today it is a no-op. Asynchronous pub/sub works through two separate, simpler YAML fields: per-entity publishes and per-service subscribes.
Library side (BaseForge.Core/Infrastructure/API):
IIntegrationEvent : INotification(Core) — extends MediatR's notification contract. The publisher callsIEventBus.PublishAsync<TEvent>(); on the consumer sideRabbitMqConsumerHostedService(Infrastructure,BackgroundService) deserializes the message from the queue to the matching CLR type and dispatches it locally withIPublisher.Publish()(MediatR). The developer writes an ordinaryINotificationHandler<TEvent>and never sees RabbitMQ — no new CQRS library is added (consistent with the §3 decision).builder.Services.AddBaseForge(options => options.EnableRabbitMq(mq => { ... }))— the exact same fluent pattern asEnableJwt. If there are subscriptions (mq.Subscribe<TEvent>(eventType, queueName)), the consumer hosted service is added automatically; the outbox relay (below) is always added.- Broker: a single topic exchange (
baseforge.events). The routing key is derived from the event'sEventType(service/EntityKind→service.EntityKind). - Packages:
RabbitMQ.Client(fully async API) +Microsoft.Extensions.Hosting.Abstractions— added toBaseForge.Infrastructure(no dependency on ASP.NET Core; the host-framework-agnostic rule is kept).
Transactional Outbox (2026-07-16 — solves the dual-write risk):
IEventBus.PublishAsync<TEvent>() no longer writes to RabbitMQ directly. In the earlier design the handler first committed to the DB with _unitOfWork.SaveChangesAsync() and then wrote to the broker with _eventBus.PublishAsync() — there was no atomicity between the two: if the DB commit succeeded but the publish failed (network/broker error), the change was persisted but the event was never published.
OutboxEventBus : IEventBus(Infrastructure, Scoped) —PublishAsyncadds the event as anOutboxMessagerow (Core, plain POCO — does not implementIAuditEntity/ISoftDelete/ITenantEntity, not subject to global query filters) to the caller's currentBaseForgeDbContextchange tracker. It does not write to the DB and does no I/O.- In the CodeGen templates (
Templates.cs) the_eventBus.PublishAsync(...)call now happens before_unitOfWork.SaveChangesAsync(...)— so the outbox row is written atomically in a singleSaveChangesAsynccall, in the same transaction as the triggering business entity change. OutboxPublisherHostedService(Infrastructure,BackgroundService) — periodically scansBaseForgeDbContext.OutboxMessages(RabbitMqOptions.OutboxPollingInterval, default 2s), sends unprocessed (ProcessedAt IS NULL) rows to the real broker withIRabbitMqPublisher.PublishRawAsync()(the renamed formerRabbitMqEventBus— no longer generic, it writes the ready envelope JSON as-is) and marksProcessedAton success.- Multi-instance safety: rows are selected with
SELECT ... FOR UPDATE SKIP LOCKED— two instances never process the same row at the same time, no extra lease/claim column is needed, and if a process crashes the Postgres lock is released automatically. - The
IEventBusregistration changed from Singleton to Scoped (because it must write to the same scope'sBaseForgeDbContextas the caller) —OutboxPublisherHostedServiceis always registered. - Max retries + dead marking (2026-07-16): When
RabbitMqOptions.OutboxMaxRetries(default 10) is exceeded the row is markedOutboxMessage.IsDead = trueand drops out of the relay'sWHEREclause (AND "IsDead" = false) — it is not retried forever, but it is not deleted either (it stays in the table for manual inspection). - Cleanup/retention job (2026-07-16): At the end of every scan tick (whether or not there were new messages), processed rows (
ProcessedAtset) older thanRabbitMqOptions.OutboxRetention(default 7 days) are bulk-deleted withExecuteDeleteAsync.IsDeadrows are exempt from this cleanup.
CodeGen side:
EntitySpec.Publishes: List<string>(created/updated/deleted) — the matching Create/Update/Delete command publishes{Entity}{Kind}Event(now beforeSaveChangesAsync, into the outbox) (Features/{Entity}s/{Entity}Events.cs).ServiceSpec.Subscribes: List<SubscribeSpec>(event: "service/EntityKind",handler: ClassName) — if the target entity is found in a sibling spec (or its own spec — self-subscription needs no special case) and the Kind is in itspublisheslist, a shadow event/data class with the real fields ("rich") is generated; otherwise one with justId("minimal"), plus anINotificationHandler<T>stub (Integration/{Handler}.cs). Sibling-spec lookup shares the sameLoadSiblingSpechelper as gRPC external reference resolution (§5.1).- CLI/YAML-only v1:
publishes/subscribesthemselves (which entity publishes/listens to which event) still have no form in the Designer web UI. Unlikevia: event, these two fields are not represented in the/metaendpoint orEntityEditor.tsx; they can be planned as a fast-follow. - Designer form for RabbitMQ tuning (2026-07-16):
ServiceSpec.RabbitMqTuning(OutboxMaxRetries/OutboxRetentionDays, optional) — the exact same pattern asDockerPortsSpec(nullable nested object, directly controlled input). In the Designer it is always visible under the service form (right below DockerPorts), independent ofpublishes/subscribes— because the Designer's TS model does not representpublishes/subscribes, "does this service use RabbitMQ" could not be computed reliably on the client. If filled,mq.OutboxMaxRetries/mq.OutboxRetentionoverride lines are added to the generatedProgram.cs'soptions.EnableRabbitMq(mq => ...)block. - Docker topology: the
rabbitmqservice in the rootdocker-compose.yml(already scaffolded withRABBITMQ_*variables in.env.example) becomes the single shared broker. Generated services do not start a RabbitMQ container in their isolated compose files; theRabbitMq:Hostdefault inappsettings.jsonishost.docker.internal(see the cross-service host note in §5.1).
v1 limitations (deliberate, documented simplicity):
Consumer side: no DLQ/retry policyDLQ solved (2026-07-16): Previously, a message rejected withnack(requeue: false)was silently and permanently deleted by RabbitMQ because no dead-letter exchange was defined on the queue — genuine data loss. Now, for every subscription a{queue}.deadqueue bound to a shared{ExchangeName}.dlx(fanout) exchange is declared (the main queue is opened with thex-dead-letter-exchangeargument); a rejected message is no longer lost and can be inspected in{queue}.dead(from the RabbitMQ management UI) and replayed manually. Deliberately out of scope: automatic retry-N-times-with-delay-then-DLQ — it requires TTL+DLX chaining (delay queue pattern), and trusting it is set up correctly without validating against a live broker is risky; a separate future task.- Outbox relay: at-least-once delivery, not exactly-once — if the process crashes after the publish reaches RabbitMQ but before
ProcessedAtis committed, the message may be sent again on the next scan. The actual problem (the event being lost entirely when publishing fails after commit) is fully solved.NoSolved (2026-07-16) — Inbox pattern:EventId-based idempotency on the consumerInboxMessage(Core, does not implement marker interfaces for the same reason asOutboxMessage) +BaseForgeDbContext.InboxMessages. Before dispatching to MediatR,RabbitMqConsumerHostedService.HandleDeliveryAsynclooks up the sameEventIdinInboxMessages— if found, itacks without running the handler again. Marking happens AFTER the handler (mark-after, not mark-before): if the handler crashes, the Inbox row has not been committed yet, so the redelivery still looks "unprocessed" and is retried — with mark-before the event would be wrongly considered "already processed" and lost. Remaining limitation: these two steps (the handler's own DB effects + the Inbox row) are NOT in one transaction — if a crash happens between a successful handler and the Inbox commit/ack, a rare genuine duplicate is possible (much better than before, not perfect). No cleanup/retention job in the outboxSolved (2026-07-16) — see above.Unlimited retries in the outboxSolved (2026-07-16) — dead marking after max retries, see above. (The outbox's own "dead" rows have no separate DLQ/broker path, they stay in the table — the broker DLQ on the consumer side is a different mechanism.)- The
OutboxMessagestable, like all other entity tables, is created without migrations, only withDatabase.EnsureCreated(). No channel poolSolved (2026-07-16): A bounded (capacity 10)RentChannelAsync/ReturnChannelAsyncpool was added toRabbitMqConnectionManager;RabbitMqPublisher.PublishRawAsyncnow uses it instead of opening/closing a channel per publish. The consumer hosted service already holds a single channel for the application's lifetime, so it was unchanged.- As with gRPC calls, there is no JWT/identity propagation in messages.
5.3. JSON/JSONB Field Type
json was added to the spec type system: string (serialized JSON text) on the C# side, Postgres jsonb on the database side ([Column(TypeName = "jsonb")] rather than EF Core's fluent API — the same pattern as the existing MaxLength, embedded into the entity class as a DataAnnotation attribute).
- Decision:
["json"] = ("string", "jsonb")was added toTypeMap.cs;[Column(TypeName = "jsonb")]is generated only on the entity class, not on Create/Update command DTOs (the Column attribute is only meaningful on EF-mapped types; DTOs are not mapped — unlikeMaxLength, which is also meaningful on DTOs for ASP.NET model validation). - Rationale: For flexible/schemaless payload fields (e.g. event-specific data of audit/trace events) a single column is enough instead of a separate table/JOIN; Postgres's native
jsonbsupport also enables querying/indexing (later viaEF.Functions.JsonContains, etc.). - Limitations: Maps only to Postgres
jsonb(if another provider such as SQL Server is targeted, this type must be reconsidered).maxLengthis meaningless for json, so it is rejected automatically bySpecValidator's string/text-only check. It is carried asstringin gRPC proto (the same "no native equivalent" limitation as decimal/datetime/guid).
5.4. Append-Only Entities
EntitySpec.AppendOnly: bool — when true, the Update/Delete command, its handler and controller action are never generated; only Create/GetById/List remain.
- Decision: A per-entity flag, not per-service (a service can have both mutable and append-only entities — e.g.
Productmutable,TraceEventappend-only, in the same service). - Rationale: Audit/trace records that require regulatory compliance such as GMP/Annex 11 and 21 CFR Part 11 must never be modifiable/deletable through the API. Guaranteeing this with an endpoint that physically does not exist at the generator level is safer than just "the client should not call Update/Delete" (contract/documentation).
- Limitations: With
AppendOnly=true,publishescannot contain anything other thancreated(updated/deleted) andanonymousActionscannot containupdate/delete—SpecValidatorcatches this as an error before generation (fail loud instead of silently ignoring).
5.5. Multi-Tenancy
ServiceSpec.MultiTenant: bool — when true, all entities of the service implement ITenantEntity (Guid TenantId, BaseForge.Core.Entities); options.EnableMultiTenancy() is called.
- Decision: Service-wide, not per-entity — real isolation must cover every table; per-entity choice would be a foot-gun (forgetting one table = tenant leak).
BaseEntity<TKey>was not changed (that would be a breaking change affecting all existing services) — the newITenantEntitymarker interface, following the same pattern asISoftDelete, is added by CodeGen only to entities of services withMultiTenant: true;TenantIdis not defined by the user in YAML, it is injected automatically. - Mechanism:
ICurrentTenant(Core, same shape asICurrentUser) +CurrentTenant(API, reads the JWTtenant_idclaim) are registered in DI withEnableMultiTenancy(). InBaseForgeDbContext:ApplyAuditAndSoftDeletestampsTenantIdontoITenantEntitys in theAddedstate; ifICurrentTenant.TenantIdis null it throwsInvalidOperationException(fail loud instead of a silent NULL row).- Because EF Core allows only one query filter per entity type,
OnModelCreatingbuilds theISoftDeleteandITenantEntityfilters into a single combined filter withExpression.AndAlso(4 cases: neither / soft-delete only / tenant only / both). - The generated DbContext's constructor forwards
ICurrentUser?/ICurrentTenant?toBaseForgeDbContext((options, currentUser = null, currentTenant = null) : base(...)) — without this forwarding, tenant stamping never works.
- A known reflection trap (caught during generation, verified with a unit test): When referencing the current context (
this) in the query filter expression, it must be explicitly typed as the base class withExpression.Constant(this, typeof(BaseForgeDbContext))— ifExpression.Constant(this)uses the runtime type (always the derived DbContext class generated by CodeGen), theprivateCurrentTenantIdproperty (defined only onBaseForgeDbContext; private members are not inherited by the derived type viaFlattenHierarchy) cannot be found via reflection and every query throwsArgumentException. - Limitations: The tenant claim name is fixed:
tenant_id. Adding a record to a multi-tenant entity without a tenant context (e.g. from a background service withoutICurrentTenant) throws — this is a deliberate design (instead of a silent cross-tenant leak).
5.6. Centralized Logging and Correlation ID
Each microservice used to be trapped in its own console output — there was no way to follow a request across services through an HTTP → gRPC → RabbitMQ event chain. Centralized, structured logging with Serilog + Grafana Loki and a CorrelationId that crosses all three boundaries (HTTP/gRPC/RabbitMQ) were added.
Design decision — always on: Like ExceptionHandlingMiddleware/RequestLoggingMiddleware, this is not an opt-in toggle in spec.yaml — ServiceSpec/CodeModel/Designer UI were not touched, it was only added to the fixed CodeGen templates. If the Loki URL is empty/unreachable, the service keeps logging to the console (consistent with RabbitMQ's pattern of connecting to a shared broker via host.docker.internal — Loki being up is not a prerequisite); if the Loki sink hits a write error internally (2026-07-16), a diagnostic line is now written to stderr through Serilog.Debugging.SelfLog — it is not completely silent.
ICorrelationIdAccessor(Core,BaseForge.Core.Logging) — ambient access contract for the current flow's correlation id.CorrelationIdAccessor(Infrastructure) implements it withAsyncLocal<string?>, registered as Singleton; it flows correctly along the async call chain (HTTP → handler → outbox write, gRPC call, consumer's MediatR dispatch) and does not leak between concurrent flows.- HTTP boundary:
CorrelationIdMiddleware(API) — added at the very start of the pipeline (even beforeExceptionHandlingMiddleware). It uses the incomingX-Correlation-Idheader (or generates one), writes it to the accessor, adds it to the SerilogLogContext, and writes the same header back to the response. - gRPC boundary:
CorrelationIdClientInterceptor/CorrelationIdServerInterceptor(API,BaseForge.API.Grpc) —correlation-idis added to the outgoing call's metadata, and read on the server side into the accessor/LogContext. The CodeGen Program.cs template automatically adds.AddInterceptor<CorrelationIdClientInterceptor>()to everyAddGrpcClient<...>()call and the server interceptor toAddGrpc(). - RabbitMQ boundary: A
CorrelationIdfield was added toEventEnvelope(Infrastructure) — no DB schema/migration change (OutboxMessage.Payloadis already a full JSON blob; the new field is just a member of that JSON).OutboxEventBus.PublishAsyncembeds the accessor's current value when building the envelope;RabbitMqConsumerHostedService.HandleDeliveryAsyncrestores this id into the new scope's accessor andLogContextbefore dispatching to MediatR — the logs of the handler processing the event carry the same id as the original request that triggered it. - Serilog wiring: the new
WebApplicationBuilder.AddBaseForgeLogging(serviceName)(API) — called separately fromAddBaseForge(services, DI), at thebuilder.Host.UseSerilog(...)level (Serilog replaces the logging provider through the Host). Each log line is tagged with aServicefield; if theSerilog:LokiUrlappsettings key is set it is pushed withSerilog.Sinks.Grafana.Loki, otherwise it is written only to the console. - Shared
loki+grafanacontainers were added to the rootdocker-compose.yml(same pattern as Postgres/RabbitMQ); Grafana auto-provisions the Loki datasource viagrafana/provisioning/datasources/loki.yaml(no manual "Add datasource" required).
v1 limitations (deliberate, documented simplicity):
No ready-made dashboard in GrafanaSolved (2026-07-16):grafana/dashboards/baseforge-logs.json(provisioning:grafana/provisioning/dashboards/dashboards.yaml) — a log panel filterable withService/CorrelationIdtemplate variables + a log volume per service time-series panel. More advanced queries are still done with LogQL in Explore.- Log retention/rotation: configured to 7 days with
grafana/loki-config.yaml(2026-07-16) (retention_period: 168h,compactor.retention_enabled: true) — to change it, edit this file and restart withdocker compose up -d loki. Verified on 2026-09-26 on a live Loki 3.2.0 container (service logs arrive with theservicelabel, Grafana datasource and dashboard provisioning work). Loki + Grafana were only defined in this repo's root compose; since there was no Loki running in the user's workspace, logs silently went only to the consoleSolved (2026-09-26): on the first service/identity generation,observability/(compose + thegrafana/files above, embedded from a single source; a random Grafana admin password in.env) is written to the workspace root; if the folder exists it is left alone. The generatedlaunchSettings.jsonprovidesSerilog__LokiUrl=http://localhost:3100and thelocalhostequivalent of the Authority for localdotnet run—host.docker.internalonly resolves inside containers.- If
Serilog:LokiUrlis empty/unreachable, logs fall back to the console; when unreachable, a diagnostic message is now written to stderr viaSelfLog(2026-07-16) — but this is visibility only, not a full health check/retry. - The gRPC client interceptor only supports unary calls (CodeGen currently only generates unary
GetById— no streaming RPCs).
5.7. Health Checks and Service Status Monitoring
The healthcheck: blocks in docker-compose used to exist only for infrastructure containers (Postgres pg_isready, RabbitMQ rabbitmq-diagnostics ping) — there was no app-level probe showing whether the generated service's own application container was alive, so the Identity dashboard's "Services" section only showed a services.json snapshot frozen at codegen time (name/port/entity count, no liveness).
Design decision — always on: Like logging in §5.6, /health is not an opt-in toggle in spec.yaml — its whole purpose is for Identity to be able to poll every service reliably; if it were opt-in, some services would not show up on the dashboard.
/healthendpoint (BaseForge.API):AddBaseForgealways callsAddHealthChecks(); if a connection string was given withUsePostgreSQL(it always is),PostgresHealthCheck(Infrastructure, rawNpgsqlConnection+SELECT 1— without adding a separateAspNetCore.HealthChecks.NpgSqldependency) is added as a"postgresql"check.UseBaseForge(now takesWebApplication— extended fromIApplicationBuilderbecause endpoint mapping is required) maps/healthindependently of JWT/[Authorize](Protectis applied per controller, there is no global filter) with a small custom JSON response writer:{"status":"Healthy","checks":[{"name":"postgresql","status":"Healthy","durationMs":12}]}.- Docker healthcheck: A
curl -f http://localhost:8080/healthbasedhealthcheck:block was added to CodeGen'sdocker-compose.yml/Dockerfiletemplates (Templates.cs, andIdentityGenerator.BuildCompose/BuildDockerfilefor identity); becausemcr.microsoft.com/dotnet/aspnet:10.0does not include curl,apt-get install curlwas added to the final Docker stage. - Identity's live polling (
ServicesApiController.Status,GET /api/services/status): Identity polls every registered service except itself viahost.docker.internal:{restPort}/health— because each generated service runs in its own independent docker-compose network (container DNS is not shared), the same host-mapped port approach as the existing cross-service gRPC pattern (§7.1,CrossServiceHost = "host.docker.internal") is used. This is the first server-to-serverHttpClientin the codebase ("ServiceHealthClient", 2-second timeout, named viaAddHttpClient) — until now inter-service communication was only gRPC/RabbitMQ. - Dashboard (React):
Home.tsxmerges the existing staticservices.jsonlist (name/port/entity count) with the live{name, healthy, checkedAt}list returned by/api/services/statusby name; it refreshes every 10 seconds withsetInterval, and shows a green/red/grey dot + "Up"/"Down"/"Checking…" badge on each card.
v1 limitations (deliberate, documented simplicity):
- No historical uptime/downtime record or chart — only the current state (pull/polling, not push).
/healthand/api/services/statusare unauthenticated — consistent with the trust level the staticservices.jsonalready shares (internal/ops purpose; the dashboard is already within the same trust boundary).- No automatic alerts/notifications (email/Slack when a service goes down) — a separate future feature.
- When running locally (
dotnet run, outside a container), resolution ofhost.docker.internalis not guaranteed — a known limitation already shared by the existing gRPC cross-service pattern, not a new risk.
5.8. Gateway / BFF — YARP-Based Reverse Proxy
Until now, inter-service communication (§5.1) only provided "resolve a single record by ID" (gRPC, Integration/{Entity}Client.cs) — there was no mechanism for a frontend to reach ALL CRUD/list endpoints of multiple services through a single origin (each service lives in an isolated docker-compose network on its own host port). This adds a permanent gateway/BFF feature that lets one service expose the REST surface of the others to the frontend through a single door.
Design decision — entity-agnostic, config-only proxy. The gateway does NOT know/proxy a sibling service's entities one by one — the entire /api/* surface of every sibling service in ServiceSpec.Gateway.ProxiedServices is transparently forwarded with YARP under /api/gateway/{service}/{**catch-all}. The benefit: when a new entity is added to a sibling service the gateway does NOT need to be regenerated — only port/route information is generated up front; entity information is not needed at all.
- Spec:
gateway: { proxiedServices: [core, its, netsis] }(GatewaySpec,ServiceSpec.cs). - Port resolution (
CodeGenerator.ResolveGatewayTargets): each proxied service's host REST port is read from the sharedservices.jsonregistry at the workspace root (ServiceRegistry.LoadForWorkspace, the SAME pattern as identity gRPC port resolution in §7.1) — NOT from the sibling's rawspec.yamlDockerPorts, because for a target that has not been generated yet / has no port set this falls back to a fixed8080and multiple proxied services could collide on the same (wrong) address. A target not found in the registry is silently skipped (a warning is written) — that service must be generated/updated first. - Generation: a
ReverseProxysection in appsettings.json (aRoutes+Clusterspair for each proxied service; the/api/gateway/{service}prefix is stripped withPathRemovePrefixand/apiis added withPathPrefix— the previously usedPathPattern: /api/{catch-all}encoded the/in multi-segment captures and turned two-or-more-segment paths such asListings/{id}into 404s at the target, found live in production) andbuilder.Services.AddReverseProxy().LoadFromConfig(...)+app.MapReverseProxy()inProgram.cs(Templates.cs). TheYarp.ReverseProxypackage reference is added conditionally only to projects withGatewayset (Templates.Project,ProjectFileModel.HasGateway). - CORS: CORS on the proxied services is irrelevant from the browser's point of view (the browser never goes to them directly) — only the gateway service's OWN
corsOrigins(the existing general CORS mechanism,Cors:AllowedOrigins) must include the frontend's origin.
v1 limitation (deliberate, documented simplicity): MapReverseProxy() endpoints run outside the MVC pipeline — [Authorize] is NOT applied to them, and the gateway hop does no authentication itself. YARP forwards the Authorization header as-is by default, and the real authorization boundary still sits in the target service (protect: true + [Authorize]) — this is not a security hole (the gateway does NOT ADD an extra verification layer, but it does NOT BREAK the existing boundary either), there is just no pre-check at the gateway. Also: the proxied service's port is "baked" into appsettings.json at generation time — if a sibling's port changes later, the gateway must be regenerated too (the same known limitation as the existing gRPC-client port coupling).
6. Authentication
- There is a single central Identity Service (JWT / OAuth2).
- Every service validates the JWT token locally in its own middleware; there is no call to a central DB on every request.
6.1. Authorization Model (roles + ownership)
Authentication ("are you signed in?") and authorization ("are you allowed to do this?") are separate. auth.protect + anonymousActions only expressed the former; Identity put Admin/User roles into the token but services did not read them — every registered user could call every write endpoint. In a real-world project this gap was closed with ~60 hand-written "owner/admin required" checks and an AdminAuth.cs copied into every service.
Spec (service):
auth:
protect: true
defaultAccess: authenticated # for actions not listed in access (default: authenticated)
superRoles: [SuperAdmin] # optional — these roles pass every rule (roles + owner) automatically
entities:
Post:
ownerField: AuthorId # optional, guid prop
access:
list: anonymous
getById: anonymous
create: [Admin, Editor]
update: [Admin, owner]
delete: [Admin]Per-action values: anonymous → [AllowAnonymous]; authenticated → [Authorize]; role list → [Authorize(Roles = "...")]; if the list contains owner → [Authorize] + ownership check (the listed roles and superRoles pass the check). anonymousActions is kept for backward compatibility (same as access: { x: anonymous }); both cannot be used on the same entity.
Ownership (ownerField):
create: the owner field is not read from the request; it is stamped with the token'ssub(records cannot be created on behalf of someone else).update: the owner field is never changed (ownership cannot be transferred through the API).- If the
update/deleterule containsowner: when the caller is not the owner and none of their roles is in the rule/superRoles→ForbiddenException→ 403. - If the
list/getByIdrule containsowner: in the same situation only their own records are returned (someone else's record in getById is a 404 — existence is not leaked).
The controller decides, the handler enforces. The controller computes the role/ownership state and passes it to the command/query as a [BindNever]/[JsonIgnore] field (RestrictToOwnerId); the handler applies the filter/check only if this field is set. Rationale: gRPC server services call the same handlers without a user context (service-to-service, trusted calls) — if the check were done directly in the handler with ICurrentUser, service-to-service reads would come back empty. Because the field cannot be bound from the client (closed to model binding), it cannot be bypassed by the request.
Role claim (EnableJwt): MapInboundClaims = false, RoleClaimType = "role", NameClaimType = "sub" — OpenIddict's short claim names stay as they are, and [Authorize(Roles = ...)] and User.IsInRole work without extra code. Breaking change: code in services that looks up claims with ClaimTypes.Role/ClaimTypes.NameIdentifier must now look for the short names (role/sub) (CurrentUser.UserId checks both).
Identity (auth.yaml):
roles: [Admin, User, Editor] # seeded; Admin and User are always added
registration:
enabled: false # default: closed
defaultRole: UserWhile registration is closed: /api/account/register returns 404, the SPA hides the register link, and no account is created for a user arriving for the first time through an external provider (Google, etc.) — external sign-in is also a registration path; only pre-existing users (added from the admin panel) can sign in with an external provider. Rationale for the closed default: a safe default for a public generator; projects that need registration explicitly write enabled: true in the spec.
Known limitations: superRoles does not bypass the tenant filter in multi-tenant services (§5.5) — a SuperAdmin also only sees data from their own tenant_id; platform-wide (cross-tenant) reads are a separate feature. Counter (counters) endpoints remain public regardless of access. Role names in a service spec are checked against the roles list of the sibling identity/auth.yaml if it is found (a warning otherwise); if it is not found, the check is skipped.
6.2. List Filters and Read Visibility
Post:
filterable: [Status, AuthorId] # ?status=Live&authorId=... (equality; paginated lists only)
readFilter:
where: { IsPublished: true } # everyone sees only these (AND)
bypassRoles: [Admin] # + the service's superRoles automatically
bypassOwner: true # the owner also sees their own records (drafts)- filterable: props, relation FKs (
{Relation}Id) and external reference fields; types string/number/bool/guid/date/enum. Added to the list query as nullable properties; a filter that is not supplied is not applied. Cannot collide with pagination/search names (Page,Search…). - readFilter: a generalization of the hand-written "hide drafts from anonymous users" pattern. Applied to list and getById; a record that does not match the condition is a 404 in getById (existence is not leaked).
wherevalues are translated to C# literals at generation time according to their type (bool/enum/string/int). Same principle as ownership (§6.1): the controller decides (ApplyReadFilter,ReadFilterOwnerId—[BindNever], cannot be bound from the client), the handler enforces; gRPC service-to-service reads are not affected. For non-paginated lists it is applied in memory. - Why separate from the access rule?
accessdetermines "who can enter this endpoint",readFilterdetermines "which rows those who enter will see"; in a blog where the list is public (list: anonymous) but drafts should only be visible to the author/admin, both are needed together.
6.3. User Profile Fields (userProfile)
Because Identity is not generated from a spec (the embedded reference service is copied), domain-specific user fields (e.g. Specialty, DiplomaNo, VerificationStatus) used to require a hand-written side entity + a hand-edited user.proto. Declarative fields were added to auth.yaml:
userProfile:
props:
Specialty: string
DiplomaNo: { type: string, nullable: true, maxLength: 32 }
VerificationStatus: { type: enum, values: [Pending, Approved, Rejected], default: Pending, editableBy: admin, inToken: true }- Field definition is the same as service
props(PropSpec: type, nullable, maxLength, default, enumvalues) + two extra keys:editableBy: self | admin(defaultself— the user edits it from the profile page;adminonly from the admin panel) andinToken(defaultfalse; iftruethe field becomes a JWT claim with its camelCase name — the value is stale until the token is refreshed). Thejsontype and the use ofeditableBy/inTokenin service specs are rejected; names colliding with Identity's own fields (Email,FullName, …) and standard claim names are invalid. - No separate table: the fields are added directly to
ApplicationUser(partialclass; the generatedEntities/ApplicationUser.Profile.cs). Enums becomeUser{Field}C# enums, stored as strings in the DB (same principle as enums in services). - Schema sync: Because Identity uses
EnsureCreated, columns for fields added later to an existing database would not be created. The generatedUserProfile.EnsureColumnsAsyncrunsALTER TABLE "AspNetUsers" ADD COLUMN IF NOT EXISTSfor every field at startup (NOT NULL columns are added with a default —defaultor the type's zero value — so existing rows stay valid). Columns are never dropped/retyped (risk of data loss; do it by hand). - API:
GET /api/account/meand admin user rows return aprofiledictionary;PUT /api/account/profileaccepts onlyselffields inprofile(400 if anadminfield is sent);PUT /api/admin/users/{id}/profileedits all fields. Partial update: fields not sent are unchanged. Values are validated by type (enum value, maxLength, nullable).GET /api/account/profile-schemareturns field metadata — the shared sign-in SPA renders the profile and admin forms dynamically from it (the SPA is pre-built and embedded, so the fields are not known at build time). - gRPC:
user.protois no longer a fixed file; it is generated from auth.yaml (UserMessage1–4 fixed, profile fields from 5 onward in order). When resolving anidentity/Userexternal reference, CodeGen readsidentity/auth.yamlin the workspace and generates the same proto and richUserReferencefields; if auth.yaml is not found it falls back to the profile-less embedded proto. If the field order changes, the proto numbers change — Identity and consuming services must be regenerated together. - Known limitations: profile fields are not asked on the registration form (filled on the profile page after registration;
adminfields are closed to the user anyway). Profile changes do not publish events (publishes) — Identity event sync is a separate open item.
7. Containerization
- A separate
Dockerfileper service. - All services come up with a single
docker-compose.yml. Note: CodeGen currently generates a separate, isolateddocker-compose.ymlfor each service (with its own Postgres); the shared rootdocker-compose.ymlis only used for single-instance infrastructure (Postgres + RabbitMQ, see §5.2) — generated services connect to this shared broker viahost.docker.internal. - Configuration is read from the
.envfile;.env.productionis used in production.
7.1. Service Registry (ServiceRegistry) — Port/Authority Accuracy
On every generation, ServiceRegistry.cs keeps a shared services.json at the workspace root (one level above the generated service folder): Name, RestPort, GrpcPort, PostgresPort, IsIdentity, Authority, Audience, Protected. It has two consumers:
- The Identity dashboard — during generation, the current state of this registry is copied into identity's own
wwwrootand baked into the image (SnapshotForIdentity); the "Services" section reads it. - CodeGen itself — when a service references
identity/User(via: grpc), Identity's real gRPC port is read from this registry (ServiceRegistry.LoadForWorkspace); when a sibling service (non-identity) is referenced, the port is read directly from the sibling's ownspec.yaml(DockerPorts.Grpc). Decision change (2026-07-13): Previously theGrpc:{Provider}address in appsettings.json had the port hardcoded as8081— regardless of which port the provider actually used. This went unnoticed as long as everyone used the default ports; now that ports routinely differ (see §7.2) it would become a real connection error. If no port is found in the registry/sibling spec, it silently falls back to the old default (8081,8082for identity) — no exception is thrown.
7.2. Designer — Auto-Incrementing Port/Authority Suggestions
The Designer reads this registry via /api/workspace and, when a new service/identity is opened (spec.yaml/auth.yaml not yet on disk), pre-fills the REST/gRPC/Postgres ports as real, editable default values (not just placeholder text), one above the highest value used in the workspace — the user can still change them by hand. Likewise the Authority field is suggested as http://host.docker.internal:{identity's real REST port} if there is an Identity entry in the workspace (previously hardcoded http://localhost:5090 — an address never reachable from inside a Docker container, since 5090 is only Identity's local dotnet run port). After a service/identity is generated (both can be generated one after another in the same Designer session), the workspace is re-read; fields the user has not changed (still equal to the previous suggestion) are updated live, and a manually entered value is never overwritten.
8. Distribution
- The library packages are published to public NuGet (nuget.org):
BaseForge.Core,BaseForge.Infrastructure,BaseForge.API,BaseForge.Tools, plus theBaseForge.CodeGen.NET tool (baseforgeCLI).
Decision Log
| Date | Decision | Status |
|---|---|---|
| 2026-06-24 | Project skeleton (.NET 10, 3 src + 2 test projects, .slnx) set up | ✅ |
| 2026-06-24 | Opinionated Library + Clean Architecture + CQRS (MediatR) decisions taken from the original specification | ✅ |
| 2026-06-24 | Data access revised from the spec's "ADO.NET, no ORM" to EF Core 10 (ORM) + Dapper (raw SQL) | ✅ |
| 2026-06-24 | MediatR pinned at 12.5.0 for CQRS (last free/Apache-2.0 version; v13+ is commercial) | ✅ |
| 2026-06-24 | nuget.org Trusted Publishing (OIDC, .github/workflows/publish.yml) set up instead of a classic API key | ✅ |
| 2026-06-24 | Backlog "ER Diagram": BaseForge.Tools package + DbmlGenerator (EF Core model → DBML) added; source = EF Core model, output = DBML | ✅ |
| 2026-07-07 | Synchronous gRPC communication made real: automatic proto generation (server+client), rich sibling-spec resolution, identity/User special case, Kestrel two-port (h2c) fix. RabbitMQ still in backlog. | ✅ |
| 2026-07-10 | RabbitMQ async event pub/sub added: IIntegrationEvent/IEventBus (Core/Infrastructure, reuses MediatR for local dispatch), EnableRabbitMq (API, following the EnableJwt pattern), CodeGen publishes/subscribes (see §5.2). via: event permanently a no-op — reserved for a separate future feature. In the same pass, rich gRPC clients' ProviderHost was fixed to host.docker.internal (it never worked across isolated compose networks). | ✅ |
| 2026-07-13 | json/jsonb prop type added (see §5.3): TypeMap + [Column(TypeName = "jsonb")] only on the entity class (not on DTOs). | ✅ |
| 2026-07-13 | Append-only entity support added (see §5.4): EntitySpec.AppendOnly — Update/Delete command/handler/controller action are never generated; for the GMP/21 CFR Part 11 audit/trace scenario. | ✅ |
| 2026-07-13 | Multi-tenancy added (see §5.5): ServiceSpec.MultiTenant, ITenantEntity/ICurrentTenant/EnableMultiTenancy(), a combined soft-delete+tenant query filter via Expression.AndAlso in BaseForgeDbContext. Two real bugs were found and fixed during generation: (1) the generated DbContext constructor never forwarded ICurrentUser/ICurrentTenant, (2) Expression.Constant(this) in the query filter used the runtime type, so the private CurrentTenantId property could not be found by reflection on the derived type (fixed with Expression.Constant(this, typeof(BaseForgeDbContext)), verified with a unit test). | ✅ |
| 2026-07-13 | Docker port/Authority accuracy added (see §7.1/7.2): Postgres port + public LoadForWorkspace in ServiceRegistry; the gRPC cross-service appsettings address now uses the real port (read from the provider's own spec or the identity registry) — previously hardcoded 8081, a real connection bug. The Designer now pre-fills ports/Authority for a new service/identity from the workspace registry without collisions (editable); suggestions update live when Identity and a regular service are generated back-to-back in the same session. | ✅ |
| 2026-07-16 | Transactional Outbox Pattern added (see §5.2): IEventBus no longer writes to RabbitMQ directly; OutboxEventBus writes the event as an OutboxMessage row in the same SaveChangesAsync transaction; OutboxPublisherHostedService (multi-instance safe with FOR UPDATE SKIP LOCKED) sends it to the real broker through a separate, reliable relay. The dual-write / event loss risk between DB commit and RabbitMQ publish is solved (with an at-least-once guarantee). RabbitMqEventBus refactored into RabbitMqPublisher/IRabbitMqPublisher (wire format unchanged). | ✅ |
| 2026-07-16 | Centralized logging (Serilog + Grafana Loki) + Correlation ID added (see §5.6): ICorrelationIdAccessor (AsyncLocal) is shared between the HTTP middleware, gRPC client/server interceptors and the RabbitMQ outbox/consumer — a request is logged with the same id across all three boundaries (HTTP/gRPC/RabbitMQ). AddBaseForgeLogging (Host level, separate from AddBaseForge) always writes to the console, and to Loki if Serilog:LokiUrl is set. Shared loki/grafana containers added to the root docker-compose.yml. Always on (not an opt-in spec toggle like RabbitMQ/JWT) — ServiceSpec/Designer UI untouched. | ✅ |
| 2026-09-26 | Role + ownership based authorization model added (see §6.1): entity.access (anonymous / authenticated / role list + owner), entity.ownerField, auth.defaultAccess, auth.superRoles; roles and a closed-by-default registration in Identity (first sign-in with an external provider also counts as registration). The controller decides, the handler enforces via RestrictToOwnerId — gRPC's user-context-less service-to-service reads are not affected. EnableJwt now uses MapInboundClaims=false + RoleClaimType="role". Backward compatible: specs that do not use access generate exactly the same controllers. Two pre-existing bugs fixed in the same pass: image upload returned 500 because a new service had no wwwroot; the role list in the Identity admin panel was fixed to Admin/User. Release note: because the generated code uses new BaseController helpers, a new version of BaseForge.API must be released together with the generator (CodeGenerator.BaseForgeVersion). | ✅ |
| 2026-09-26 | User profile fields added (see §6.3): auth.yaml userProfile.props (editableBy: self|admin, inToken), fields directly on ApplicationUser (partial) instead of a separate table; user.proto generated from auth.yaml and identity/User consumers get the same proto; schema sync to an existing DB with ADD COLUMN IF NOT EXISTS; dynamic profile/admin forms in the shared sign-in SPA from a metadata endpoint. | ✅ |