Skip to main content

PRD-003: Platform Infrastructure

Author: MLT backend team | Date: 2026-08-18 | Status: Draft | Version: v0.1.0


Revision History

VersionDateAuthorDescription of Change
v0.1.02026-08-18MLT backend teamInitial 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 tdd skill).
  • 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 existing DomainError. The primary command-flow error path.
  • Real notificationsIEmailSender backed by MailKit (Mailtrap in dev/test) and IRealtimeNotifier backed 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 TypeDefinitionBaselineTarget
PrimaryRepos/UoW/migrations/events are testable against real Postgres; events dispatch on commit; email+realtime delivernoneworks end-to-end
Guard RailNo handler side-effect rolls back committed datacommitted data never reverted by a failing handler
Guard RailMigrations apply cleanly with no pending model changesgreen 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 + UnitOfWork per test class, applies EF migrations, and is opt-in (unit tests stay on EF-InMemory).

6.2 Domain events

  • SYS-REQ-201: An AggregateRoot base collects domain events (RaiseDomainEvent, read-only DomainEvents, ClearDomainEvents); events derive from the existing Event base.
  • SYS-REQ-202: UnitOfWork.CommitAsync, after SaveChangesAsync succeeds, 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 a DomainError (reusing the existing item→bag error stack: DomainNotificationDomainError). Minimal surface: LINQ Select/SelectMany (sync + async) so steps compose as from … select …, and Match to consume it; implicit construction from a value or a DomainError. A failed step short-circuits the rest; guards return explicit DomainErrors (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 existing ApiController.FromResult<T>FromError). The notification-bus pattern (DomainNotificationHandler collecting published DomainNotifications) 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: IEmailSender backed by a MailKit SMTP sender; dev/test target Mailtrap (config-driven, secrets out of code); a test helper asserts sent mail.
  • SYS-REQ-402: IRealtimeNotifier backed 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 tdd skill governs backend code).
  • Domain events build on the existing Miewdiator + Event base; 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.