PRD-003: Platform Infrastructure
Author: MLT backend team | Date: 2026-08-18 | Status: Draft | Version: v0.1.0
Revision History
| Version | Date | Author | Description of Change |
|---|---|---|---|
| v0.1.0 | 2026-08-18 | MLT backend team | Initial draft — the net-new cross-cutting backend infrastructure the domain builds on. |
1. Context & Business Rationale
Net-new cross-cutting backend infrastructure that the feature epics depend on: an integration-test
harness, a domain-event system, a Result<T> railway type, and real notification transports
(email + realtime). This is a foundation epic — it unblocks E02 (Identity/Auth) and E03+ (case
domain, workflow).
Aware of what already exists (this fills gaps, it does not rebuild): Miewdiator
(Send/Publish, an Event base, IMediatorHandler.RaiseEvent), Hangfire (background jobs
on Postgres), Serilog + OpenTelemetry + Prometheus (observability), ProblemDetails, and the
EF-InMemory unit-test pattern. Koltena/bmcar are inspiration only — note they use MediatR;
we use Miewdiator.
2. Problem Statement
The backend can't be tested against real Postgres, has no way for aggregates to announce facts (domain events don't collect or dispatch), has no functional-error primitive, and has no working email/realtime transport. Every feature epic needs these before it can be built to the bar this product requires (server-authoritative, tested, court-grade audit).
3. Goals, Non-Goals, and Success Metrics
3.1 Goals
- Integration-test harness (Testcontainers-Postgres) — the first thing built; everything else
is developed test-first on it (per the repo's
tddskill). - Domain-event system — aggregates collect events; the UnitOfWork dispatches them on commit via Miewdiator.
Result<T>return type — a success-or-failure return wrapper for handlers/repos, where failure carries the existingDomainError. The primary command-flow error path.- Real notifications —
IEmailSenderbacked by MailKit (Mailtrap in dev/test) andIRealtimeNotifierbacked by SignalR — no placeholder stubs.
3.2 Non-Goals
- Transactional outbox — deferred; named as the reliability upgrade for guaranteed delivery.
- Clock/time abstraction — out of scope for now.
- Secrets management → E11 (Security). CI pipeline → E13. Audit trail → E09 (fed by domain events, specified there).
- The email delivery pipeline (queue/retry/DLQ) → E10 — it wraps this epic's MailKit sender.
3.3 Success Metrics
| Metric Type | Definition | Baseline | Target |
|---|---|---|---|
| Primary | Repos/UoW/migrations/events are testable against real Postgres; events dispatch on commit; email+realtime deliver | none | works end-to-end |
| Guard Rail | No handler side-effect rolls back committed data | — | committed data never reverted by a failing handler |
| Guard Rail | Migrations apply cleanly with no pending model changes | — | green on a fresh container |
4. Actors
Backend developers (this is internal infrastructure; no end-user actor).
5. User Stories (developer-facing)
- As a developer, I can write an integration test against real Postgres with migrations applied.
- As a developer, an aggregate can raise a domain event and have it dispatched after commit.
- As a developer, I can compose a command as a
Result<T>railway. - As a developer, I can send email (to Mailtrap in dev) and push a realtime notification.
6. System Requirements
6.1 Test harness
- SYS-REQ-101: A Testcontainers-Postgres harness + reusable base fixture provides a
DbContext+UnitOfWorkper test class, applies EF migrations, and is opt-in (unit tests stay on EF-InMemory).
6.2 Domain events
- SYS-REQ-201: An
AggregateRootbase collects domain events (RaiseDomainEvent, read-onlyDomainEvents,ClearDomainEvents); events derive from the existingEventbase. - SYS-REQ-202:
UnitOfWork.CommitAsync, afterSaveChangesAsyncsucceeds, publishes collected events via Miewdiator in raise order, then clears them. A failed/rolled-back commit publishes nothing. A handler exception is logged and does not revert committed data.
6.3 Result<T>
- SYS-REQ-301: A
Result<T>return type — either success (T) or failure carrying aDomainError(reusing the existing item→bag error stack:DomainNotification→DomainError). Minimal surface: LINQSelect/SelectMany(sync + async) so steps compose asfrom … select …, andMatchto consume it; implicit construction from a value or aDomainError. A failed step short-circuits the rest; guards return explicitDomainErrors (exceptions are not coerced into a generic error). No wider operator set unless a real need appears. - SYS-REQ-302:
Result<T>is the primary command-flow error path (rendered by the existingApiController.FromResult<T>→FromError). The notification-bus pattern (DomainNotificationHandlercollecting publishedDomainNotifications) is retained only for accumulating multiple validation messages, not for normal request flow.Result<T>concerns control flow / return value; it is unrelated to domain events (facts), which stay on the mediator for side-effects.
6.4 Notifications
- SYS-REQ-401:
IEmailSenderbacked by a MailKit SMTP sender; dev/test target Mailtrap (config-driven, secrets out of code); a test helper asserts sent mail. - SYS-REQ-402:
IRealtimeNotifierbacked by a SignalR hub, tenant-scoped groups, authenticated by the identity cookie; a test client helper asserts delivery.
7. Design Notes & Opinionated Decisions
(This is the epic's design home — the "why" behind the stories. Per-task "how" lives on each issue.)
7.1 Testcontainers, and how migrations are tested
The integration harness is a library the test process drives — no compose file, no Tilt. Per
test class: start a real Postgres container, point EF at its connection string, run
Database.MigrateAsync() (the actual migration files, in order), run the test, drop the
container. This is why it beats EF-InMemory for anything schema-shaped: real SQL, constraints, the
global query filter, and migrations. Migration tests assert (a) all migrations apply cleanly on a
fresh DB and (b) HasPendingModelChanges() is false (the migrations fully describe the model — the
check that catches the PoC's "altered a table that was never created" class of bug). CI
requirement: a Docker daemon must be available for Testcontainers (a CI line item, not a bespoke
test image or docker-compose.test.yml). Tilt stays purely for the local dev loop.
7.2 Domain events — collect then dispatch on commit
Opinionated: aggregates accumulate events (raise from domain methods; never construct-and-publish
inline); the UnitOfWork dispatches them after SaveChangesAsync succeeds, via Miewdiator
(IMediator.Publish), in raise order, then clears. Events fire only on a committed change,
in-process, within the request. Handlers (IEventHandler<T>) are where side-effects live (audit
writes, notifications). Non-choices: not MediatR (we use Miewdiator); no immediate in-method
publish; no cross-aggregate transactional guarantees beyond the single SaveChanges. Reliability:
v1 is in-process, post-commit, best-effort (at-most-once); a transactional outbox is the named
upgrade if any handler's effect must not be lost.
7.3 Result<T> — and how it relates to the existing error handling
Result<T> reuses, it doesn't replace, the existing error stack: DomainNotification (one error
item — key, message, ErrorTypes→HTTP status) → DomainError (a bag of items) → Result<T>
(the return wrapper). Success carries T; failure carries a DomainError; the first failure
short-circuits, so a command reads as a straight sequence (from … select …). This is exactly
Koltena's shape (its Result<T> carries DomainError too), and MLT's ApiController.FromResult<T>
already renders it — with one divergence: do not swallow exceptions into a generic error; guards
return explicit domain errors.
The only thing that changes is how the error surfaces: today the repo publishes DomainNotifications
on the mediator bus and a handler collects them; Result<T> returns the failure instead.
Decision: Result<T> is the primary path for command flow; keep the notification-bus pattern only
for accumulating several validation messages. Result<T> is about control flow and is not the same
as domain events (facts on the bus for side-effects) — they are orthogonal despite both touching
Miewdiator. Independent of the event system; tested with plain unit tests.
7.4 Notifications — real, not stubs
IEmailSender → a MailKit SMTP sender; Mailtrap is the dev/test inbox (a hosted fake SMTP), with
a capture helper for assertions; prod points at the real SMTP/ESP. IRealtimeNotifier → a SignalR
hub with tenant-scoped groups. Both are consumable directly by command handlers (e.g. password
reset, invite) and — where appropriate — by domain-event handlers; they do not depend on the
event system. E10's email pipeline (Hangfire queue/retry/DLQ) later wraps the MailKit sender for
reliable delivery.
8. Open Questions
- When does at-least-once delivery become a hard requirement (i.e. when do we build the outbox)?
- Mailtrap vs a local SMTP capture (e.g. a container) for dev/test — provisioning + credentials.
9. Edge Cases & Exception Handling
(to be extended by refine-feature)
10. Given-When-Then Acceptance Criteria
(per-task acceptance criteria live on each Task issue; see the Epic's sub-issues)
11. Technical Constraints & Dependencies
- Built test-first on the Testcontainers harness (the
tddskill governs backend code). - Domain events build on the existing Miewdiator +
Eventbase; aggregate collection + commit dispatch are the net-new parts. - CI must expose a Docker daemon for Testcontainers.
- Feeds E09 (audit), unblocks E02/E03; the MailKit sender is wrapped by E10.