Opal Samples
Opal Samples is a marketplace for music sample packs — but the model is collaborative rather than catalogue-first. Artists submit individual sounds into a project, a label-side reviewer accepts the ones that fit, and once a project hits a readiness threshold an admin promotes it into a sellable product. Buyers pay through Stripe, every contributor on the underlying project earns a calculated share of each sale, and Stripe Connect transfers their earnings out on demand. The marketplace shape isn’t the novel bit; what’s worth describing is the system behind it — a single-process .NET modular monolith with ten domain modules, a React frontend that takes the audio-playback and asset-delivery problems seriously, event-driven communication on top of RabbitMQ, a Redis cache layer that invalidates itself, and a financial pipeline that’s auditable down to the penny.
Modular monolith
The codebase splits into ten domain modules under src/Modules/Opal.Modules.{Name}/ — Identity, Roster, Projects, Catalog, Commerce, Payouts, Media, Notifications, Admin, Audit — each one a self-contained vertical slice of Api/Application/Domain/Infrastructure. One Postgres schema per module; cross-module joins go through query handlers, never through entity navigation properties. The shared kernel (Opal.Shared) carries only what every module genuinely needs: Result<T>, base entities, integration-event contracts, the role and permission constants. The host project (Opal.Api) owns middleware, authentication, the Quartz scheduler, MassTransit wiring, and the handful of cross-module orchestrators — CompleteOrderCommand, PromoteProjectHandler, the deletion handlers — that have to coordinate writes across more than one module’s schema.
The reason this beats a microservice fan-out at this scale: one process to deploy, one connection pool to size, one transaction boundary I can actually rely on, and zero distributed-systems tax for problems that aren’t actually distributed. Modules talk through two channels — query handlers (synchronous, in-process) and integration events (asynchronous, durable). Neither path requires going through the wire just because a piece of data lives in a different schema.
Frontend
The frontend is React 19 with TypeScript, built with Vite, styled with Tailwind and shadcn/ui. State splits along the usual axis: server state lives in React Query — every endpoint has a typed hook in hooks/ that knows its cache key and invalidation rules — and ephemeral client state lives in Zustand stores. There’s no Redux layer trying to be both at once, and no useEffect chains attempting to mirror server state into local state.
The audio model is the part worth describing. A single HTMLAudioElement lives in useAudioPlayerStore and the entire app shares it, so clicking play on a new track in one component automatically pauses whatever was playing in another — no event bus, no refs threaded up the tree, no second <audio> element to keep in sync. WaveformPlayer is canvas-based with compact/standard/large variants, drawing buffered/played/unplayed regions through CSS custom properties (--waveform-unplayed, --waveform-played, --waveform-buffered) so the player picks up theme changes automatically. Waveform peaks are computed server-side during the asset pipeline (128 normalized values) and shipped as JSON; the canvas just paints them.
Asset URLs go through an indirection that’s invisible to most components and turns out to matter a lot. A request for /api/products/{id}/cover hits the backend, which builds a token-authenticated URL into the bunny.net pull zone and returns a 302 redirect to it; the browser follows the redirect transparently and the asset is served from the CDN with the auth check having happened exactly once. The application code never handles raw CDN URLs or signing tokens — it requests an opaque endpoint and the backend resolves it. Versioning is baked into the storage key itself: covers use a content hash, avatars use a tick count, submissions bump an AudioVersion integer used as a ?v= query param. Replacing an asset writes to a new key, the old one stays reachable for in-flight requests, and the CDN never serves stale bytes because the URL itself changed.
Route-level access control is policy-based on both sides. The backend declares policies like ActiveRosterMember (requires a roster role plus the IsRetired claim equal to "false") and applies them at the controller level; the frontend’s ActiveRosterRoute mirrors the same predicate on the client and silently redirects retired members away from project and contribution routes toward earnings. Suspending or retiring a user updates their security stamp, which invalidates the session at next request — the change takes effect immediately, not at next login.
CQRS, MediatR, and the Result pattern
Every command and query is a small record type dispatched through MediatR to a single handler. Commands mutate; queries don’t. Both return Result<T> — never throw for an expected failure. A user trying to cart-add an archived product, a discount code applied to an out-of-allowlist cart, a payout requested below the minimum threshold: all of those are domain outcomes, and the handler returns a Result.Failure<T>("…") that the controller turns into a 400 with a meaningful body. Exceptions are reserved for the genuinely exceptional — a deadlocked transaction, a webhook from a future version of Stripe — and only those reach the global exception handler.
Two pipeline behaviors wrap every handler:
LoggingBehaviorrecords the request name, the duration, and the success/failure path in a structured form, so any handler — past, present, or future — gets observability without each one re-inventing it.ValidationBehaviorruns the request’sIValidator<T>(FluentValidation) before the handler ever sees the message; failure short-circuits with aResult.Failurecarrying the validation errors, so the handler body never has to start with a defensive guard wall.
The shape is mechanical enough that adding a new feature is mostly typing: the request, its validator, the handler, the controller endpoint that dispatches it. Cross-cutting concerns sit in one place; domain logic sits in another. The bug class of “this handler forgot to validate” stops being possible because validation isn’t a thing the handler does — it’s a thing that happens to the handler.
Event-driven cross-module communication
The async path between modules is MassTransit on RabbitMQ. Integration events live in Opal.Shared and are the only thing modules know about each other — Commerce never references Catalog’s Product entity, it consumes a ProductUpdated event with the minimal payload the consumer actually needs.
Two MassTransit details bit during development and are worth recording.
Retry filters stack; they don’t override. Calling r.None() inside a consumer-specific endpoint configurator looks like it should opt out of the bus-level default retry policy, but it doesn’t — it composes underneath the default, runs after its three retries, and changes nothing. The structural fix lives in MassTransitExtensions: the AddConfigureEndpointsCallback that registers the default retry policy explicitly excludes endpoints with bespoke semantics, so the asset pipeline endpoint has no default to inherit from in the first place.
Concurrency caps belong on the consumer definition, not on the bus. AssetPipelineConsumerDefinition declares ConcurrentMessageLimit = 1 and a 30-minute UseTimeout. The pipeline downloads 300–500 MB of WAVs to a temp directory, processes them twice for 24-bit and 16-bit output, and writes back to S3 — running two in parallel on the same node would peg the disk, the network egress, and the CPU all at once. The single-message limit serializes pipeline invocations per node without serializing the rest of the bus.
Redis cache-aside with a central invalidation registry
Read-heavy queries — product detail pages, the active discount-code list, public artist profiles — go through a cache-aside layer in Opal.Shared/Infrastructure/Caching/. Keys carry an opal:v{version}: prefix; bumping the version is the nuclear-option flush, useful once during a schema-shaping refactor and otherwise left alone. Default TTL is six hours, which is long enough to actually offload the database under load and short enough that any invalidation bug heals itself before lunch.
The invalidation contract is what makes this maintainable. Every cache key the application reads is also declared in CacheInvalidationExtensions.ConfigureInvalidationRules() — a single method in the host project that says, in essentially declarative form, “when ProductUpdated fires, invalidate product:{id} and product-list:*.” The MassTransit consumer that watches each event reads its instructions from that registry, so the failure mode where someone adds a new cache read but forgets to wire its invalidation can’t happen — there’s no second place to wire it.
One piece of reverse-lookup machinery deserves a mention. Because product metadata (title, slug, cover image, genres) lives on the linked Project rather than on Product itself, editing a project has to invalidate the dependent product’s cache as well. ProjectUpdatedCacheConsumer does the reverse lookup: given a ProjectId, find the product whose SourceProjectId matches, invalidate its keys. The product-side cache never has to know it depends on a project — the consumer encodes that dependency in one place.
The asset pipeline
When an admin promotes a project, the work of turning a folder of accepted submissions into a downloadable product is fronted by a single AssetPipelineRequested event and run by AssetPipelineConsumer in four sequential phases:
- Load and stage — pull project, product, submissions, content types, and musical reference data into a
ProcessedSubmissionlist, then download every WAV from S3 intoopal-pipeline/on local disk. A pack is 300–500 MB and an in-memory pipeline would be a fast way to OOM the host. - Render audio — process every submission twice, at 24-bit and 16-bit. One-shots go through sample-rate conversion → silence trim → peak normalize → bit-depth conversion; loops skip the silence trim and the normalize, because either step would change the loop’s length or amplitude relative to the rest of the pack.
- Package — generate 128kbps MP3s plus 128-peak waveform JSON for the (up to eight) submissions flagged as previews and the single demo track, then build
24bit.zipand16bit.zippreserving the project’s folder structure, upload to Wasabi S3, hash with SHA-256, and writeProductPreview/ProductDemo/ProductDownloadrows to the catalog schema in one transaction. - Finalize — purge the bunny.net CDN for the affected URLs and publish
ProductUpdated.
Per-step status is persisted as JSONB on an AssetPipelineTrigger row keyed by the request — true for ok, false for failed, null for pending. The JSON keys are the names of the AssetPipelineStep enum members, so renaming an enum member would silently break every historical trigger row’s status display; the enum carries a “do not rename” comment that anyone about to edit it will see. The admin UI polls the trigger row every five seconds and renders a green/red/grey bar per phase, so an operator can see exactly which step a stuck pipeline got stuck on.
Two operational choices look at first glance like things to “fix”:
Retries are deliberately off. A failed pipeline writes its error to the trigger row and stops. The admin re-promotes from the dashboard. An automatic retry on a near-30-minute audio job that just failed at the upload step is almost always wasted compute — the underlying fault is either transient enough to clear in seconds (in which case the admin retries from the UI in seconds) or persistent (in which case three more 30-minute attempts solve nothing). AssetPipelineFaultConsumer exists purely to drain dead-letter messages so they don’t pile up.
The project is never auto-reverted on pipeline failure. A half-promoted product (with some asset rows but not others) is exactly the state the admin needs to see in order to diagnose the failure. Reverting would erase the evidence and force a from-scratch re-promotion that loses the partial work.
Order completion and the order-time earnings ledger
Earnings are computed at order completion time, not by a monthly batch job. The instant Stripe sends a checkout.session.completed webhook, the cross-module CompleteOrderCommand orchestrator runs a revenue waterfall over every line item in the order:
OrderItem.ChargedAmountMinor
→ minus platform fee (percent of charged)
→ roster pool (the remainder)
→ split into submission pool + management pool (percent each)
→ submission pool divided across contributors weighted by ContentType.ContributionWeight
→ management pool divided flat across distinct accepted-review reviewersThe calculation lives in OrderEarningsCalculationService — pure functions, no DbContext, fully unit-testable on synthetic input. It emits one ArtistEarning row per (OrderItemId, ArtistId, Pool, Type) tuple, each row carrying not just the final amount but also a snapshot of every percentage and weight that produced it. Recomputing from the snapshot months later, after the PayoutSettings row has been edited, still produces the original number — the row is a ledger entry, not a derivation that has to be re-evaluated.
A few details that matter under stress:
- All money is
long AmountMinorin GBP pence. No floats, no decimals, no currency conversion at the ledger layer. Conversion at the UI boundary only. - The rounding doesn’t lose pennies. Each waterfall stage floors to minor units and tracks the remainder; the remainder pennies redistribute to the top contributors at that stage. The invariant
SUM(artist_earnings_for_pool) == pool_amountholds exactly. Anyone who’s ever had to explain a £0.01 discrepancy on a quarterly statement knows why this matters. - Discount proration follows the same rule.
BuildLineItemsprorates a percentage discount across line items using floor + penny redistribution, writes the prorated minor-unit charge into eachOrderItem.ChargedAmountMinor, and that pre-rounded number is the authoritative input to the earnings waterfall. Earnings can’t drift from order totals because the rounding choice was made once, at the order layer. - If no reviewer qualifies for the management pool, it rolls into the submission pool rather than vanishing. The percentages stay constant on the ledger row regardless, which means the rollover is auditable after the fact.
Order completion, download row creation, earnings rows, and the FinancialAuditLog entry all commit inside a single TransactionScope that spans two DbContexts (Commerce and Payouts). Either the whole thing lands or none of it does. A webhook retry from Stripe finds the order already-Paid and short-circuits — order completion is keyed on the order plus Stripe’s session ID, idempotent by construction.
Refunds and chargebacks don’t mutate the original earnings. charge.refunded and charge.dispute.created webhooks dispatch their own orchestrators that create compensating negative ArtistEarning rows via ArtistEarning.CreateReversal, linked to the original sale row. Double-reversal protection lives in the factory method: a reversal whose target already has a reversal of the same type raises a domain error rather than producing two negatives on one sale. An artist’s current balance is SUM(AmountMinor) WHERE Status = Open, full stop — no second source of truth, no nightly job to keep it consistent.
Stripe Connect payouts
A connected artist clicks “request payout” once their Open balance is over the MinimumPayoutAmount threshold. The handler does four things, in order, inside a transaction:
FOR UPDATErow lock so two concurrent payout requests from the same artist serialize at the database rather than racing through the rest of the handler.- Move qualifying earnings to
ReservedForPayout. OnlyStatus = Openrows past theirAvailableAthold date (default 14 days post-order) move. The intermediate state means the rows are no longer counted in the balance but also not yet paid — if anything goes wrong between here and Stripe, releasing them back toOpenis one update. - Create the Stripe Transfer with an idempotency key of
payout:{payoutId}. Stripe deduplicates a retried request to the exact same transfer; the application never accidentally double-pays. - Persist a
Payoutrow inRequestedstate and publishPayoutRequested.
A filtered unique index does the heavy structural work: UNIQUE (ArtistId) WHERE Status IN (Requested, Processing). An artist can have any number of historical Completed/Failed payouts, but only one active one at a time, and the constraint is enforced by the database rather than by an if (existingPayout != null) return error check in the handler that races under contention.
Finalization is webhook-driven. StripeTransferPaidConsumer catches transfer.paid, finds the payout by Stripe’s transfer ID — with a fallback to the metadata-encoded payout ID, because Stripe’s webhook payloads have a couple of fields either of which can carry the identifier depending on the event flavor — flips the reservation to PaidOut, writes the audit log entry, and publishes PayoutCompleted. The audit-log write happens inline rather than via an event consumer; a separate consumer would create a window where the payout reads as completed but isn’t yet audited.
PayoutReconciliationJob runs every six hours as the back-pressure valve. Any payout stuck in Requested or Processing for more than 48 hours gets reconciled against Stripe’s transfer-status endpoint and either advanced or reverted accordingly. Stripe webhooks are reliable but not unconditionally so; the reconciliation job is the small operational safety net that catches the once-a-quarter case where a webhook just doesn’t arrive.
Testing without Moq
Moq doesn’t survive .NET 10 — its expression-tree machinery breaks on the version bump. Rather than patching around it, Opal.TestCommon/Fakes/ ships small hand-rolled fakes for the interfaces tests actually touch. FakePublishEndpoint is the representative one: a typed buffer of captured messages with HasPublished<TEvent>(predicate) and GetPublished<TEvent>() accessors, so asserting that order completion published a ProductUpdated event for a specific product is a single chained call rather than a verify expression. Each fake is a few dozen lines, and the accidental upside of being forced off the library is that mock-and-verify ceremony stopped sneaking into tests that didn’t need it.
What this enables
What ends up mattering, after all of this scaffolding, is what becomes uneventful because of it. An admin can re-promote a half-failed product in one click with the previous run’s failed step still visible. Pennies reconcile to zero on every quarterly payout statement because the rounding choice was made once, at the order layer, and propagated as data rather than re-derived. Adding a feature looks like typing a request, a validator, a handler, and an endpoint — the rest of the machinery is already there. The architecture isn’t the product; it’s what keeps the product cheap to change.